Files
2026-05-27 23:29:53 +01:00

12 KiB

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"