Initial Commit
This commit is contained in:
@@ -0,0 +1,234 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# 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 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"`
|
||||
Reference in New Issue
Block a user