12 KiB
Capability Spec Delta: storage-browser-indexeddb (storage-browser-indexeddb)
Package: @suaveplan/storage-browser-indexeddb
Status: PROPOSED
ADDED 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 AbstractStorageAND every documented method is a function on the instance
Scenario: CRUD round-trip
- WHEN the consumer calls
set(key, bytes)followed byget(key) - THEN the returned
Uint8Arraybyte-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
IndexedDbErrorwithcode === INDEXEDDB.INVALID_KEY
Scenario: oversize key rejection
- WHEN a consumer calls
storage.get("a".repeat(1025)) - THEN the call throws
IndexedDbErrorwithcode === 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
IndexedDbErrorwithcode === INDEXEDDB.INVALID_OPTIONS
Scenario: empty dbName
- WHEN a consumer passes
new IndexedDbStorage({ dbName: "" }) - THEN the call throws
IndexedDbErrorwithcode === 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
IndexedDbErrorwithcode === 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
expiresAtis in the past - THEN
getreturnsundefined,getMetadatareturnsnull,existsreturnsfalse
Scenario: live entry read returns value
- WHEN a consumer reads a key whose
expiresAtis in the future - THEN
getreturns 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 withsize === bytes.byteLength, numericcreatedAt/modifiedAt, andmetadata.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/1and the consumer callslist({ prefix: "user/" }) - THEN the result
keysare 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 === 2andhasMore === 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
Uint8Arraybyte-equals bytes[1..3)of the value
Scenario: negative offset rejection
- WHEN the consumer calls
getPartial(key, -1, 1) - THEN the call throws
IndexedDbErrorwithcode === 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_EXCEEDEDNotAllowedError→INDEXEDDB.PERMISSION_DENIEDVersionError→INDEXEDDB.VERSION_ERRORAbortError→INDEXEDDB.ABORTEDTransactionInactiveError→INDEXEDDB.TRANSACTION_ABORTED- Unknown errors →
INDEXEDDB.IO_FAILEDwith the original error preserved ascause.
Scenario: quota error mapping
- WHEN an underlying
putrejects withQuotaExceededError - THEN
setthrowsIndexedDbErrorwithcode === INDEXEDDB.QUOTA_EXCEEDEDand the original error attached ascause
Scenario: unknown error mapping
- WHEN an underlying call rejects with a generic
Error - THEN the package throws
IndexedDbErrorwithcode === 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.openis 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
MeterProviderand performsset(key, bytes) - THEN the provider's recorder receives one
writes_totalcounter increment, oneop_duration_mssample, and oneio_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()thencreateStorage({ 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")remainstrueand 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.indexedDBand a consumer callsdefaultIndexedDB().open("db") - THEN the call throws
IndexedDbErrorwithcode === INDEXEDDB.UNAVAILABLE
Verified by: src/idb-adapter/idb-adapter.test.ts::"throws INDEXEDDB.UNAVAILABLE lazily when globalThis.indexedDB is missing"