fluxer/packages/cache/BEST_PRACTICES.md
2026-02-17 12:22:36 +00:00

333 lines
11 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Cache package best practices
Working document. Append new lessons as they arise.
---
## 1. Extract shared logic into focused, single-responsibility files
When two or more providers (KV, Redis, InMemory) share identical logic, extract it
into a dedicated file rather than duplicating it. Each extracted file should own
exactly one concern:
| File | Concern |
|---|---|
| `CacheProviderTypes.tsx` | Shared interfaces consumed by providers and callers (`CacheLogger`, `CacheTelemetry`, `CacheKeyClassifier`) |
| `CacheSerialization.tsx` | JSON parse/stringify helpers with error handling and truncation |
| `CacheLockValidation.tsx` | Lock key/token format validation, token generation, key formatting |
| `CacheKeyClassification.tsx` | Mapping a cache key to a telemetry dimension string |
| `RedisClientTypes.tsx` | `RedisClient` and `RedisPipeline` interface contracts |
**Why:** duplicated code drifts apart over time, bugs get fixed in one copy but not
another, and readers have to mentally diff near-identical methods.
---
## 2. Separate public contract types from implementation files
Types that callers or tests need to import (`CacheLogger`, `CacheTelemetry`,
`RedisClient`) belong in their own files not buried inside a provider class.
**Rule of thumb:** if a type appears in an `import type` statement in a file that is
not the defining file, it deserves its own home.
**Before:**
```tsx
// KVCacheProvider.tsx defines AND exports CacheLogger
export interface CacheLogger { ... }
export class KVCacheProvider { ... }
```
**After:**
```tsx
// CacheProviderTypes.tsx sole owner of the type
export interface CacheLogger { ... }
// KVCacheProvider.tsx imports the type
import type {CacheLogger} from '@fluxer/cache/src/CacheProviderTypes';
```
---
## 3. Use an `instrumented` helper to collapse telemetry boilerplate
When every method in a class follows the same start/try/record-success/catch/record-error
pattern, extract it into a single private method:
```tsx
private async instrumented<T>(
operation: string,
key: string,
fn: () => Promise<T>,
statusResolver?: (result: T) => string,
): Promise<T> { ... }
```
Each call site then becomes one line of intent instead of 25 lines of ceremony:
```tsx
async get<T>(key: string): Promise<T | null> {
return this.instrumented('get', key, async () => {
const value = await this.client.get(key);
if (value == null) return null;
return safeJsonParse<T>(value, this.logger);
}, (result) => (result == null ? 'miss' : 'hit'));
}
```
**Why:** the telemetry pattern was copy-pasted across `get`, `set`, `delete` with
only the operation name and status differing. A helper makes the unique logic visible
and the boilerplate invisible.
---
## 4. Keep serialisation consistent
Use `serializeValue()` and `safeJsonParse()` from `CacheSerialization.tsx` inside the
cache package rather than bare `JSON.stringify` / `JSON.parse`. Benefits:
- Parse errors are caught and logged with truncated values, not swallowed or thrown
raw.
- A single place to add future concerns (metrics on parse failures, encoding changes,
etc.).
- Callers outside the cache package doing their own `JSON.stringify` for HTTP bodies,
worker queues, etc. do **not** need these helpers they are cache-specific.
---
## 5. Centralise validation and make it reusable
Lock key and token validation was copy-pasted across all three providers with identical
regexes and error messages. `CacheLockValidation.tsx` now owns:
- `validateLockKey(key)` throws on bad format
- `validateLockToken(token)` throws on bad format
- `generateLockToken()` `randomBytes(16).toString('hex')`
- `formatLockKey(key)` returns `lock:${key}`
**Guideline:** if the same regex or format string appears in more than one file, it
should be a named export in a shared module.
---
## 6. Import from the defining file ("horse's mouth")
Per project conventions: all callsites import directly from the file that defines the
symbol. Never re-export. Never create barrel files.
```tsx
// Good
import type {CacheLogger} from '@fluxer/cache/src/CacheProviderTypes';
// Bad importing a type from a file that re-exports it
import type {CacheLogger} from '@fluxer/cache/src/providers/KVCacheProvider';
```
When moving a type to a new home, grep for every import and update it in the same
change. Do not leave the old export as a compatibility shim.
---
## 7. Provider-specific config stays in the provider file
`KVCacheProviderConfig` stays in `KVCacheProvider.tsx` because it references
`IKVProvider`, which is specific to that implementation. Only types shared across
multiple providers get extracted.
**Heuristic:** if a type is only imported by a single file and its tests, it belongs
in that file.
---
## 8. Verify refactors with the full test suite, formatter, and type checker
After any structural refactor, run in order:
```bash
cd packages/cache && pnpm test # all existing tests pass
pnpm biome check --write packages/cache/src/ # formatting (run from repo root)
cd packages/cache && pnpm typecheck # type checking
```
Then spot-check downstream consumers:
```bash
cd packages/api && pnpm typecheck
```
Pre-existing failures in downstream packages are acceptable so long as **none of the
errors reference the refactored package**.
---
## 9. Do not over-extract
Not every use of `randomBytes` or `JSON.stringify` in the codebase needs to use the
cache package's helpers. Cache serialisation helpers are for **cache values**. Other
domains (SSO tokens, CSP nonces, HTTP bodies, worker queues) have their own
serialisation needs and should not couple to the cache package.
**Rule:** extract shared code within a bounded context (the cache package), not across
unrelated domains.
---
## 10. Naming conventions for extracted files
Follow the project's descriptive-filename convention. Avoid generic names:
| Good | Bad |
|---|---|
| `CacheLockValidation.tsx` | `Validation.tsx` |
| `CacheKeyClassification.tsx` | `Utils.tsx` |
| `CacheProviderTypes.tsx` | `Types.tsx` |
| `RedisClientTypes.tsx` | `RedisTypes.tsx` |
Domain-prefix every file so it is unambiguous in search results and import
auto-complete.
---
## 11. Avoid backwards-compatibility shims
When moving exports to new files, do not leave behind re-exports or aliases in the old
location. This creates indirection and lets stale imports survive indefinitely.
Instead:
1. Move the definition.
2. Update every import site in the same commit.
3. Delete the old export entirely.
---
## 12. Keep the abstract contract stable
`ICacheService` and `CacheMSetEntry` stay in `ICacheService.tsx`. They define the
public contract that all providers implement and all consumers depend on. Refactoring
provider internals should never change this file.
---
## 13. One import block, no blank lines inside
Per project conventions, keep a single contiguous import block at the top of each file
with no blank lines or code between imports. Let biome handle ordering.
```tsx
// Good
import {classifyKeyType} from '@fluxer/cache/src/CacheKeyClassification';
import {formatLockKey, generateLockToken} from '@fluxer/cache/src/CacheLockValidation';
import type {CacheLogger} from '@fluxer/cache/src/CacheProviderTypes';
import {safeJsonParse, serializeValue} from '@fluxer/cache/src/CacheSerialization';
import {ICacheService} from '@fluxer/cache/src/ICacheService';
```
---
## 14. Scope of `safeJsonParse` logger parameter
`safeJsonParse` accepts an optional `CacheLogger`. The KV provider passes its logger
so parse errors are reported. The Redis provider passes nothing (logs to console as a
fallback in the provider itself now removed in favour of silent null return). The
InMemory provider does not call `safeJsonParse` at all because it stores native values,
not serialised strings.
When adding a new provider, decide up front whether parse errors should be logged and
pass the logger accordingly.
---
## 15. Watch for non-atomic operations
The Redis provider's `acquireLock` was (and remains, pending a proper fix) non-atomic:
```tsx
await this.client.set(lockKey, token);
await this.client.expire(lockKey, ttlSeconds);
```
Between these two calls, the lock exists without a TTL. If the process crashes, the
lock is held forever. The KV provider uses `SET ... EX ... NX` in a single command,
which is correct. When implementing distributed primitives, always prefer atomic
operations.
---
## 16. Downstream callsites should use shared utilities
After extracting shared logic, audit the rest of the codebase for consumers that
duplicate the same logic inline. Common patterns:
- `lock:${key}` string templates → `formatLockKey(key)`
- `crypto.randomBytes(16).toString('hex')``generateLockToken()`
- `Math.random().toString(36)...``generateLockToken()` (also a security fix)
- Mock implementations with inline lock validation → import from `CacheLockValidation`
Update downstream callsites in the same change to prevent the shared code from
becoming an orphan.
---
## 17. Never use Math.random() for tokens
`Math.random()` is not cryptographically secure. For any token used in locking,
authentication, or session management, use `crypto.randomBytes()` or the shared
`generateLockToken()` utility.
```tsx
// bad
const token = Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15);
// good
import {generateLockToken} from '@fluxer/cache/src/CacheLockValidation';
const token = generateLockToken();
```
---
## 18. Remove no-op string operations
Watch for `.replace()` calls that silently match nothing:
```tsx
// bad — 'deletion_queue:rebuild_lock' doesn't contain 'lock:' as a prefix
// .replace('lock:', '') matches nothing and returns the original string unchanged
REBUILD_LOCK_KEY.replace('lock:', '')
// good — pass the key directly
REBUILD_LOCK_KEY
```
No-op operations are a sign the author assumed a different key format. Verify the
format and remove the dead code.
---
## 19. Mock implementations should use shared utilities
Test mocks (like `MockCacheService`) that implement lock acquisition should use the
same shared utilities as real providers:
```tsx
// bad — inline duplication
const lockKey = `lock:${key}`;
const token = crypto.randomBytes(16).toString('hex');
// good — shared utilities keep mocks consistent
import {formatLockKey, generateLockToken} from '@fluxer/cache/src/CacheLockValidation';
const lockKey = formatLockKey(key);
const token = generateLockToken();
```
This ensures mocks produce tokens in the same format as real providers and that
format changes propagate automatically.
---
## Changelog
| Date | Change |
|---|---|
| 2026-02-06 | Initial version from cache package refactoring |
| 2026-02-06 | Added downstream cleanup lessons (sections 1619) |