Files
suaveplan/openspec/specs/storage-browser-indexeddb/spec.md
2026-05-27 23:29:53 +01:00

236 lines
12 KiB
Markdown

<!-- markdownlint-disable MD024 -->
# Capability Spec: storage-browser-indexeddb
**Package**: `@suaveplan/storage-browser-indexeddb`
**Status**: ACTIVE
## Requirements
### Requirement: AbstractStorage conformance
`IndexedDbStorage` SHALL extend `AbstractStorage` and implement every abstract method (`get`, `set`, `delete`, `exists`, `list`, `clear`, `getMetadata`, `getWithMetadata`, `getSize`, `getPartial`, `setPartial`, `getItemSize`, `createReadStream`, `createWriteStream`) with the public-contract semantics each method declares in `@suaveplan/storage`.
#### Scenario: instance shape
- **WHEN** a consumer constructs `new IndexedDbStorage()`
- **THEN** the instance passes `instanceof AbstractStorage` AND every documented method is a function on the instance
#### Scenario: CRUD round-trip
- **WHEN** the consumer calls `set(key, bytes)` followed by `get(key)`
- **THEN** the returned `Uint8Array` byte-equals the input
**Verified by:** `src/indexeddb-storage/indexeddb-storage.test.ts::"round-trips Uint8Array values"` AND `src/indexeddb-storage/indexeddb-storage.spec-conformance.test.ts::"is an AbstractStorage instance"` AND `src/indexeddb-storage/indexeddb-storage.spec-conformance.test.ts::"implements every AbstractStorage abstract method"`
---
### Requirement: Capabilities matrix
`IndexedDbStorage.capabilities` SHALL be the frozen object `{ partialReads: true, partialWrites: false, nativeStreams: false, atomicWrites: true, contentAddressable: false }`.
#### Scenario: declared capabilities
- **WHEN** consumers read `storage.capabilities`
- **THEN** the value deep-equals the documented matrix
**Verified by:** `src/indexeddb-storage/indexeddb-storage.spec-conformance.test.ts::"declares the documented capabilities matrix"`
---
### Requirement: Strict key sanitisation
The package SHALL validate every caller-supplied key via `sanitizeKey` BEFORE any IndexedDB call. Rejections SHALL throw `IndexedDbError(INDEXEDDB.INVALID_KEY)` for empty strings, control characters, absolute paths, and `..` traversal segments, and `IndexedDbError(INDEXEDDB.KEY_TOO_LONG)` for keys over 1024 characters.
#### Scenario: path-traversal rejection
- **WHEN** a consumer calls `storage.get("../../etc/passwd")`
- **THEN** the call throws `IndexedDbError` with `code === INDEXEDDB.INVALID_KEY`
#### Scenario: oversize key rejection
- **WHEN** a consumer calls `storage.get("a".repeat(1025))`
- **THEN** the call throws `IndexedDbError` with `code === INDEXEDDB.KEY_TOO_LONG`
**Verified by:** `src/key-sanitizer/key-sanitizer.test.ts::"rejects path-traversal segments"` AND `src/key-sanitizer/key-sanitizer.test.ts::"rejects keys over MAX_KEY_LENGTH"` AND `src/indexeddb-storage/indexeddb-storage.spec-conformance.test.ts::"every INDEXEDDB code is thrown by at least one production path"`
---
### Requirement: Zod-validated options boundary
Constructing `IndexedDbStorage` SHALL throw `IndexedDbError(INDEXEDDB.INVALID_OPTIONS)` when the supplied options fail the `IndexedDbStorageOptionsSchema` Zod check. The thrown error SHALL carry the Zod error as `cause`.
#### Scenario: bad option type
- **WHEN** a consumer passes `new IndexedDbStorage({ dbName: 123 as never })`
- **THEN** the call throws `IndexedDbError` with `code === INDEXEDDB.INVALID_OPTIONS`
#### Scenario: empty dbName
- **WHEN** a consumer passes `new IndexedDbStorage({ dbName: "" })`
- **THEN** the call throws `IndexedDbError` with `code === INDEXEDDB.INVALID_OPTIONS`
**Verified by:** `src/indexeddb-storage/indexeddb-storage.spec-conformance.test.ts::"rejects malformed options with INDEXEDDB.INVALID_OPTIONS"` AND `src/indexeddb-storage/indexeddb-storage.spec-conformance.test.ts::"rejects empty dbName option with INDEXEDDB.INVALID_OPTIONS"`
---
### Requirement: AbortSignal support
Every public async method SHALL accept an optional `AbortSignal` as the final parameter. A pre-aborted signal SHALL cause the method to throw `IndexedDbError(INDEXEDDB.ABORTED)` BEFORE any IndexedDB call is issued.
#### Scenario: pre-aborted signal short-circuit
- **WHEN** a consumer calls any public method with an already-aborted signal
- **THEN** the call throws `IndexedDbError` with `code === INDEXEDDB.ABORTED`
**Verified by:** `src/indexeddb-storage/indexeddb-storage.test.ts::"rejects with INDEXEDDB.ABORTED when signal is pre-aborted"`
---
### Requirement: TTL via `expiresAt` field
When `set(key, bytes, { ttl })` is called with `ttl > 0`, the persisted row SHALL carry `expiresAt = now + ttl * 1000`. Read paths (`get`, `getMetadata`, `getWithMetadata`, `getPartial`, `exists`, `list`, `getSize`) SHALL skip entries whose `expiresAt` has passed; `get` / `getWithMetadata` / `getPartial` SHALL lazily delete the expired row.
#### Scenario: expired entry read returns null/undefined
- **WHEN** a consumer reads a key whose `expiresAt` is in the past
- **THEN** `get` returns `undefined`, `getMetadata` returns `null`, `exists` returns `false`
#### Scenario: live entry read returns value
- **WHEN** a consumer reads a key whose `expiresAt` is in the future
- **THEN** `get` returns the stored bytes
**Verified by:** `src/indexeddb-storage/indexeddb-storage.test.ts::"expires entries after TTL"` AND `src/indexeddb-storage/indexeddb-storage.test.ts::"does not expire entries without TTL"`
---
### Requirement: Per-entry metadata
Every `set` SHALL record `{ size, createdAt, modifiedAt, metadata? }` on the row. `createdAt` and `modifiedAt` SHALL use the injected `Clock` when supplied; the default is `Date.now()`. `getMetadata` / `getWithMetadata` SHALL surface this record.
#### Scenario: metadata round-trip
- **WHEN** a consumer calls `set(key, bytes, { metadata: { mimeType: "text/plain" } })`
- **THEN** `getMetadata(key)` returns a record with `size === bytes.byteLength`, numeric `createdAt`/`modifiedAt`, and `metadata.mimeType === "text/plain"`
**Verified by:** `src/indexeddb-storage/indexeddb-storage.test.ts::"records size, createdAt, modifiedAt, and custom metadata"`
---
### Requirement: Prefix-based listing with pagination
`list(options?)` SHALL return only live keys, sorted ascending. When `options.prefix` is supplied, only keys starting with that prefix are returned. When `options.limit` is supplied, the result is paginated; `hasMore` reflects whether more keys remain past `offset + limit`. `total` SHALL be the unfiltered prefix-matched count.
#### Scenario: prefix filter
- **WHEN** the store contains `user/1`, `user/2`, `admin/1` and the consumer calls `list({ prefix: "user/" })`
- **THEN** the result `keys` are exactly `["user/1", "user/2"]`
#### Scenario: pagination
- **WHEN** the store contains 3 keys and the consumer calls `list({ limit: 2, offset: 0 })`
- **THEN** the result `keys.length === 2` and `hasMore === true`
**Verified by:** `src/indexeddb-storage/indexeddb-storage.test.ts::"lists keys with prefix + pagination"`
---
### Requirement: Partial reads
`getPartial(key, offset, length, signal?)` SHALL return the requested byte range from the stored value, clamped to the value's byte length, or `null` when the key is absent / expired. Negative or non-integer `offset`/`length` SHALL throw `IndexedDbError(INDEXEDDB.INVALID_KEY)`.
#### Scenario: in-bounds slice
- **WHEN** the consumer calls `getPartial(key, 1, 2)` on a 5-byte value
- **THEN** the returned `Uint8Array` byte-equals bytes `[1..3)` of the value
#### Scenario: negative offset rejection
- **WHEN** the consumer calls `getPartial(key, -1, 1)`
- **THEN** the call throws `IndexedDbError` with `code === INDEXEDDB.INVALID_KEY`
**Verified by:** `src/indexeddb-storage/indexeddb-storage.test.ts::"returns the requested byte range from getPartial"` AND `src/indexeddb-storage/indexeddb-storage.test.ts::"rejects negative offset on getPartial"`
---
### Requirement: DOMException mapping
The package SHALL translate every IndexedDB DOMException at the boundary into a typed `IndexedDbError`:
- `QuotaExceededError` / `NS_ERROR_DOM_QUOTA_REACHED` → `INDEXEDDB.QUOTA_EXCEEDED`
- `NotAllowedError` → `INDEXEDDB.PERMISSION_DENIED`
- `VersionError` → `INDEXEDDB.VERSION_ERROR`
- `AbortError` → `INDEXEDDB.ABORTED`
- `TransactionInactiveError` → `INDEXEDDB.TRANSACTION_ABORTED`
- Unknown errors → `INDEXEDDB.IO_FAILED` with the original error preserved as `cause`.
#### Scenario: quota error mapping
- **WHEN** an underlying `put` rejects with `QuotaExceededError`
- **THEN** `set` throws `IndexedDbError` with `code === INDEXEDDB.QUOTA_EXCEEDED` and the original error attached as `cause`
#### Scenario: unknown error mapping
- **WHEN** an underlying call rejects with a generic `Error`
- **THEN** the package throws `IndexedDbError` with `code === INDEXEDDB.IO_FAILED`
**Verified by:** `src/indexeddb-storage/indexeddb-storage.test.ts::"maps QuotaExceededError to INDEXEDDB.QUOTA_EXCEEDED"` AND `src/indexeddb-storage/indexeddb-storage.test.ts::"maps unknown errors to INDEXEDDB.IO_FAILED preserving cause"`
---
### Requirement: Lifecycle dedup on open
Concurrent first-touch operations on a fresh `IndexedDbStorage` SHALL share a single in-flight `IDBOpenDBRequest` promise; only one database open SHALL be performed regardless of caller concurrency.
#### Scenario: concurrent first-touch opens once
- **WHEN** ten `get(key)` calls fan out concurrently before the database has opened
- **THEN** the underlying `IdbFactoryLike.open` is invoked exactly once
**Verified by:** `src/indexeddb-storage/indexeddb-storage.spec-conformance.test.ts::"concurrent first-touch shares a single open promise"`
---
### Requirement: OpenTelemetry surface
Every public operation SHALL emit one `op_duration_ms{op,outcome}` histogram sample, increment exactly one of `{reads,writes,deletes}_total{op,outcome}` counters on success, increment `errors_total{op,outcome}` on `error`/`aborted`, and (where bytes flow) record one `io_bytes{op,direction}` histogram sample. The instrument set SHALL respect a caller-installed `MeterProvider`.
#### Scenario: in-memory MeterProvider receives observations
- **WHEN** a consumer installs an in-memory `MeterProvider` and performs `set(key, bytes)`
- **THEN** the provider's recorder receives one `writes_total` counter increment, one `op_duration_ms` sample, and one `io_bytes{direction: "write"}` sample
**Verified by:** `src/indexeddb-storage/indexeddb-storage.spec-conformance.test.ts::"emits OTel instruments through an in-memory MeterProvider"` AND `src/otel/otel.test.ts::"records exactly one counter per successful operation"`
---
### Requirement: Factory registration is explicit and idempotent
`registerIndexedDbProvider()` SHALL register `IndexedDbStorage` under the `"indexeddb"` slot in `@suaveplan/storage/factory`. Repeated calls SHALL be no-ops. The package SHALL NOT self-register at module-evaluation time.
#### Scenario: round-trip via createStorage
- **WHEN** a consumer calls `registerIndexedDbProvider()` then `createStorage({ type: "indexeddb", config })`
- **THEN** the returned value is `instanceof IndexedDbStorage`
#### Scenario: idempotent
- **WHEN** a consumer calls `registerIndexedDbProvider()` three times in a row
- **THEN** `hasStorageProvider("indexeddb")` remains `true` and no error is thrown
**Verified by:** `src/registry/registry.test.ts::"registers the indexeddb provider and resolves it via createStorage"` AND `src/registry/registry.test.ts::"is idempotent across repeated registerIndexedDbProvider calls"`
---
### Requirement: Lazy default adapter
`defaultIndexedDB()` SHALL return an `IdbFactoryLike` whose `open` method throws `IndexedDbError(INDEXEDDB.UNAVAILABLE)` lazily — only when invoked — when `globalThis.indexedDB` is missing. Module import SHALL NOT touch `globalThis.indexedDB`.
#### Scenario: missing global throws on first call
- **WHEN** the runtime environment does not expose `globalThis.indexedDB` and a consumer calls `defaultIndexedDB().open("db")`
- **THEN** the call throws `IndexedDbError` with `code === INDEXEDDB.UNAVAILABLE`
**Verified by:** `src/idb-adapter/idb-adapter.test.ts::"throws INDEXEDDB.UNAVAILABLE lazily when globalThis.indexedDB is missing"`