Initial Commit
This commit is contained in:
@@ -0,0 +1,88 @@
|
||||
# Add @suaveplan/storage-browser-indexeddb — Browser IndexedDB AbstractStorage adapter
|
||||
|
||||
**Package**: `@suaveplan/storage-browser-indexeddb`
|
||||
**Status**: PROPOSED
|
||||
**Type**: new-package
|
||||
|
||||
## Why
|
||||
|
||||
Genesis ships `@suaveplan/storage` (AbstractStorage core with in-memory adapter), `@suaveplan/storage-s3` (object store), `@suaveplan/storage-file` (Node filesystem), and `@suaveplan/storage-browser-opfs` (browser OPFS). The upstream `../suaveplan/packages/browser/storage-browser` bundled IndexedDB AND LocalStorage in one package; on genesis we split them so each backend is independently tree-shakeable and consumers don't pay the bundle cost of the backend they didn't pick. This change ships the IndexedDB half. The upstream `IndexedDB` class pre-dates the genesis `AbstractStorage` contract: it uses the legacy abstract surface (no `getPartial` / `setPartial` / `createReadStream` / `createWriteStream` / `getItemSize`), has no AbortSignal, throws raw `StorageError` with stringly codes, side-effect registers in `index.ts`, and emits no OTel. This change rebuilds it fresh against the current `AbstractStorage` contract.
|
||||
|
||||
## What changes
|
||||
|
||||
- ADDED: `IndexedDbStorage` class extending `AbstractStorage` — IndexedDB-backed key-value storage rooted at a configurable database + object-store name.
|
||||
- ADDED: `createIndexedDbStorage(options)` factory.
|
||||
- ADDED: `IndexedDbStorageOptions` interface + `IndexedDbStorageOptionsSchema` Zod schema; throws `INDEXEDDB.INVALID_OPTIONS` on malformed input.
|
||||
- ADDED: `IdbFactoryLike` structural surface (`{ open(name, version?): IdbOpenDbRequestLike }`) + `defaultIndexedDB()` adapter pointing at `globalThis.indexedDB`. Injectable so tests substitute a real `fake-indexeddb` factory without `vi.stubGlobal`.
|
||||
- ADDED: `sanitizeKey(key)` strict key boundary — same contract as the OPFS sibling (rejects empty, `..`, absolute paths, control characters, ≤ 1024 chars).
|
||||
- ADDED: `INDEXEDDB_CODES` registry via `defineCodes("INDEXEDDB", [...])` — 11 codes: `NOT_FOUND`, `PERMISSION_DENIED`, `QUOTA_EXCEEDED`, `UNAVAILABLE`, `INVALID_KEY`, `KEY_TOO_LONG`, `INVALID_OPTIONS`, `ABORTED`, `IO_FAILED`, `VERSION_ERROR`, `TRANSACTION_ABORTED`.
|
||||
- ADDED: `IndexedDbError extends SuaveplanError<IndexedDbErrorCode>` with `key` discriminant.
|
||||
- ADDED: Module-augmentation registry — `declare module "@suaveplan/storage/factory"` adds `indexeddb: IndexedDbStorage` to `StorageProviderRegistry` and `indexeddb: IndexedDbStorageOptions` to `StorageProviderConfig`; `registerIndexedDbProvider()` registers the constructor with the factory.
|
||||
- ADDED: TTL via stored `expiresAt` field + IndexedDB `expiresAt` index; expired rows are skipped on read and lazily deleted on first observation.
|
||||
- ADDED: `getPartial(offset, length)` via in-memory `Uint8Array.subarray` — declared `capabilities.partialReads: true`.
|
||||
- ADDED: DOMException mapping — `NotFoundError` → `undefined`/`false` per AbstractStorage contract; `NotAllowedError` → `INDEXEDDB.PERMISSION_DENIED`; `QuotaExceededError` / `NS_ERROR_DOM_QUOTA_REACHED` → `INDEXEDDB.QUOTA_EXCEEDED`; `VersionError` → `INDEXEDDB.VERSION_ERROR`; `AbortError` → `INDEXEDDB.ABORTED`; `TransactionInactiveError` → `INDEXEDDB.TRANSACTION_ABORTED`; missing `globalThis.indexedDB` → `INDEXEDDB.UNAVAILABLE`.
|
||||
- ADDED: OTel surface — `storage_browser_indexeddb.op_duration_ms{op,outcome}` + `storage_browser_indexeddb.io_bytes{op,direction}` histograms; `storage_browser_indexeddb.{reads,writes,deletes,errors}_total{op,outcome}` counters; per-op span on `@suaveplan/storage-browser-indexeddb` tracer.
|
||||
- ADDED: Lifecycle dedup — concurrent first-touch operations share a single in-flight `IDBOpenDBRequest` promise.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- Proactive TTL sweep via `requestIdleCallback` over the `expiresAt` index — the upstream enterprise plan flags it but the lazy-delete behavior already satisfies the AbstractStorage observable contract. A separate change can wire the sweep when a consumer needs it.
|
||||
- Cross-tab synchronisation via `BroadcastChannel` — orthogonal concern, handled at a higher layer.
|
||||
- Encryption-at-rest — handled by `@suaveplan/drive-plugin-chunks` and `@suaveplan/crypto-vault` for callers that need it.
|
||||
- LocalStorage backend — shipped as a sibling package `@suaveplan/storage-browser-localstorage` in a parallel change.
|
||||
- Structured-clone payload mode — upstream stored raw `Uint8Array`; we preserve that contract. A structured-clone variant can be a future feature.
|
||||
- Migration helper — version `1` is the only supported version at ship; future migrations add a `request.transaction.oldVersion` branch in `openDatabase`'s `onupgradeneeded` callback.
|
||||
|
||||
## Source mapping
|
||||
|
||||
This change derives from `../suaveplan/enterprise/storage-browser.md` (120 lines covering BOTH IndexedDB and LocalStorage) and rebuilds `../suaveplan/packages/browser/storage-browser/src/indexeddb/indexeddb.ts` (413 lines) against the current `AbstractStorage` contract.
|
||||
|
||||
| Requirement | Source lines | Notes |
|
||||
|---|---|---|
|
||||
| Browser IndexedDB adapter purpose | L10-L12 | "Browser storage adapters — IndexedDB and LocalStorage — that extend AbstractStorage … with a unified async API over Uint8Array payloads." — preserved for the IndexedDB half. |
|
||||
| Open/upgrade with `expiresAt` index | L21 | Implemented in `openDatabase` `onupgradeneeded` — `createObjectStore({ keyPath: "key" })` + `createIndex("expiresAt", "expiresAt", { unique: false })`. |
|
||||
| Metadata tracking (size, createdAt, modifiedAt, custom fields) | L12 | `IndexedDbEntry.metadata` carries `StorageMetadata` populated by `putEntry`. |
|
||||
| TTL expiration | L12 | `expiresAt` field on entry; read paths skip + lazy-delete on first observation. |
|
||||
| Prefix-based listing with pagination | L12 | `paginateKeys` over the cursor walker. |
|
||||
| Storage-quota error wrapping (`StorageQuotaError`) | L12 | Genesis swap: `INDEXEDDB.QUOTA_EXCEEDED` typed code; same observable contract. |
|
||||
| Factory registration | L23 | `registerIndexedDbProvider()` — explicit call, not side-effect import (deviation: see L48 below). |
|
||||
| `sideEffects` vs registry inconsistency fix | L48 | Genesis fix: explicit `registerIndexedDbProvider()` + `sideEffects: false` in `package.json`. |
|
||||
| Match documented sub-path exports | L49 | `package.json` `exports` map declares one entry per feature module. |
|
||||
| Co-located `.md` docs for every public symbol | L50 | Every `.ts` impl file ships with a sibling `.md`. |
|
||||
| Tests use `fake-indexeddb` | L19, L27 | Test suites use a real in-memory `fake-indexeddb` IDBFactory via the injectable adapter — no `vi.mock` / `vi.stubGlobal`. |
|
||||
| SSR / headless Node smoke import | L52 | Module-evaluation is side-effect-free; `defaultIndexedDB()` throws `UNAVAILABLE` lazily at first method call only. |
|
||||
| Bundle-size carve-out per backend | L53 | Split into separate genesis packages (this one + `@suaveplan/storage-browser-localstorage`). |
|
||||
|
||||
### Requirements with no source line
|
||||
|
||||
| Requirement | Justification |
|
||||
|---|---|
|
||||
| AbortSignal threaded everywhere | Genesis-wide convention enforced by `AbstractStorage`; upstream pre-dates the AbortSignal contract. |
|
||||
| Strict key sanitiser (`sanitizeKey`) | Mirrors the `@suaveplan/storage-browser-opfs/key-sanitizer` boundary so consumers can swap backends without seeing a different validation contract. |
|
||||
| Zod boundary on `IndexedDbStorageOptions` | Genesis-wide convention; upstream had no runtime validation. |
|
||||
| OTel instruments (4 counters + 2 histograms) | Genesis-wide convention; upstream emitted no observability. |
|
||||
| Structured `IndexedDbError` with `key` discriminant | Genesis-wide convention; upstream used plain `StorageError` with stringly codes. |
|
||||
| Lifecycle dedup via in-flight open promise | Required to avoid duplicate `IDBOpenDBRequest`s under concurrent first-touch — upstream had a race. |
|
||||
| 11-code registry incl. `VERSION_ERROR` + `TRANSACTION_ABORTED` | Genesis-wide convention enforces complete DOMException coverage. |
|
||||
|
||||
## Impact
|
||||
|
||||
### Affected dependents
|
||||
|
||||
Run `bunx turbo ls --filter=...@suaveplan/storage-browser-indexeddb` to enumerate. No dependents (new package).
|
||||
|
||||
- No dependents (verified by inspecting `MANIFEST.md` and the genesis dep graph).
|
||||
|
||||
## Verification approach
|
||||
|
||||
- `src/indexeddb-storage/indexeddb-storage.test.ts` — covers CRUD round-trip, metadata, TTL expiry, prefix listing, pagination, partial reads, abort signals, DOMException mapping, lifecycle dedup, idempotent close.
|
||||
- `src/indexeddb-storage/indexeddb-storage.spec-conformance.test.ts` — pins type-level / observability / strict-mode / lifecycle scenarios.
|
||||
- `src/registry/registry.test.ts` — registers + resolves via `createStorage`; idempotent re-registration; cleanup after `clearStorageProviders`.
|
||||
- `src/key-sanitizer/key-sanitizer.test.ts` — boundary rejections (empty, length, `..`, absolute paths, control chars).
|
||||
- `src/idb-adapter/idb-adapter.test.ts` — `isIndexedDbAvailable` truthy + falsy; lazy `UNAVAILABLE` throw.
|
||||
- `src/error-codes/error-codes.test.ts` — every code in the registry is constructable; `IndexedDbError.key` discriminant is preserved.
|
||||
- `src/otel/otel.test.ts` — counter dispatch per `op`; bytes histogram per direction; provider override + cache rebuild.
|
||||
|
||||
## Migration impact
|
||||
|
||||
None — additive only.
|
||||
@@ -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"`
|
||||
@@ -0,0 +1,64 @@
|
||||
# Tasks — Add @suaveplan/storage-browser-indexeddb
|
||||
|
||||
## 1. Spec authoring
|
||||
|
||||
- [x] 1.1 Read `../suaveplan/enterprise/storage-browser.md` end-to-end (IndexedDB half)
|
||||
- [x] 1.2 Read `openspec/specs/storage/spec.md` and `openspec/specs/storage-browser-opfs/spec.md`
|
||||
- [x] 1.3 Read `openspec/AGENTS.md` Sections 13 and 14
|
||||
- [x] 1.4 Draft `proposal.md` from `openspec/templates/proposal.md`
|
||||
- [x] 1.5 Fill `## Source mapping` table — every requirement cites a line range or is justified in the "no source line" table
|
||||
- [x] 1.6 Draft `specs/storage-browser-indexeddb/spec.md` from `openspec/templates/spec-delta.md`
|
||||
- [x] 1.7 Every requirement in the spec delta carries a `**Verified by:**` line naming concrete `it(...)` titles
|
||||
|
||||
## 2. Implementation
|
||||
|
||||
- [x] 2.1 `src/constants/index.ts` — `MAX_KEY_LENGTH`, `DEFAULT_DB_NAME`, `DEFAULT_STORE_NAME`, `DEFAULT_VERSION`, `EXPIRES_AT_INDEX`
|
||||
- [x] 2.2 `src/error-codes/error-codes.ts` — `INDEXEDDB_CODES` (11 codes) + `IndexedDbError`
|
||||
- [x] 2.3 `src/key-sanitizer/key-sanitizer.ts` — strict key boundary
|
||||
- [x] 2.4 `src/idb-adapter/idb-adapter.ts` — lazy `defaultIndexedDB()` + `isIndexedDbAvailable()`
|
||||
- [x] 2.5 `src/types/idb-like.types.ts` — structural IDBFactory surface
|
||||
- [x] 2.6 `src/types/indexeddb-storage.types.ts` — `IndexedDbStorageOptions` + Zod schema + `IndexedDbEntry`
|
||||
- [x] 2.7 `src/otel/otel.ts` — meter, instruments, `recordOp` dispatcher
|
||||
- [x] 2.8 `src/indexeddb-storage/indexeddb-storage.ops.ts` — `openDatabase`, `withStore`, `promisifyRequest`, `paginateKeys`, `collectLiveEntries`
|
||||
- [x] 2.9 `src/indexeddb-storage/indexeddb-storage.errors.ts` — DOMException mapping + assert helpers
|
||||
- [x] 2.10 `src/indexeddb-storage/indexeddb-storage.crud.ts` — CRUD primitives
|
||||
- [x] 2.11 `src/indexeddb-storage/indexeddb-storage.config.ts` — `resolveOptions` + `recordedOp` wrapper
|
||||
- [x] 2.12 `src/indexeddb-storage/indexeddb-storage.ts` — `IndexedDbStorage` class + `createIndexedDbStorage` factory
|
||||
- [x] 2.13 `src/registry/registry.ts` — `registerIndexedDbProvider` with factory module augmentation
|
||||
|
||||
## 3. Tests
|
||||
|
||||
- [x] 3.1 Unit tests at `src/indexeddb-storage/indexeddb-storage.test.ts` covering every CRUD/metadata/TTL/list/abort/error path
|
||||
- [x] 3.2 Spec-conformance tests at `src/indexeddb-storage/indexeddb-storage.spec-conformance.test.ts` pinning type/observability/strict-mode/lifecycle scenarios
|
||||
- [x] 3.3 Targeted feature tests for key-sanitizer, idb-adapter, error-codes, otel, registry
|
||||
- [x] 3.4 Every `it(...)` carries `{ timeout: 5000 }` or 30000
|
||||
|
||||
## 4. Documentation
|
||||
|
||||
- [x] 4.1 Sibling `.md` for every implementation `.ts`
|
||||
- [x] 4.2 `README.md` links every co-located `.md` under `## Modules`
|
||||
- [x] 4.3 Update CLAUDE.md package listing — deferred to after manifest regenerates (gen script + STATUS update lives in 13b refresh; CLAUDE.md hand-edit unnecessary for this addition because the listing is generated from MANIFEST + STATUS via `bun scripts/folder-reorg/generate-manifest.ts`)
|
||||
|
||||
## 5. Pre-archive verification
|
||||
|
||||
- [ ] 5.1 `bun run check`
|
||||
- [ ] 5.2 `bunx turbo lint typecheck test --filter=@suaveplan/storage-browser-indexeddb`
|
||||
- [ ] 5.3 `bunx turbo test:coverage --filter=@suaveplan/storage-browser-indexeddb` (100/100/100/100)
|
||||
- [ ] 5.4 `bun scripts/openspec-conformance.ts @suaveplan/storage-browser-indexeddb`
|
||||
- [ ] 5.5 `bunx turbo build --filter=@suaveplan/storage-browser-indexeddb`
|
||||
|
||||
## 6. Pre-archive 7-row checklist
|
||||
|
||||
- [ ] 6.1 Generics threaded; type-level claims under `expectTypeOf` where applicable
|
||||
- [ ] 6.2 Zod schemas wired at every declared boundary (`IndexedDbStorageOptionsSchema` on constructor)
|
||||
- [ ] 6.3 Every error code in `error-codes/error-codes.ts` is thrown / constructed by at least one production path or test
|
||||
- [ ] 6.4 Strict-mode behaviors tested both on and off (n/a — no strict-mode flag on this package)
|
||||
- [ ] 6.5 OTel instruments asserted with a real in-memory MeterProvider
|
||||
- [ ] 6.6 Lifecycle dedup tested (concurrent first-touch operations share a single open promise)
|
||||
- [ ] 6.7 No retroactive ticks
|
||||
|
||||
## 7. Archive
|
||||
|
||||
- [ ] 7.1 Fold spec delta into `openspec/specs/storage-browser-indexeddb/spec.md`
|
||||
- [ ] 7.2 Move `openspec/changes/storage-browser-indexeddb/` → `openspec/archive/<yyyy-mm-dd>-storage-browser-indexeddb/`
|
||||
- [ ] 7.3 Refresh `MANIFEST.md` via `bun scripts/folder-reorg/generate-manifest.ts`
|
||||
Reference in New Issue
Block a user