Initial Commit
This commit is contained in:
@@ -0,0 +1,186 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: cache-storage (cache-storage)
|
||||
|
||||
**Package**: `@suaveplan/cache-storage`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: StorageCache extends AbstractCache<T>
|
||||
|
||||
The package SHALL provide `StorageCache<T> extends AbstractCache<T>` (from `@suaveplan/cache`). The class SHALL implement every method on `AbstractCacheShape<T>`: `get`, `set`, `delete`, `clear`, `has`, `getStats`, `getMultiple`, `setMultiple`, `wrap`. The constructor SHALL accept `{ storage: AbstractStorage; codec?: Codec<T>; defaultTtl?: number; clock?: Clock; events?: EventBusLike<CacheEvents>; namespace?: string }` with `codec` defaulting to `JsonCodec<T>` and `defaultTtl` defaulting to `0` (no expiry).
|
||||
|
||||
#### Scenario: instance is an AbstractCache and an AbstractCacheShape
|
||||
|
||||
- **WHEN** a caller constructs `new StorageCache({ storage: new MemoryStorage() })`
|
||||
- **THEN** the instance is `instanceof AbstractCache` AND every `AbstractCacheShape<T>` method exists on it
|
||||
|
||||
#### Scenario: inherited wrap delegates to set on miss
|
||||
|
||||
- **WHEN** a caller invokes `cache.wrap("k", async () => 42)` on a miss
|
||||
- **THEN** the loader is called exactly once AND a subsequent `cache.get("k")` returns `42`
|
||||
|
||||
**Verified by:** `src/storage-cache/storage-cache.test.ts::"instance is an AbstractCache and exposes the AbstractCacheShape surface"` AND `src/storage-cache/storage-cache.test.ts::"inherited wrap caches the loader result on a miss"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: 32-byte fixed-width metadata header
|
||||
|
||||
`StorageCache` SHALL prepend a 32-byte fixed-width header to every stored value. The header layout SHALL be: bytes 0..7 `createdAt` u64 BE; bytes 8..15 `expiresAt` u64 BE (`0` means no expiry); bytes 16..19 `flags` u32 BE; bytes 20..23 `hitCount` u32 BE; bytes 24..31 reserved (zero). The package SHALL export `HEADER_BYTES = 32`, `encodeHeader(meta) → Uint8Array`, and `decodeHeader(bytes) → HeaderMeta`.
|
||||
|
||||
#### Scenario: header round-trips every field
|
||||
|
||||
- **WHEN** a caller calls `decodeHeader(encodeHeader({ createdAt: 1, expiresAt: 2, flags: 3, hitCount: 4 }))`
|
||||
- **THEN** the result is `{ createdAt: 1, expiresAt: 2, flags: 3, hitCount: 4 }`
|
||||
|
||||
#### Scenario: decodeHeader on a buffer shorter than 32 bytes throws ENTRY_CORRUPT
|
||||
|
||||
- **WHEN** a caller calls `decodeHeader(new Uint8Array(16))`
|
||||
- **THEN** the call throws `StorageCacheError` with `code === "CACHE_STORAGE.ENTRY_CORRUPT"`
|
||||
|
||||
**Verified by:** `src/header/header.test.ts::"header.encode then header.decode round-trips every field"` AND `src/header/header.test.ts::"decodeHeader throws ENTRY_CORRUPT on bytes shorter than HEADER_BYTES"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: TTL expiration is lazy on read
|
||||
|
||||
`StorageCache.get` SHALL parse the stored header and compare `expiresAt` against `clock()`. When `expiresAt !== 0 && clock() >= expiresAt`, `get` SHALL invoke `storage.delete(prefixedKey)` AND return `undefined`. When `expiresAt === 0`, the entry SHALL never expire. `set(key, value, { ttl })` with `ttl > 0` SHALL compute `expiresAt = clock() + ttl`. `set` with `ttl === undefined` SHALL use `defaultTtl`.
|
||||
|
||||
#### Scenario: get returns undefined and deletes when entry is expired
|
||||
|
||||
- **WHEN** a caller `set`s a key with `ttl: 10` (ms), advances an injected clock past expiry, then `get`s the key
|
||||
- **THEN** the call returns `undefined` AND `storage.exists(prefixedKey)` returns `false`
|
||||
|
||||
#### Scenario: entry with expiresAt 0 never expires
|
||||
|
||||
- **WHEN** a caller `set`s a key with no `ttl` and `defaultTtl: 0`, then advances the clock by 10 days, then `get`s the key
|
||||
- **THEN** the call returns the stored value
|
||||
|
||||
**Verified by:** `src/storage-cache/storage-cache.test.ts::"get returns undefined and deletes when entry has expired"` AND `src/storage-cache/storage-cache.test.ts::"entry with no ttl never expires"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: ENTRY_CORRUPT on short blob from underlying storage
|
||||
|
||||
When `storage.get(prefixedKey)` returns a `Uint8Array` shorter than `HEADER_BYTES`, `StorageCache.get` SHALL throw `StorageCacheError` with `code === "CACHE_STORAGE.ENTRY_CORRUPT"` and `key` set to the original (unprefixed) key.
|
||||
|
||||
#### Scenario: short blob surfaces as ENTRY_CORRUPT
|
||||
|
||||
- **WHEN** a fake `AbstractStorage` returns `new Uint8Array(16)` for `get(prefixedKey)` AND the caller calls `cache.get(originalKey)`
|
||||
- **THEN** the call throws `StorageCacheError` with `code === "CACHE_STORAGE.ENTRY_CORRUPT"` AND `error.key === originalKey`
|
||||
|
||||
**Verified by:** `src/storage-cache/storage-cache.test.ts::"get throws ENTRY_CORRUPT when underlying storage returns fewer than HEADER_BYTES bytes"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Codec<T> decouples T from byte storage
|
||||
|
||||
The package SHALL declare `Codec<T>` with `encode(value: T): Uint8Array` and `decode(bytes: Uint8Array): T`. The package SHALL export two codecs: `IdentityCodec` (constrains `T = Uint8Array`; pass-through identity) and `JsonCodec<T>` (UTF-8 JSON via `TextEncoder`/`TextDecoder`). Decode failure SHALL throw `StorageCacheError` with `code === "CACHE_STORAGE.CODEC_ERROR"`. `JsonCodec` SHALL be the default codec used by `StorageCache` when none is provided.
|
||||
|
||||
#### Scenario: JsonCodec round-trips a structured value
|
||||
|
||||
- **WHEN** a caller encodes `{ user: "alice", roles: ["admin"] }` AND then decodes the result
|
||||
- **THEN** the decoded value deep-equals the input
|
||||
|
||||
#### Scenario: JsonCodec.decode throws CODEC_ERROR on malformed bytes
|
||||
|
||||
- **WHEN** a caller calls `new JsonCodec().decode(new Uint8Array([0xff, 0xfe, 0xfd]))`
|
||||
- **THEN** the call throws `StorageCacheError` with `code === "CACHE_STORAGE.CODEC_ERROR"`
|
||||
|
||||
#### Scenario: IdentityCodec passes Uint8Array through unchanged
|
||||
|
||||
- **WHEN** a caller calls `new IdentityCodec().decode(new IdentityCodec().encode(bytes))`
|
||||
- **THEN** the result is byte-identical to `bytes`
|
||||
|
||||
**Verified by:** `src/codec/json.test.ts::"round-trips a structured value through encode then decode"` AND `src/codec/json.test.ts::"decode throws CODEC_ERROR on malformed bytes"` AND `src/codec/identity.test.ts::"encode then decode preserves the byte sequence"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: CACHE_STORAGE error registry
|
||||
|
||||
The package SHALL declare `CACHE_STORAGE_CODES` via `defineCodes("CACHE_STORAGE", [...])` with exactly these 5 codes: `ENTRY_CORRUPT`, `CODEC_ERROR`, `BACKEND_ERROR`, `KEY_TOO_LONG`, `VALUE_TOO_LARGE`. The package SHALL declare `StorageCacheError extends SuaveplanError<CacheStorageCode>` with optional `key` discriminant. Every code SHALL be reachable from at least one test in this package.
|
||||
|
||||
#### Scenario: every declared CACHE_STORAGE_* code is reachable
|
||||
|
||||
- **WHEN** the test suite runs
|
||||
- **THEN** for every entry in `CACHE_STORAGE_CODES`, at least one assertion exercises a real call site that produces that code
|
||||
|
||||
**Verified by:** `src/errors/errors.test.ts::"every CACHE_STORAGE code is reachable from a real call site"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Module augmentation registers "storage" provider
|
||||
|
||||
The package SHALL `declare module "@suaveplan/cache"` and augment `CacheProviderRegistry` with `storage: typeof StorageCache`. The package SHALL side-effect-import a `registry-augment` module that calls `registerCacheProvider("storage", StorageCache)` exactly once via an idempotent guard. After importing `@suaveplan/cache-storage`, `hasCacheProvider("storage")` SHALL return `true` AND `createCache("storage", { storage })` SHALL return a `StorageCache`.
|
||||
|
||||
#### Scenario: side-effect import registers the "storage" provider
|
||||
|
||||
- **WHEN** the consumer imports `@suaveplan/cache-storage` AND calls `hasCacheProvider("storage")`
|
||||
- **THEN** the result is `true`
|
||||
|
||||
#### Scenario: createCache("storage", config) returns a StorageCache instance
|
||||
|
||||
- **WHEN** a caller calls `createCache("storage", { storage: new MemoryStorage() })`
|
||||
- **THEN** the result is `instanceof StorageCache` AND `instanceof AbstractCache`
|
||||
|
||||
**Verified by:** `src/registry-augment/registry-augment.test.ts::"hasCacheProvider returns true after import"` AND `src/registry-augment/registry-augment.test.ts::"createCache(\"storage\", ...) returns a StorageCache instance"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Namespacing prefixes every stored key
|
||||
|
||||
When `namespace` is provided in the constructor and is non-empty, `StorageCache` SHALL prefix every key passed to the underlying `AbstractStorage` with `${namespace}:`. The unprefixed key SHALL be carried on any thrown `StorageCacheError` via the `key` discriminant.
|
||||
|
||||
#### Scenario: namespaced set writes to the prefixed key
|
||||
|
||||
- **WHEN** a `StorageCache` constructed with `namespace: "users"` calls `set("alice", value)`
|
||||
- **THEN** the underlying `storage.set` is invoked with key `"users:alice"`
|
||||
|
||||
**Verified by:** `src/storage-cache/storage-cache.test.ts::"namespace prefixes keys before delegating to the underlying storage"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Lifecycle events emitted when EventBusLike provided
|
||||
|
||||
When `events` is provided in the constructor, `StorageCache` SHALL emit `hit`, `miss`, `set`, `delete`, `clear`, and `expire` topics with payloads matching the `CacheEvents` map exported from `@suaveplan/cache`. When `events` is not provided, no event work SHALL be performed (zero-cost when unused).
|
||||
|
||||
#### Scenario: emits hit on get after set
|
||||
|
||||
- **WHEN** a caller subscribes to `hit`, `set`s a key, then `get`s it
|
||||
- **THEN** the `hit` listener is invoked exactly once with `{ key }`
|
||||
|
||||
#### Scenario: emits miss on get of an unknown key
|
||||
|
||||
- **WHEN** a caller subscribes to `miss` and calls `get("missing")`
|
||||
- **THEN** the `miss` listener is invoked exactly once with `{ key: "missing" }`
|
||||
|
||||
**Verified by:** `src/storage-cache/storage-cache.test.ts::"emits hit event when get returns a stored value"` AND `src/storage-cache/storage-cache.test.ts::"emits miss event when get returns undefined"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Stats counters via getStats
|
||||
|
||||
`StorageCache.getStats()` SHALL return a `CacheStats` with `hits`, `misses`, `evictions`, and `hitRate` reflecting client-side counters; `size` SHALL be a best-effort count maintained via `set`/`delete`. Counters SHALL be monotonically non-decreasing across the cache's lifetime; `clear()` SHALL reset `size` to `0` but SHALL NOT reset `hits`/`misses`/`evictions`.
|
||||
|
||||
#### Scenario: hitRate reflects hits over hits-plus-misses
|
||||
|
||||
- **WHEN** a caller records 7 hits and 3 misses
|
||||
- **THEN** `getStats().hitRate === 0.7`
|
||||
|
||||
**Verified by:** `src/storage-cache/storage-cache.test.ts::"hitRate computes hits over hits-plus-misses"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Tier purity — universal
|
||||
|
||||
The package SHALL NOT import from `node:*`, `bun:*`, or any DOM-only API. The package SHALL run unmodified under Node 18+, Bun, Deno, modern browsers, and Cloudflare Workers.
|
||||
|
||||
#### Scenario: tier-purity grep finds no forbidden imports
|
||||
|
||||
- **WHEN** `grep -rnE "from ['\"](node:|bun:)" packages/flow/cache-storage/src/` is run
|
||||
- **THEN** the output is empty
|
||||
|
||||
**Verified by:** `src/storage-cache/storage-cache.test.ts::"src tree contains no node: or bun: imports"`
|
||||
Reference in New Issue
Block a user