diff --git a/docs/reviews/adapters/INVENTORY.md b/docs/reviews/adapters/INVENTORY.md index aec2102..a4d7274 100644 --- a/docs/reviews/adapters/INVENTORY.md +++ b/docs/reviews/adapters/INVENTORY.md @@ -74,68 +74,70 @@ | 64 | `src/adapters/platform/browser-mutation-intent-factory.ts` | [Network/state](./01-network-and-state.md) | | 65 | `src/adapters/platform/bounded-capacity.ts` | [Network/state](./01-network-and-state.md) | | 66 | `src/adapters/platform/index.ts` | [Network/state](./01-network-and-state.md) | -| 67 | `src/adapters/platform/system-clock.ts` | [Network/state](./01-network-and-state.md) | -| 68 | `src/adapters/query-cache/conditional-validator-store.ts` | [Network/state](./01-network-and-state.md) | -| 69 | `src/adapters/query-cache/cursor-pagination-runtime.ts` | [Network/state](./01-network-and-state.md) | -| 70 | `src/adapters/query-cache/index.ts` | [Network/state](./01-network-and-state.md) | -| 71 | `src/adapters/query-cache/server-state-scope-runtime.ts` | [Network/state](./01-network-and-state.md) | -| 72 | `src/adapters/query-cache/tanstack-cache-coordinator.ts` | [Network/state](./01-network-and-state.md) | -| 73 | `src/adapters/query-cache/tanstack-query-cache.ts` | [Network/state](./01-network-and-state.md) | -| 74 | `src/adapters/realtime/event-codec.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | -| 75 | `src/adapters/realtime/event-consumer.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | -| 76 | `src/adapters/realtime/index.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | -| 77 | `src/adapters/realtime/json-member-scanner.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | -| 78 | `src/adapters/realtime/live-poll-handoff-coordinator.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | -| 79 | `src/adapters/realtime/polling/bounded-poll-coordinator.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | -| 80 | `src/adapters/realtime/polling/index.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | -| 81 | `src/adapters/realtime/reconnect-coordinator.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | -| 82 | `src/adapters/realtime/reconnect-policy.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | -| 83 | `src/adapters/realtime/result.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | -| 84 | `src/adapters/realtime/sse/fetch-sse-connection.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | -| 85 | `src/adapters/realtime/sse/index.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | -| 86 | `src/adapters/realtime/sse/sse-parser.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | -| 87 | `src/adapters/realtime/stream-coordinator.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | -| 88 | `src/adapters/realtime/websocket/index.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | -| 89 | `src/adapters/realtime/websocket/websocket-connection.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | -| 90 | `src/adapters/realtime/websocket/websocket-protocol.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | -| 91 | `src/adapters/service-worker/index.ts` | [Worker/push](./05-service-worker-and-web-push.md) | -| 92 | `src/adapters/service-worker/service-worker-entry.ts` | [Worker/push](./05-service-worker-and-web-push.md) | -| 93 | `src/adapters/service-worker/service-worker-lifecycle.ts` | [Worker/push](./05-service-worker-and-web-push.md) | -| 94 | `src/adapters/service-worker/service-worker-page-controller.ts` | [Worker/push](./05-service-worker-and-web-push.md) | -| 95 | `src/adapters/service-worker/service-worker-protocol.ts` | [Worker/push](./05-service-worker-and-web-push.md) | -| 96 | `src/adapters/service-worker/service-worker-removal.ts` | [Worker/push](./05-service-worker-and-web-push.md) | -| 97 | `src/adapters/service-worker/service-worker-static-assets.ts` | [Worker/push](./05-service-worker-and-web-push.md) | -| 98 | `src/adapters/storage/browser-storage-adapter.ts` | [Storage/files](./03-storage-and-browser-files.md) | -| 99 | `src/adapters/storage/browser-storage-codec.ts` | [Storage/files](./03-storage-and-browser-files.md) | -| 100 | `src/adapters/storage/index.ts` | [Storage/files](./03-storage-and-browser-files.md) | -| 101 | `src/adapters/storage/indexeddb/index.ts` | [Storage/files](./03-storage-and-browser-files.md) | -| 102 | `src/adapters/storage/indexeddb/indexeddb-failure.ts` | [Storage/files](./03-storage-and-browser-files.md) | -| 103 | `src/adapters/storage/indexeddb/indexeddb-governance.ts` | [Storage/files](./03-storage-and-browser-files.md) | -| 104 | `src/adapters/storage/indexeddb/indexeddb-maintenance.ts` | [Storage/files](./03-storage-and-browser-files.md) | -| 105 | `src/adapters/storage/indexeddb/indexeddb-migrations.ts` | [Storage/files](./03-storage-and-browser-files.md) | -| 106 | `src/adapters/storage/indexeddb/indexeddb-runtime.ts` | [Storage/files](./03-storage-and-browser-files.md) | -| 107 | `src/adapters/storage/indexeddb/indexeddb-types.ts` | [Storage/files](./03-storage-and-browser-files.md) | -| 108 | `src/adapters/storage/opfs/browser-opfs-runtime.ts` | [Storage/files](./03-storage-and-browser-files.md) | -| 109 | `src/adapters/storage/opfs/index.ts` | [Storage/files](./03-storage-and-browser-files.md) | -| 110 | `src/adapters/storage/opfs/indexeddb-opfs-journal.ts` | [Storage/files](./03-storage-and-browser-files.md) | -| 111 | `src/adapters/storage/opfs/opfs-byte-store-adapter.ts` | [Storage/files](./03-storage-and-browser-files.md) | -| 112 | `src/adapters/storage/opfs/opfs-policy.ts` | [Storage/files](./03-storage-and-browser-files.md) | -| 113 | `src/adapters/storage/opfs/opfs-worker-client.ts` | [Storage/files](./03-storage-and-browser-files.md) | -| 114 | `src/adapters/storage/opfs/opfs-worker-protocol.ts` | [Storage/files](./03-storage-and-browser-files.md) | -| 115 | `src/adapters/storage/opfs/opfs-worker-runtime.ts` | [Storage/files](./03-storage-and-browser-files.md) | -| 116 | `src/adapters/telemetry/best-effort-telemetry.ts` | [Network/state](./01-network-and-state.md) | -| 117 | `src/adapters/telemetry/index.ts` | [Network/state](./01-network-and-state.md) | -| 118 | `src/adapters/web-push/inbound/notification-click-adapter.ts` | [Worker/push](./05-service-worker-and-web-push.md) | -| 119 | `src/adapters/web-push/inbound/push-event-adapter.ts` | [Worker/push](./05-service-worker-and-web-push.md) | -| 120 | `src/adapters/web-push/index.ts` | [Worker/push](./05-service-worker-and-web-push.md) | -| 121 | `src/adapters/web-push/notification-registry.ts` | [Worker/push](./05-service-worker-and-web-push.md) | -| 122 | `src/adapters/web-push/push-association-fence-store.ts` | [Worker/push](./05-service-worker-and-web-push.md) | -| 123 | `src/adapters/web-push/push-codec.ts` | [Worker/push](./05-service-worker-and-web-push.md) | -| 124 | `src/adapters/web-push/push-registration-gateway.ts` | [Worker/push](./05-service-worker-and-web-push.md) | -| 125 | `src/adapters/web-push/push-subscription-adapter.ts` | [Worker/push](./05-service-worker-and-web-push.md) | -| 126 | `src/adapters/web-push/runtime-support.ts` | [Worker/push](./05-service-worker-and-web-push.md) | -| 127 | `src/adapters/web-push/service-worker-runtime.ts` | [Worker/push](./05-service-worker-and-web-push.md) | -| 128 | `src/adapters/web-push/service-worker-scope-host.ts` | [Worker/push](./05-service-worker-and-web-push.md) | +| 67 | `src/adapters/platform/indexeddb-connection.ts` | [Network/state](./01-network-and-state.md) | +| 68 | `src/adapters/platform/indexeddb-transaction.ts` | [Network/state](./01-network-and-state.md) | +| 69 | `src/adapters/platform/system-clock.ts` | [Network/state](./01-network-and-state.md) | +| 70 | `src/adapters/query-cache/conditional-validator-store.ts` | [Network/state](./01-network-and-state.md) | +| 71 | `src/adapters/query-cache/cursor-pagination-runtime.ts` | [Network/state](./01-network-and-state.md) | +| 72 | `src/adapters/query-cache/index.ts` | [Network/state](./01-network-and-state.md) | +| 73 | `src/adapters/query-cache/server-state-scope-runtime.ts` | [Network/state](./01-network-and-state.md) | +| 74 | `src/adapters/query-cache/tanstack-cache-coordinator.ts` | [Network/state](./01-network-and-state.md) | +| 75 | `src/adapters/query-cache/tanstack-query-cache.ts` | [Network/state](./01-network-and-state.md) | +| 76 | `src/adapters/realtime/event-codec.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | +| 77 | `src/adapters/realtime/event-consumer.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | +| 78 | `src/adapters/realtime/index.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | +| 79 | `src/adapters/realtime/json-member-scanner.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | +| 80 | `src/adapters/realtime/live-poll-handoff-coordinator.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | +| 81 | `src/adapters/realtime/polling/bounded-poll-coordinator.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | +| 82 | `src/adapters/realtime/polling/index.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | +| 83 | `src/adapters/realtime/reconnect-coordinator.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | +| 84 | `src/adapters/realtime/reconnect-policy.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | +| 85 | `src/adapters/realtime/result.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | +| 86 | `src/adapters/realtime/sse/fetch-sse-connection.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | +| 87 | `src/adapters/realtime/sse/index.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | +| 88 | `src/adapters/realtime/sse/sse-parser.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | +| 89 | `src/adapters/realtime/stream-coordinator.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | +| 90 | `src/adapters/realtime/websocket/index.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | +| 91 | `src/adapters/realtime/websocket/websocket-connection.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | +| 92 | `src/adapters/realtime/websocket/websocket-protocol.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) | +| 93 | `src/adapters/service-worker/index.ts` | [Worker/push](./05-service-worker-and-web-push.md) | +| 94 | `src/adapters/service-worker/service-worker-entry.ts` | [Worker/push](./05-service-worker-and-web-push.md) | +| 95 | `src/adapters/service-worker/service-worker-lifecycle.ts` | [Worker/push](./05-service-worker-and-web-push.md) | +| 96 | `src/adapters/service-worker/service-worker-page-controller.ts` | [Worker/push](./05-service-worker-and-web-push.md) | +| 97 | `src/adapters/service-worker/service-worker-protocol.ts` | [Worker/push](./05-service-worker-and-web-push.md) | +| 98 | `src/adapters/service-worker/service-worker-removal.ts` | [Worker/push](./05-service-worker-and-web-push.md) | +| 99 | `src/adapters/service-worker/service-worker-static-assets.ts` | [Worker/push](./05-service-worker-and-web-push.md) | +| 100 | `src/adapters/storage/browser-storage-adapter.ts` | [Storage/files](./03-storage-and-browser-files.md) | +| 101 | `src/adapters/storage/browser-storage-codec.ts` | [Storage/files](./03-storage-and-browser-files.md) | +| 102 | `src/adapters/storage/index.ts` | [Storage/files](./03-storage-and-browser-files.md) | +| 103 | `src/adapters/storage/indexeddb/index.ts` | [Storage/files](./03-storage-and-browser-files.md) | +| 104 | `src/adapters/storage/indexeddb/indexeddb-failure.ts` | [Storage/files](./03-storage-and-browser-files.md) | +| 105 | `src/adapters/storage/indexeddb/indexeddb-governance.ts` | [Storage/files](./03-storage-and-browser-files.md) | +| 106 | `src/adapters/storage/indexeddb/indexeddb-maintenance.ts` | [Storage/files](./03-storage-and-browser-files.md) | +| 107 | `src/adapters/storage/indexeddb/indexeddb-migrations.ts` | [Storage/files](./03-storage-and-browser-files.md) | +| 108 | `src/adapters/storage/indexeddb/indexeddb-runtime.ts` | [Storage/files](./03-storage-and-browser-files.md) | +| 109 | `src/adapters/storage/indexeddb/indexeddb-types.ts` | [Storage/files](./03-storage-and-browser-files.md) | +| 110 | `src/adapters/storage/opfs/browser-opfs-runtime.ts` | [Storage/files](./03-storage-and-browser-files.md) | +| 111 | `src/adapters/storage/opfs/index.ts` | [Storage/files](./03-storage-and-browser-files.md) | +| 112 | `src/adapters/storage/opfs/indexeddb-opfs-journal.ts` | [Storage/files](./03-storage-and-browser-files.md) | +| 113 | `src/adapters/storage/opfs/opfs-byte-store-adapter.ts` | [Storage/files](./03-storage-and-browser-files.md) | +| 114 | `src/adapters/storage/opfs/opfs-policy.ts` | [Storage/files](./03-storage-and-browser-files.md) | +| 115 | `src/adapters/storage/opfs/opfs-worker-client.ts` | [Storage/files](./03-storage-and-browser-files.md) | +| 116 | `src/adapters/storage/opfs/opfs-worker-protocol.ts` | [Storage/files](./03-storage-and-browser-files.md) | +| 117 | `src/adapters/storage/opfs/opfs-worker-runtime.ts` | [Storage/files](./03-storage-and-browser-files.md) | +| 118 | `src/adapters/telemetry/best-effort-telemetry.ts` | [Network/state](./01-network-and-state.md) | +| 119 | `src/adapters/telemetry/index.ts` | [Network/state](./01-network-and-state.md) | +| 120 | `src/adapters/web-push/inbound/notification-click-adapter.ts` | [Worker/push](./05-service-worker-and-web-push.md) | +| 121 | `src/adapters/web-push/inbound/push-event-adapter.ts` | [Worker/push](./05-service-worker-and-web-push.md) | +| 122 | `src/adapters/web-push/index.ts` | [Worker/push](./05-service-worker-and-web-push.md) | +| 123 | `src/adapters/web-push/notification-registry.ts` | [Worker/push](./05-service-worker-and-web-push.md) | +| 124 | `src/adapters/web-push/push-association-fence-store.ts` | [Worker/push](./05-service-worker-and-web-push.md) | +| 125 | `src/adapters/web-push/push-codec.ts` | [Worker/push](./05-service-worker-and-web-push.md) | +| 126 | `src/adapters/web-push/push-registration-gateway.ts` | [Worker/push](./05-service-worker-and-web-push.md) | +| 127 | `src/adapters/web-push/push-subscription-adapter.ts` | [Worker/push](./05-service-worker-and-web-push.md) | +| 128 | `src/adapters/web-push/runtime-support.ts` | [Worker/push](./05-service-worker-and-web-push.md) | +| 129 | `src/adapters/web-push/service-worker-runtime.ts` | [Worker/push](./05-service-worker-and-web-push.md) | +| 130 | `src/adapters/web-push/service-worker-scope-host.ts` | [Worker/push](./05-service-worker-and-web-push.md) | -합계: **128/128**. 새 adapter 파일이 추가되면 이 ledger와 해당 상세 리뷰 inventory를 같은 변경에서 갱신한다. +합계: **130/130**. 새 adapter 파일이 추가되면 이 ledger와 해당 상세 리뷰 inventory를 같은 변경에서 갱신한다. diff --git a/src/adapters/platform/indexeddb-connection.ts b/src/adapters/platform/indexeddb-connection.ts new file mode 100644 index 0000000..c53b3dc --- /dev/null +++ b/src/adapters/platform/indexeddb-connection.ts @@ -0,0 +1,699 @@ +/** + * IDB-X-01. Shared IndexedDB connection mechanics. + * + * Four adapters independently reimplemented "turn an open request into a + * promise, hold a blocked deadline, route upgrade/error/success, close a + * connection that arrives after the caller gave up, and drop the cached handle + * when the browser takes it away". Only the mechanics are shared here. Database + * naming, schema, migrations, governance binding and the failure taxonomy stay + * with each subsystem, so this module imports none of them and is not a + * generic storage layer. + * + * The `IDBFactory` is a required parameter rather than a read of + * `globalThis.indexedDB`. `eslint.config.ts:40-63` bans that property on every + * browser root and `eslint.config.ts:519-530` grants the owned-adapter escape + * hatch to `src/adapters/platform/browser-lifecycle.ts` as a single file, not + * to this folder. Requiring the factory is also what all four callers already + * do, so nothing in eslint.config.ts has to change. + * + * What this module owns: event wiring, the settle-once latch, the blocked + * deadline and the cached-handle lifecycle. What it must never own: database + * names, schemas, migrations, governance bindings, codecs, byte budgets, + * retention, idempotency receipts, failure code tables, operation labels, + * observation event shapes, durability defaults and budget criteria. Wanting to + * move one of those in here is the signal to stop. + */ + +import type { Result } from "../../contracts/result.ts"; +import type { AbortTimerSnapshot } from "./abortable-operation.ts"; + +/** + * Everything that can end an IndexedDB operation without the caller getting a + * value. The kernel reports the cause; the caller's `translate` turns it into + * that subsystem's failure code. + * + * This union is the extension point. `abortable-operation.ts:11` baked a closed + * three-member `AbortTerminalReason` into its return type, so http v3 needed + * five owners and could not use the kernel at all. Here the owner vocabulary is + * never in a return type: adding a member is a compile error in every + * `translate` (they are total functions over the union) rather than a silent + * behavior change, and no consumer has to fork. + */ +export type IndexedDbFailureCause = + /** A native throw or a `request.error` / `transaction.error`. */ + | Readonly<{ kind: "NATIVE_EXCEPTION"; error: unknown }> + /** `onblocked` fired and no deadline was configured. */ + | Readonly<{ kind: "BLOCKED"; oldVersion: number; newVersion: number | null }> + /** `onblocked` fired and the configured deadline then elapsed. */ + | Readonly<{ kind: "BLOCKED_DEADLINE" }> + /** The caller's own `AbortSignal` fired. */ + | Readonly<{ kind: "CALLER_ABORT" }> + /** The connection handle was closed, which is not the caller aborting. */ + | Readonly<{ kind: "CLOSED" }> + /** `upgrade` returned `REJECTED`, or a version change happened unexpectedly. */ + | Readonly<{ + kind: "UPGRADE_REJECTED"; + oldVersion: number; + newVersion: number | null; + detail?: unknown; + }> + /** `admit` returned `REJECT`. `detail` is opaque to the kernel. */ + | Readonly<{ kind: "ADMISSION_REJECTED"; detail?: unknown }> + /** A transaction completed without `succeed()` ever being called. */ + | Readonly<{ kind: "NO_VALUE_PRODUCED" }> + /** + * No `IDBFactory`, or a required `IDBKeyRange` the caller did not supply. + * The kernel never raises this itself — the factory is a required parameter, + * so the condition can only exist before the kernel is called. It is part of + * the vocabulary so a caller that resolves those globals has one place to + * name the failure instead of a second taxonomy beside `translate`. + */ + | Readonly<{ kind: "UNSUPPORTED" }>; + +/** + * Turns a cause into this subsystem's failure value. Built per call site so the + * kernel never learns an operation label or a failure code; a caller that needs + * `INDEXEDDB_READ` and one that needs `UPLOAD_RECONCILE` differ only here. + */ +export type IndexedDbTranslate = ( + cause: IndexedDbFailureCause, +) => Failure; + +export type IndexedDbUpgradeContext = Readonly<{ + database: IDBDatabase; + transaction: IDBTransaction; + oldVersion: number; + /** Never `null`: a null `newVersion` is reported as `UPGRADE_REJECTED` before `upgrade` runs. */ + newVersion: number; +}>; + +/** + * `REJECTED` aborts the versionchange transaction, so a schema change can never + * commit under a rejected policy. A throw from `upgrade` is equivalent to + * `REJECTED` with the thrown value as `detail`. + */ +export type IndexedDbUpgradeOutcome = + | Readonly<{ kind: "APPLIED" }> + | Readonly<{ kind: "REJECTED"; detail?: unknown }>; + +/** + * Post-open validation. It runs after `onsuccess` and may be asynchronous, so + * store/index assertions and governance reads both fit. A rejected or failed + * admission closes the connection before the caller ever sees it. + */ +export type IndexedDbAdmission = + | Readonly<{ kind: "ADMIT" }> + | Readonly<{ kind: "REJECT"; detail?: unknown }> + | Readonly<{ kind: "FAIL"; cause: IndexedDbFailureCause }>; + +export type IndexedDbOpenInput = Readonly<{ + /** Required. See the module comment for why this is not read from a global. */ + factory: IDBFactory; + databaseName: string; + /** Omit to open whatever version exists. */ + version?: number; + translate: IndexedDbTranslate; + /** + * Called inside the versionchange transaction. Omitting it means any upgrade + * is unexpected and the open fails with `UPGRADE_REJECTED` — which is what + * `indexeddb-maintenance.ts:477-484` does by hand today. + */ + upgrade?: (context: IndexedDbUpgradeContext) => IndexedDbUpgradeOutcome; + /** Post-open validation. Omitting it admits every successful open. */ + admit?: ( + database: IDBDatabase, + ) => IndexedDbAdmission | Promise; + /** Aborts a pending upgrade transaction and settles with `CALLER_ABORT`. */ + signal?: AbortSignal; + /** + * `undefined` or `0`: the `onblocked` event itself is terminal and settles + * with `BLOCKED` (maintenance's behavior). A positive value waits that long + * before settling with `BLOCKED_DEADLINE` (runtime/opfs/checkpoint). + */ + blockedTimeoutMs?: number; + /** + * Required when `blockedTimeoutMs` is positive. Build it with + * `snapshotAbortTimers` from `./abortable-operation.ts`, which binds the + * callables once so replacing a method after composition cannot change how an + * open already in flight is bounded. + */ + timers?: AbortTimerSnapshot; + /** Observation only; it cannot change the outcome and its throw is swallowed. */ + onBlocked?: ( + event: Readonly<{ oldVersion: number; newVersion: number | null }>, + ) => void; +}>; + +export type IndexedDbConnection = Readonly<{ + /** + * Single-flight: concurrent callers share one in-flight open, and a cached + * live connection is returned without touching the factory. + */ + acquire(signal?: AbortSignal): Promise>; + /** The cached connection, or `null` while none is live. Live accessor, not a snapshot. */ + current(): IDBDatabase | null; + /** + * Idempotent. Closes the cached connection and settles any in-flight open + * with `CLOSED` — not `CALLER_ABORT`, because the two have different codes in + * `indexeddb-runtime.ts` (`L743` resolves UNAVAILABLE while `L676` resolves + * ABORTED), and collapsing them would change one of them. + */ + close(): void; + isClosed(): boolean; +}>; + +export type IndexedDbConnectionInput = Readonly<{ + /** + * How to produce a connection. Normally a closure over + * `openIndexedDbDatabase`. It is a seam rather than a fixed body so a caller + * can retry, decorate or fake the open without faking an `IDBFactory`. + */ + open: ( + signal: AbortSignal | undefined, + ) => Promise>; + translate: IndexedDbTranslate; + /** + * Fired after the handle has already dropped its cached connection, so a + * listener cannot keep a connection the browser is taking back. The next + * `acquire()` opens again. + */ + onVersionChange?: (event: IDBVersionChangeEvent) => void; + /** `onclose`: the browser closed the connection without a version change. */ + onForcedClose?: () => void; +}>; + +export type IndexedDbDeleteOutcome = + | Readonly<{ kind: "DELETED" }> + /** + * The request is still live in the browser. It is not a failure and it is not + * "not applied": `deleteDatabase` cannot be cancelled after dispatch, so the + * effect is unknown. `indexeddb-checkpoint-store.ts:449-462` makes the same + * distinction and its comment explains why. + */ + | Readonly<{ kind: "BLOCKED_DEADLINE" }>; + +export type IndexedDbDeleteInput = Readonly<{ + factory: IDBFactory; + databaseName: string; + translate: IndexedDbTranslate; + blockedTimeoutMs?: number; + timers?: AbortTimerSnapshot; + /** + * Called exactly once when the native request truly settles, success or + * error — never on a blocked deadline. The caller uses it to release a + * pending-deletion registration; the kernel does not own such a registry + * because whether a realm may recreate the database is the caller's policy. + */ + onSettled?: () => void; +}>; + +function succeeded(value: Value): Result { + return Object.freeze({ ok: true as const, value }); +} + +function failed(error: Failure): Result { + return Object.freeze({ ok: false as const, error }); +} + +/** + * A connection that lost a race is closed, never leaked. The close itself is + * best effort: a handle the browser already tore down cannot be closed again, + * and that must not replace the outcome the caller was given. + */ +function closeQuietly(database: IDBDatabase): void { + try { + database.close(); + } catch { + // A connection that cannot be closed is already gone. + } +} + +/** + * A positive deadline with no scheduler leaves the open unbounded, which is the + * same class of configuration defect `assertBoundedCapacity` and + * `snapshotAbortTimers` reject at construction rather than at runtime. It is a + * throw instead of a `Failure` because no `translate` could describe it without + * the caller first deciding it is acceptable to run unbounded. + */ +function assertBlockedDeadline( + blockedTimeoutMs: number | undefined, + timers: AbortTimerSnapshot | undefined, +): boolean { + const bounded = + blockedTimeoutMs !== undefined && + Number.isFinite(blockedTimeoutMs) && + blockedTimeoutMs > 0; + if (bounded && !timers) { + throw new TypeError( + "A positive blockedTimeoutMs requires a timer snapshot.", + ); + } + return bounded; +} + +/** + * Settles exactly once. A connection that arrives after the settle — a late + * `onsuccess`, a rejected admission, an abort — is closed rather than leaked. + */ +export function openIndexedDbDatabase( + input: IndexedDbOpenInput, +): Promise> { + const { factory, databaseName, translate } = input; + const bounded = assertBlockedDeadline(input.blockedTimeoutMs, input.timers); + if (input.signal?.aborted) { + return Promise.resolve(failed(translate({ kind: "CALLER_ABORT" }))); + } + + return new Promise>((resolve) => { + let settled = false; + let blockedTimer: unknown; + let blockedTimerSet = false; + let upgradeRejection: IndexedDbFailureCause | null = null; + + const settle = (result: Result) => { + if (settled) { + if (result.ok) closeQuietly(result.value); + return; + } + settled = true; + if (blockedTimerSet) { + blockedTimerSet = false; + try { + input.timers?.clearTimer(blockedTimer); + } catch { + // A throwing scheduler cannot keep the open unresolved. + } + } + try { + input.signal?.removeEventListener("abort", onCallerAbort); + } catch { + // A hostile signal facade cannot block cleanup. + } + resolve(result); + }; + const settleFailure = (cause: IndexedDbFailureCause) => { + settle(failed(translate(cause))); + }; + + let request: IDBOpenDBRequest; + try { + request = + input.version === undefined + ? factory.open(databaseName) + : factory.open(databaseName, input.version); + } catch (error) { + settleFailure({ kind: "NATIVE_EXCEPTION", error }); + return; + } + + /** + * An upgrade transaction is the only cancellable part of an open request: + * aborting it makes the request fail through `onerror`, and there is no + * other way to stop a dispatched open. + */ + const abortUpgrade = () => { + try { + request.transaction?.abort(); + } catch { + // The request's own error path owns whatever happens next. + } + }; + + function onCallerAbort(): void { + abortUpgrade(); + settleFailure({ kind: "CALLER_ABORT" }); + } + input.signal?.addEventListener("abort", onCallerAbort, { once: true }); + + request.onupgradeneeded = (event) => { + const transaction = request.transaction; + const oldVersion = event.oldVersion; + const newVersion = event.newVersion; + // A null `newVersion` means a delete is in progress, and no `upgrade` + // policy can be applied to a schema that is going away. It is reported + // before the callback runs so a caller never sees a half-open upgrade. + if (!transaction || newVersion === null || !input.upgrade) { + upgradeRejection = { kind: "UPGRADE_REJECTED", oldVersion, newVersion }; + abortUpgrade(); + return; + } + let outcome: IndexedDbUpgradeOutcome; + try { + outcome = input.upgrade({ + database: request.result, + transaction, + oldVersion, + newVersion, + }); + } catch (error) { + outcome = { kind: "REJECTED", detail: error }; + } + if (outcome.kind === "APPLIED") return; + upgradeRejection = { + kind: "UPGRADE_REJECTED", + oldVersion, + newVersion, + detail: outcome.detail, + }; + abortUpgrade(); + }; + + request.onblocked = (event) => { + const blocked = Object.freeze({ + oldVersion: event.oldVersion, + newVersion: event.newVersion, + }); + if (input.onBlocked) { + try { + input.onBlocked(blocked); + } catch { + // Observation cannot change the outcome. + } + } + if (!bounded) { + settleFailure({ kind: "BLOCKED", ...blocked }); + return; + } + if (blockedTimerSet || settled) return; + try { + blockedTimer = input.timers?.setTimer(() => { + settleFailure({ kind: "BLOCKED_DEADLINE" }); + }, input.blockedTimeoutMs as number); + blockedTimerSet = true; + } catch { + // A scheduler that cannot install the deadline leaves the open + // unbounded, so the deadline is treated as already elapsed. + settleFailure({ kind: "BLOCKED_DEADLINE" }); + } + }; + + request.onerror = () => { + settleFailure( + upgradeRejection ?? { kind: "NATIVE_EXCEPTION", error: request.error }, + ); + }; + + const routeAdmission = ( + database: IDBDatabase, + admission: IndexedDbAdmission, + ) => { + if (settled) { + closeQuietly(database); + return; + } + if (admission.kind === "ADMIT") { + settle(succeeded(database)); + return; + } + // A connection the caller will never see is closed before the failure is + // reported, so a rejected admission cannot leak a live handle. + closeQuietly(database); + settleFailure( + admission.kind === "REJECT" + ? { kind: "ADMISSION_REJECTED", detail: admission.detail } + : admission.cause, + ); + }; + + request.onsuccess = () => { + const database = request.result; + if (settled) { + closeQuietly(database); + return; + } + if (!input.admit) { + settle(succeeded(database)); + return; + } + let admission: IndexedDbAdmission | Promise; + try { + admission = input.admit(database); + } catch (error) { + closeQuietly(database); + settleFailure({ kind: "NATIVE_EXCEPTION", error }); + return; + } + void Promise.resolve(admission).then( + (resolved) => routeAdmission(database, resolved), + (error: unknown) => { + closeQuietly(database); + settleFailure({ kind: "NATIVE_EXCEPTION", error }); + }, + ); + }; + }); +} + +export function createIndexedDbConnection( + input: IndexedDbConnectionInput, +): IndexedDbConnection { + let connection: IDBDatabase | null = null; + let attempt: Promise> | null = null; + let controller: AbortController | null = null; + let closed = false; + const closeWaiters = new Set<() => void>(); + + /** + * The browser is taking the connection back. The cached handle is dropped + * before the subsystem is told, so a notification callback cannot hand out a + * connection that is already gone. The next `acquire()` opens again. + */ + const invalidate = (database: IDBDatabase, notify: () => void) => { + if (connection !== database) return; + connection = null; + try { + notify(); + } catch { + // Losing the connection is independent from announcing it. + } + }; + + const adopt = (database: IDBDatabase): IDBDatabase => { + database.onversionchange = (event) => { + // The connection has to go for the other context's upgrade to proceed, + // so it is closed here rather than left to a listener's discretion. + closeQuietly(database); + invalidate(database, () => input.onVersionChange?.(event)); + }; + database.onclose = () => { + // `onclose` means the browser already tore the connection down, so there + // is nothing left to close — only a cached handle to drop. + invalidate(database, () => input.onForcedClose?.()); + }; + return database; + }; + + const start = (): Promise> => { + const openController = new AbortController(); + controller = openController; + const started = input.open(openController.signal).then( + (result) => { + if (attempt === started) { + attempt = null; + controller = null; + } + if (!result.ok) return result; + if (closed) { + // The handle was closed while the open was in flight; the connection + // that arrived belongs to nobody. + closeQuietly(result.value); + return result; + } + connection = adopt(result.value); + return result; + }, + (error: unknown) => { + if (attempt === started) { + attempt = null; + controller = null; + } + return failed( + input.translate({ kind: "NATIVE_EXCEPTION", error }), + ); + }, + ); + attempt = started; + return started; + }; + + return Object.freeze({ + acquire(signal) { + if (closed) { + return Promise.resolve( + failed(input.translate({ kind: "CLOSED" })), + ); + } + if (signal?.aborted) { + return Promise.resolve( + failed( + input.translate({ kind: "CALLER_ABORT" }), + ), + ); + } + const live = connection; + if (live) return Promise.resolve(succeeded(live)); + + const shared = attempt ?? start(); + // The shared open is not cancelled by one caller giving up: another + // caller may still want the connection, and the request cannot be + // un-dispatched anyway. + return new Promise>((resolve) => { + let callerSettled = false; + const finishCaller = (result: Result) => { + if (callerSettled) return; + callerSettled = true; + try { + signal?.removeEventListener("abort", onAbort); + } catch { + // A hostile signal facade cannot block cleanup. + } + resolve(result); + }; + function onAbort(): void { + finishCaller( + failed(input.translate({ kind: "CALLER_ABORT" })), + ); + } + function onClose(): void { + finishCaller(failed(input.translate({ kind: "CLOSED" }))); + } + signal?.addEventListener("abort", onAbort, { once: true }); + closeWaiters.add(onClose); + void shared.then( + (result) => { + closeWaiters.delete(onClose); + // A handle closed mid-open reports CLOSED even if the open itself + // succeeded: the connection is already gone. + finishCaller( + closed + ? failed(input.translate({ kind: "CLOSED" })) + : result, + ); + }, + () => { + closeWaiters.delete(onClose); + finishCaller(failed(input.translate({ kind: "CLOSED" }))); + }, + ); + }); + }, + current: () => connection, + close() { + if (closed) return; + closed = true; + const live = connection; + connection = null; + if (live) closeQuietly(live); + // Cancelling the in-flight open aborts a pending upgrade transaction, so + // a closed handle does not leave a versionchange transaction running. + try { + controller?.abort(); + } catch { + // A cancelled open still reports CLOSED to its waiters below. + } + controller = null; + for (const waiter of [...closeWaiters]) { + closeWaiters.delete(waiter); + waiter(); + } + }, + isClosed: () => closed, + }); +} + +/** + * In this module because it is the same `IDBOpenDBRequest` state machine as + * `openIndexedDbDatabase`, not because three subsystems need it — only + * `indexeddb-checkpoint-store.ts` deletes a database, and nothing here asks the + * other three to start. Leaving it out would leave a third hand-written copy of + * the settle-once blocked-deadline latch eight lines away from the kernel's. + * + * There is deliberately no `signal`: the request cannot be cancelled after + * dispatch, so reporting ABORTED while the deletion may still commit would be a + * lie. Callers check their signal before calling. + */ +export function deleteIndexedDbDatabase( + input: IndexedDbDeleteInput, +): Promise> { + const { factory, databaseName, translate } = input; + const bounded = assertBlockedDeadline(input.blockedTimeoutMs, input.timers); + + return new Promise>((resolve) => { + let settled = false; + let notified = false; + let blockedTimer: unknown; + let blockedTimerSet = false; + + /** + * Independent of `settle`: a blocked deadline resolves the caller while the + * request is still live, and the registration must be released when the + * request actually lands, not when the caller stopped waiting. + */ + const notifySettled = () => { + if (notified) return; + notified = true; + try { + input.onSettled?.(); + } catch { + // Releasing a registration cannot change the deletion outcome. + } + }; + + const settle = (result: Result) => { + if (settled) return; + settled = true; + if (blockedTimerSet) { + blockedTimerSet = false; + try { + input.timers?.clearTimer(blockedTimer); + } catch { + // A throwing scheduler cannot keep the deletion unresolved. + } + } + resolve(result); + }; + + let request: IDBOpenDBRequest; + try { + request = factory.deleteDatabase(databaseName); + } catch (error) { + notifySettled(); + settle(failed(translate({ kind: "NATIVE_EXCEPTION", error }))); + return; + } + + request.onsuccess = () => { + notifySettled(); + settle(succeeded({ kind: "DELETED" as const })); + }; + request.onerror = () => { + notifySettled(); + settle( + failed(translate({ kind: "NATIVE_EXCEPTION", error: request.error })), + ); + }; + request.onblocked = (event) => { + if (!bounded) { + settle( + failed( + translate({ + kind: "BLOCKED", + oldVersion: event.oldVersion, + newVersion: event.newVersion, + }), + ), + ); + return; + } + if (blockedTimerSet || settled) return; + try { + blockedTimer = input.timers?.setTimer(() => { + settle(succeeded({ kind: "BLOCKED_DEADLINE" as const })); + }, input.blockedTimeoutMs as number); + blockedTimerSet = true; + } catch { + settle(succeeded({ kind: "BLOCKED_DEADLINE" as const })); + } + }; + }); +} diff --git a/src/adapters/platform/indexeddb-transaction.ts b/src/adapters/platform/indexeddb-transaction.ts new file mode 100644 index 0000000..1570e9a --- /dev/null +++ b/src/adapters/platform/indexeddb-transaction.ts @@ -0,0 +1,433 @@ +/** + * IDB-X-02. Shared IndexedDB transaction and cursor mechanics. + * + * The same settle-once transaction state machine exists four times + * (`indexeddb-runtime.ts:976-1074`, `indexeddb-maintenance.ts:606-705`, + * `indexeddb-opfs-journal.ts:1098-1174`, + * `indexeddb-checkpoint-store.ts:497-596`) and the four already disagree: one + * of them never routes a request error at all, one aborts on a request error + * while two only record it, and one reports a value-less completion as + * CORRUPT_DATA while three report UNAVAILABLE. This module owns the mechanics + * and keeps every one of those choices at the call site. + * + * Like `indexeddb-connection.ts`, it owns no policy: not a store name, not a + * durability default, not a budget criterion, not a failure code. + */ + +import type { Result } from "../../contracts/result.ts"; +import type { IndexedDbTranslate } from "./indexeddb-connection.ts"; + +export type IndexedDbDurability = "default" | "strict" | "relaxed"; + +/** + * The failure half of a transaction context. Split out so helpers that only + * need to report failure (`onIndexedDbRequest`, `walkIndexedDbCursor`) do not + * have to be generic over the transaction's success type. + */ +export type IndexedDbRequestSink = Readonly<{ + /** + * Records the failure and aborts the transaction. The first failure wins. + * This is `indexeddb-runtime.ts:1047-1054`'s `fail`. + */ + fail(failure: Failure): void; + /** + * Records a request-level error **without aborting**: the transaction is left + * to complete or abort on its own, and the recorded error becomes the reported + * failure if it aborts. This is `indexeddb-runtime.ts:1055-1060`'s + * `requestFailed`. + * + * `indexeddb-checkpoint-store.ts:577-588` deliberately aborts instead. It + * keeps doing so by calling `fail(translate({kind:"NATIVE_EXCEPTION", error}))`. + * The kernel does not pick. + */ + requestFailed(error: unknown): void; +}>; + +export type IndexedDbTransactionContext = + IndexedDbRequestSink & + Readonly<{ + /** The first `succeed` wins; later ones are ignored. */ + succeed(value: Value): void; + /** For callers that need `objectStore()`/`index()` directly. */ + readonly transaction: IDBTransaction; + }>; + +export type IndexedDbTransactionInput = Readonly<{ + database: IDBDatabase; + stores: readonly string[]; + mode: "readonly" | "readwrite"; + translate: IndexedDbTranslate; + /** Aborts the transaction; the outcome is `CALLER_ABORT` unless completion won. */ + signal?: AbortSignal; + /** + * `undefined` opens with **no options bag at all**, which is + * `indexeddb-checkpoint-store.ts:512`'s current behavior — not the same as + * `"default"`, which passes `{durability:"default"}`. A named value falls back + * to the no-options form when the engine rejects the bag with a `TypeError`. + */ + durability?: IndexedDbDurability; + queue: ( + transaction: IDBTransaction, + context: IndexedDbTransactionContext, + ) => void; +}>; + +/** How the visitor wants the cursor advanced. */ +export type IndexedDbCursorStep = + | Readonly<{ kind: "CONTINUE" }> + | Readonly<{ kind: "CONTINUE_FROM"; key: IDBValidKey }> + | Readonly<{ + kind: "CONTINUE_PRIMARY"; + key: IDBValidKey; + primaryKey: IDBValidKey; + }> + /** End the walk here; `done` gets `reason: "STOPPED"`. */ + | Readonly<{ kind: "STOP" }> + /** + * The visitor started its own request chain and will call `resume(step)` when + * that chain finishes. Without this the pump is unusable by three of the four + * callers: every walk in `indexeddb-runtime.ts` and `indexeddb-maintenance.ts` + * issues nested requests before advancing (e.g. `L1487-1526`, `L2406-2471`, + * `L1490-1541`). A pump that only understood `CONTINUE` would be the + * too-narrow-to-adopt failure again. + */ + | Readonly<{ kind: "SUSPEND" }>; + +export type IndexedDbBudgetVerdict = "CONTINUE" | "ROW_BUDGET" | "TIME_BUDGET"; + +export type IndexedDbBudget = Readonly<{ + /** + * Checked before each row, with the number of rows already handed to `visit`. + * The kernel counts nothing itself: runtime bounds on rows it deleted + * (`indexeddb-runtime.ts:2384`) while maintenance bounds on rows it scanned + * (`indexeddb-maintenance.ts:823`), so the counter, the clock and the deadline + * all belong to the caller. A clock that cannot be read is a failure rather + * than a `false`, which is what `monotonicClock()` + * (`indexeddb-runtime.ts:2260-2269`) already does. + */ + admit(scannedRows: number): Result; +}>; + +export type IndexedDbCursorVisit = Readonly<{ + cursor: IDBCursorWithValue; + /** Rows handed to `visit` so far, this row included. */ + scannedRows: number; + /** Only meaningful after the visitor returned `SUSPEND`. Idempotent. */ + resume(step: IndexedDbCursorStep): void; +}>; + +export type IndexedDbWalkSummary = Readonly<{ + reason: "EXHAUSTED" | "STOPPED" | "ROW_BUDGET" | "TIME_BUDGET" | "ABORTED"; + scannedRows: number; +}>; + +export type IndexedDbWalkInput = Readonly<{ + request: IDBRequest; + sink: IndexedDbRequestSink; + translate: IndexedDbTranslate; + budget?: IndexedDbBudget; + /** + * Checked at each row. An aborted signal aborts the transaction and ends the + * walk with `reason: "ABORTED"`, which is what `indexeddb-runtime.ts:1375-1382` + * does inline today. + */ + signal?: AbortSignal; + visit: (visit: IndexedDbCursorVisit) => IndexedDbCursorStep; + /** The only success exit. The caller routes it into its own `succeed`. */ + done: (summary: IndexedDbWalkSummary) => void; +}>; + +function succeeded(value: Value): Result { + return Object.freeze({ ok: true as const, value }); +} + +function failed(error: Failure): Result { + return Object.freeze({ ok: false as const, error }); +} + +/** + * The durability fallback on its own, for a caller that manages its own + * transaction. `undefined` omits the options bag entirely. + */ +export function openIndexedDbTransaction( + database: IDBDatabase, + stores: readonly string[], + mode: "readonly" | "readwrite", + durability?: IndexedDbDurability, +): IDBTransaction { + const names = [...stores]; + // Not the same as `{durability:"default"}`: an engine that has never seen the + // options bag treats the two differently, and one caller relies on that. + if (durability === undefined) return database.transaction(names, mode); + try { + return database.transaction(names, mode, { durability }); + } catch (error) { + // Only an engine that does not know the option answers with a TypeError. + // Anything else — a closed connection, an unknown store — is a real error + // and must not be retried into a second, differently shaped failure. + if (error instanceof TypeError) return database.transaction(names, mode); + throw error; + } +} + +/** + * A transaction that completes without `succeed()` is reported through + * `translate({kind:"NO_VALUE_PRODUCED"})`. Three callers map that to + * UNAVAILABLE and `indexeddb-checkpoint-store.ts:541-547` maps it to + * CORRUPT_DATA; the kernel never picks. + */ +export function runIndexedDbTransaction( + input: IndexedDbTransactionInput, +): Promise> { + const { translate } = input; + if (input.signal?.aborted) { + return Promise.resolve(failed(translate({ kind: "CALLER_ABORT" }))); + } + + let transaction: IDBTransaction; + try { + transaction = openIndexedDbTransaction( + input.database, + input.stores, + input.mode, + input.durability, + ); + } catch (error) { + return Promise.resolve(failed(translate({ kind: "NATIVE_EXCEPTION", error }))); + } + + return new Promise>((resolve) => { + let candidate: Result | undefined; + let requestError: unknown; + let hasRequestError = false; + let callerAborted = false; + let settled = false; + + const finish = (result: Result) => { + if (settled) return; + settled = true; + try { + input.signal?.removeEventListener("abort", onCallerAbort); + } catch { + // A hostile signal facade cannot block cleanup. + } + resolve(result); + }; + + function onCallerAbort(): void { + const previous = callerAborted; + callerAborted = true; + try { + transaction.abort(); + } catch { + // CP-4. The transaction may already be durably committed while its + // completion event is still queued. Claiming the abort would report a + // committed mutation as ABORTED, so the claim is withdrawn and the + // transaction's own events decide. + callerAborted = previous; + } + } + + transaction.oncomplete = () => { + finish(candidate ?? failed(translate({ kind: "NO_VALUE_PRODUCED" }))); + }; + transaction.onerror = () => { + // `onabort` is the terminal signal; this only captures the error that a + // request left behind before the transaction unwinds. + if (hasRequestError) return; + requestError = transaction.error; + hasRequestError = true; + }; + transaction.onabort = () => { + if (callerAborted) { + finish(failed(translate({ kind: "CALLER_ABORT" }))); + return; + } + if (candidate && !candidate.ok) { + finish(candidate); + return; + } + // An abort never yields the value a `succeed` recorded: the transaction + // did not commit, so the native error is what happened. + finish( + failed( + translate({ + kind: "NATIVE_EXCEPTION", + error: hasRequestError ? requestError : transaction.error, + }), + ), + ); + }; + input.signal?.addEventListener("abort", onCallerAbort, { once: true }); + + const context: IndexedDbTransactionContext = Object.freeze({ + transaction, + succeed(value) { + candidate ??= succeeded(value); + }, + fail(failure) { + candidate ??= failed(failure); + try { + transaction.abort(); + } catch { + // The transaction is already finished, so no abort event is coming. + // The recorded failure is the outcome rather than a hang. + finish(candidate); + } + }, + requestFailed(error) { + if (!hasRequestError) { + requestError = error; + hasRequestError = true; + } + candidate ??= failed(translate({ kind: "NATIVE_EXCEPTION", error })); + }, + }); + + try { + input.queue(transaction, context); + } catch (error) { + context.fail(translate({ kind: "NATIVE_EXCEPTION", error })); + } + }); +} + +/** + * Wires `onsuccess`/`onerror` in one place. The four copies write this pair by + * hand at roughly 70 sites and `indexeddb-opfs-journal.ts` omits `onerror` + * everywhere, which is how a request-level error there becomes whatever + * `transaction.error` happens to hold. + */ +export function onIndexedDbRequest( + request: IDBRequest, + sink: IndexedDbRequestSink, + onSuccess: (value: Value) => void, +): void { + request.onsuccess = () => { + onSuccess(request.result); + }; + request.onerror = () => { + sink.requestFailed(request.error); + }; +} + +/** + * Drives an open cursor. It reports a native advance failure through `sink` and + * never decides what a finished walk means — `reason` distinguishes a row budget + * from a time budget so a caller can keep reporting `budgetExhausted` exactly as + * it does now (`indexeddb-runtime.ts:2387`). + */ +export function walkIndexedDbCursor( + input: IndexedDbWalkInput, +): void { + const { request, sink, translate } = input; + let scannedRows = 0; + let finished = false; + + const end = (reason: IndexedDbWalkSummary["reason"]) => { + if (finished) return; + finished = true; + input.done(Object.freeze({ reason, scannedRows })); + }; + const abandon = (failure: Failure) => { + if (finished) return; + finished = true; + sink.fail(failure); + }; + + const advance = (action: () => void) => { + try { + action(); + } catch (error) { + abandon(translate({ kind: "NATIVE_EXCEPTION", error })); + } + }; + + const applyStep = ( + cursor: IDBCursorWithValue, + step: IndexedDbCursorStep, + ): void => { + switch (step.kind) { + case "CONTINUE": + advance(() => cursor.continue()); + return; + case "CONTINUE_FROM": + advance(() => cursor.continue(step.key)); + return; + case "CONTINUE_PRIMARY": + advance(() => cursor.continuePrimaryKey(step.key, step.primaryKey)); + return; + case "STOP": + end("STOPPED"); + return; + case "SUSPEND": + // The visitor owns the cursor until it calls `resume`. + return; + default: { + const exhaustive: never = step; + return exhaustive; + } + } + }; + + request.onerror = () => { + if (finished) return; + finished = true; + // No `done`: the walk produced no summary, and the transaction's own + // outcome decides what a failed advance means for this call site. + sink.requestFailed(request.error); + }; + + request.onsuccess = () => { + if (finished) return; + const cursor = request.result; + if (!cursor) { + end("EXHAUSTED"); + return; + } + if (input.signal?.aborted) { + // Aborting through the sink keeps one abort path instead of reaching for + // a transaction the pump was never handed. `done` still runs so the + // caller sees why the walk stopped; the recorded failure outranks any + // value it produces there. + finished = true; + sink.fail(translate({ kind: "CALLER_ABORT" })); + input.done(Object.freeze({ reason: "ABORTED" as const, scannedRows })); + return; + } + if (input.budget) { + const verdict = input.budget.admit(scannedRows); + if (!verdict.ok) { + abandon(verdict.error); + return; + } + if (verdict.value === "ROW_BUDGET" || verdict.value === "TIME_BUDGET") { + end(verdict.value); + return; + } + } + + scannedRows += 1; + let suspended = false; + let resumed = false; + const resume = (step: IndexedDbCursorStep) => { + if (!suspended || resumed || finished) return; + resumed = true; + applyStep(cursor, step); + }; + + let step: IndexedDbCursorStep; + try { + step = input.visit( + Object.freeze({ cursor, scannedRows, resume }), + ); + } catch (error) { + // A visitor defect must not let a partially applied write commit, so it + // aborts rather than merely being recorded. + abandon(translate({ kind: "NATIVE_EXCEPTION", error })); + return; + } + if (step.kind === "SUSPEND") suspended = true; + applyStep(cursor, step); + }; +} diff --git a/tests/unit/indexeddb-connection.test.ts b/tests/unit/indexeddb-connection.test.ts new file mode 100644 index 0000000..068c59c --- /dev/null +++ b/tests/unit/indexeddb-connection.test.ts @@ -0,0 +1,886 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + createIndexedDbConnection, + deleteIndexedDbDatabase, + openIndexedDbDatabase, + type IndexedDbFailureCause, + type IndexedDbTranslate, +} from "../../src/adapters/platform/indexeddb-connection.ts"; +import { MemoryIndexedDbFactory } from "../helpers/memory-indexeddb.ts"; + +/** + * IDB-X-01 golden tests. The kernel owns connection mechanics only: it must + * never name an operation label, a failure code or a schema. Every assertion + * below reads the cause the kernel reported, not a taxonomy it chose. + */ + +type TestFailure = Readonly<{ cause: IndexedDbFailureCause["kind"]; detail?: unknown }>; + +/** + * A `translate` written as a total switch over the union. This is the extension + * point `abortable-operation.ts:11` lacked: adding a cause member breaks every + * translate at compile time instead of silently changing behavior, and the + * union never appears in a return type. + */ +function recordingTranslate( + seen: IndexedDbFailureCause["kind"][], +): IndexedDbTranslate { + return (cause) => { + seen.push(cause.kind); + switch (cause.kind) { + case "NATIVE_EXCEPTION": + return { cause: cause.kind, detail: cause.error }; + case "BLOCKED": + return { + cause: cause.kind, + detail: { oldVersion: cause.oldVersion, newVersion: cause.newVersion }, + }; + case "BLOCKED_DEADLINE": + case "CALLER_ABORT": + case "CLOSED": + case "NO_VALUE_PRODUCED": + case "UNSUPPORTED": + return { cause: cause.kind }; + case "UPGRADE_REJECTED": + return { cause: cause.kind, detail: cause.detail }; + case "ADMISSION_REJECTED": + return { cause: cause.kind, detail: cause.detail }; + default: { + const exhaustive: never = cause; + return exhaustive; + } + } + }; +} + +function manualTimers(): Readonly<{ + snapshot: Readonly<{ + setTimer: (callback: () => void, delayMs: number) => unknown; + clearTimer: (handle: unknown) => void; + }>; + pending: Array void; delayMs: number }>>; + cleared: unknown[]; +}> { + const pending: Array void; delayMs: number }>> = []; + const cleared: unknown[] = []; + return { + pending, + cleared, + snapshot: { + setTimer: (callback, delayMs) => { + pending.push({ callback, delayMs }); + return pending.length; + }, + clearTimer: (handle) => { + cleared.push(handle); + }, + }, + }; +} + +async function flush(): Promise { + for (let attempt = 0; attempt < 30; attempt += 1) { + await new Promise((resolve) => { + globalThis.setTimeout(resolve, 0); + }); + } +} + +type StubOpenRequest = { + result: IDBDatabase; + error: DOMException | null; + transaction: IDBTransaction | null; + onsuccess: ((event: Event) => unknown) | null; + onerror: ((event: Event) => unknown) | null; + onblocked: ((event: IDBVersionChangeEvent) => unknown) | null; + onupgradeneeded: ((event: IDBVersionChangeEvent) => unknown) | null; +}; + +type StubFactory = Readonly<{ + factory: IDBFactory; + opened: StubOpenRequest[]; + deleted: StubOpenRequest[]; +}>; + +/** + * The memory fake models a real database. These stubs model only the request + * object, so paths the fake cannot reach — a null `newVersion`, a cancellable + * upgrade transaction, `deleteDatabase` — are still exercised. + */ +function stubFactory(): StubFactory { + const opened: StubOpenRequest[] = []; + const deleted: StubOpenRequest[] = []; + const request = (): StubOpenRequest => ({ + result: { close: vi.fn() } as unknown as IDBDatabase, + error: null, + transaction: null, + onsuccess: null, + onerror: null, + onblocked: null, + onupgradeneeded: null, + }); + return { + opened, + deleted, + factory: { + open: () => { + const next = request(); + opened.push(next); + return next as unknown as IDBOpenDBRequest; + }, + deleteDatabase: () => { + const next = request(); + deleted.push(next); + return next as unknown as IDBOpenDBRequest; + }, + } as unknown as IDBFactory, + }; +} + +function versionChangeEvent( + oldVersion: number, + newVersion: number | null, +): IDBVersionChangeEvent { + return { oldVersion, newVersion } as IDBVersionChangeEvent; +} + +describe("shared IndexedDB connection mechanics", () => { + it("opens a database and hands the caller the live connection", async () => { + const memory = new MemoryIndexedDbFactory(); + const seen: IndexedDbFailureCause["kind"][] = []; + + const result = await openIndexedDbDatabase({ + factory: memory.factory, + databaseName: "kernel", + version: 1, + translate: recordingTranslate(seen), + upgrade: ({ database }) => { + database.createObjectStore("rows", { keyPath: "id" }); + return { kind: "APPLIED" }; + }, + }); + + expect(result.ok).toBe(true); + if (!result.ok) return; + expect(result.value.version).toBe(1); + expect(memory.hasStore("rows")).toBe(true); + expect(seen).toEqual([]); + }); + + it("reports a native throw from the factory as NATIVE_EXCEPTION", async () => { + const boom = new Error("no factory"); + const result = await openIndexedDbDatabase({ + factory: { + open: () => { + throw boom; + }, + } as unknown as IDBFactory, + databaseName: "kernel", + translate: recordingTranslate([]), + }); + + expect(result).toEqual({ + ok: false, + error: { cause: "NATIVE_EXCEPTION", detail: boom }, + }); + }); + + it("routes a request-level error through translate", async () => { + const memory = new MemoryIndexedDbFactory(); + await openIndexedDbDatabase({ + factory: memory.factory, + databaseName: "kernel", + version: 2, + translate: recordingTranslate([]), + upgrade: () => ({ kind: "APPLIED" }), + }); + + // Opening an older version makes the fake fill `request.error` and fire + // `onerror`; the kernel must report that error rather than a generic cause. + const result = await openIndexedDbDatabase({ + factory: memory.factory, + databaseName: "kernel", + version: 1, + translate: recordingTranslate([]), + upgrade: () => ({ kind: "APPLIED" }), + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.cause).toBe("NATIVE_EXCEPTION"); + expect((result.error.detail as DOMException).name).toBe("VersionError"); + }); + + it("settles with BLOCKED when onblocked fires and no deadline is configured", async () => { + const memory = new MemoryIndexedDbFactory(); + const observed: Array> = []; + memory.blockNextOpen(); + + const result = await openIndexedDbDatabase({ + factory: memory.factory, + databaseName: "kernel", + version: 3, + translate: recordingTranslate([]), + onBlocked: (event) => { + observed.push(event); + throw new Error("an observer cannot change the outcome"); + }, + }); + + expect(observed).toEqual([{ oldVersion: 0, newVersion: 3 }]); + expect(result).toEqual({ + ok: false, + error: { + cause: "BLOCKED", + detail: { oldVersion: 0, newVersion: 3 }, + }, + }); + }); + + it("waits the configured deadline before settling with BLOCKED_DEADLINE", async () => { + const memory = new MemoryIndexedDbFactory(); + const timers = manualTimers(); + memory.blockNextOpen(); + + let settled = false; + const pending = openIndexedDbDatabase({ + factory: memory.factory, + databaseName: "kernel", + version: 3, + translate: recordingTranslate([]), + blockedTimeoutMs: 10_000, + timers: timers.snapshot, + }); + void pending.then(() => { + settled = true; + }); + + await flush(); + expect(settled).toBe(false); + expect(timers.pending).toHaveLength(1); + expect(timers.pending[0]?.delayMs).toBe(10_000); + + timers.pending[0]?.callback(); + await expect(pending).resolves.toEqual({ + ok: false, + error: { cause: "BLOCKED_DEADLINE" }, + }); + }); + + it("rejects a positive blocked deadline that has no timer snapshot", () => { + const memory = new MemoryIndexedDbFactory(); + expect(() => + openIndexedDbDatabase({ + factory: memory.factory, + databaseName: "kernel", + translate: recordingTranslate([]), + blockedTimeoutMs: 10_000, + }), + ).toThrow(TypeError); + }); + + it("aborts the versionchange transaction when upgrade rejects, so no schema commits", async () => { + const memory = new MemoryIndexedDbFactory(); + + const result = await openIndexedDbDatabase({ + factory: memory.factory, + databaseName: "kernel", + version: 1, + translate: recordingTranslate([]), + upgrade: ({ database }) => { + database.createObjectStore("rows", { keyPath: "id" }); + return { kind: "REJECTED", detail: "POLICY" }; + }, + }); + + expect(result).toEqual({ + ok: false, + error: { cause: "UPGRADE_REJECTED", detail: "POLICY" }, + }); + // The store the rejected upgrade created must not be visible afterwards. + expect(memory.hasStore("rows")).toBe(false); + + const oldVersions: number[] = []; + await openIndexedDbDatabase({ + factory: memory.factory, + databaseName: "kernel", + version: 1, + translate: recordingTranslate([]), + upgrade: ({ oldVersion }) => { + oldVersions.push(oldVersion); + return { kind: "APPLIED" }; + }, + }); + expect(oldVersions).toEqual([0]); + }); + + it("treats a throw from upgrade as a rejection and carries the thrown value", async () => { + const memory = new MemoryIndexedDbFactory(); + const boom = new Error("migration defect"); + + const result = await openIndexedDbDatabase({ + factory: memory.factory, + databaseName: "kernel", + version: 1, + translate: recordingTranslate([]), + upgrade: () => { + throw boom; + }, + }); + + expect(result).toEqual({ + ok: false, + error: { cause: "UPGRADE_REJECTED", detail: boom }, + }); + expect(memory.hasStore("rows")).toBe(false); + }); + + it("treats any upgrade as unexpected when no upgrade callback is given", async () => { + const memory = new MemoryIndexedDbFactory(); + + const result = await openIndexedDbDatabase({ + factory: memory.factory, + databaseName: "kernel", + version: 1, + translate: recordingTranslate([]), + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.cause).toBe("UPGRADE_REJECTED"); + }); + + it("reports UPGRADE_REJECTED before running upgrade when newVersion is null", async () => { + const stub = stubFactory(); + const upgrade = vi.fn(() => ({ kind: "APPLIED" as const })); + const pending = openIndexedDbDatabase({ + factory: stub.factory, + databaseName: "kernel", + translate: recordingTranslate([]), + upgrade, + }); + + const request = stub.opened[0]; + expect(request).toBeDefined(); + if (!request) return; + const abort = vi.fn(); + request.transaction = { abort } as unknown as IDBTransaction; + request.onupgradeneeded?.(versionChangeEvent(4, null)); + request.error = new DOMException("aborted", "AbortError"); + request.onerror?.(new Event("error")); + + expect(upgrade).not.toHaveBeenCalled(); + expect(abort).toHaveBeenCalledTimes(1); + await expect(pending).resolves.toEqual({ + ok: false, + error: { cause: "UPGRADE_REJECTED", detail: undefined }, + }); + }); + + it("admits a successful open and lets an async admission run", async () => { + const memory = new MemoryIndexedDbFactory(); + const result = await openIndexedDbDatabase({ + factory: memory.factory, + databaseName: "kernel", + version: 1, + translate: recordingTranslate([]), + upgrade: () => ({ kind: "APPLIED" }), + admit: async (database) => { + await Promise.resolve(); + return database.objectStoreNames.contains("rows") + ? { kind: "ADMIT" } + : { kind: "REJECT", detail: "STORE" }; + }, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error).toEqual({ cause: "ADMISSION_REJECTED", detail: "STORE" }); + // A rejected admission must close the connection the caller never saw. + expect(memory.isConnectionClosed()).toBe(true); + }); + + it("closes the connection when admission fails with an explicit cause", async () => { + const memory = new MemoryIndexedDbFactory(); + const result = await openIndexedDbDatabase({ + factory: memory.factory, + databaseName: "kernel", + version: 1, + translate: recordingTranslate([]), + upgrade: () => ({ kind: "APPLIED" }), + admit: () => ({ kind: "FAIL", cause: { kind: "CALLER_ABORT" } }), + }); + + expect(result).toEqual({ ok: false, error: { cause: "CALLER_ABORT" } }); + expect(memory.isConnectionClosed()).toBe(true); + }); + + it("admits every successful open when no admit callback is given", async () => { + const memory = new MemoryIndexedDbFactory(); + const result = await openIndexedDbDatabase({ + factory: memory.factory, + databaseName: "kernel", + version: 1, + translate: recordingTranslate([]), + upgrade: () => ({ kind: "APPLIED" }), + }); + expect(result.ok).toBe(true); + expect(memory.isConnectionClosed()).toBe(false); + }); + + it("returns CALLER_ABORT without touching the factory for an already aborted signal", async () => { + const stub = stubFactory(); + const controller = new AbortController(); + controller.abort(); + + const result = await openIndexedDbDatabase({ + factory: stub.factory, + databaseName: "kernel", + translate: recordingTranslate([]), + signal: controller.signal, + }); + + expect(result).toEqual({ ok: false, error: { cause: "CALLER_ABORT" } }); + expect(stub.opened).toHaveLength(0); + }); + + it("aborts a pending upgrade transaction when the caller signal fires", async () => { + const stub = stubFactory(); + const controller = new AbortController(); + const pending = openIndexedDbDatabase({ + factory: stub.factory, + databaseName: "kernel", + translate: recordingTranslate([]), + signal: controller.signal, + }); + + const request = stub.opened[0]; + expect(request).toBeDefined(); + if (!request) return; + const abort = vi.fn(); + request.transaction = { abort } as unknown as IDBTransaction; + controller.abort(); + + expect(abort).toHaveBeenCalledTimes(1); + await expect(pending).resolves.toEqual({ + ok: false, + error: { cause: "CALLER_ABORT" }, + }); + }); + + it("closes a connection that arrives after the caller gave up", async () => { + const memory = new MemoryIndexedDbFactory(); + const controller = new AbortController(); + let release: ((admission: { kind: "ADMIT" }) => void) | undefined; + + const pending = openIndexedDbDatabase({ + factory: memory.factory, + databaseName: "kernel", + version: 1, + translate: recordingTranslate([]), + signal: controller.signal, + upgrade: () => ({ kind: "APPLIED" }), + admit: () => + new Promise<{ kind: "ADMIT" }>((resolve) => { + release = resolve; + }), + }); + + await flush(); + controller.abort(); + await expect(pending).resolves.toEqual({ + ok: false, + error: { cause: "CALLER_ABORT" }, + }); + + release?.({ kind: "ADMIT" }); + await flush(); + expect(memory.isConnectionClosed()).toBe(true); + }); + + it("calls translate for every cause the kernel can report", async () => { + const seen: IndexedDbFailureCause["kind"][] = []; + const translate = recordingTranslate(seen); + + // NATIVE_EXCEPTION + await openIndexedDbDatabase({ + factory: { + open: () => { + throw new Error("boom"); + }, + } as unknown as IDBFactory, + databaseName: "kernel", + translate, + }); + + // UPGRADE_REJECTED + const rejecting = new MemoryIndexedDbFactory(); + await openIndexedDbDatabase({ + factory: rejecting.factory, + databaseName: "kernel", + version: 1, + translate, + }); + + // BLOCKED + const blocking = new MemoryIndexedDbFactory(); + blocking.blockNextOpen(); + await openIndexedDbDatabase({ + factory: blocking.factory, + databaseName: "kernel", + version: 1, + translate, + }); + + // BLOCKED_DEADLINE + const deadline = new MemoryIndexedDbFactory(); + const timers = manualTimers(); + deadline.blockNextOpen(); + const deadlinePending = openIndexedDbDatabase({ + factory: deadline.factory, + databaseName: "kernel", + version: 1, + translate, + blockedTimeoutMs: 5, + timers: timers.snapshot, + }); + await flush(); + timers.pending[0]?.callback(); + await deadlinePending; + + // ADMISSION_REJECTED + const admitting = new MemoryIndexedDbFactory(); + await openIndexedDbDatabase({ + factory: admitting.factory, + databaseName: "kernel", + version: 1, + translate, + upgrade: () => ({ kind: "APPLIED" }), + admit: () => ({ kind: "REJECT", detail: "POLICY" }), + }); + + // CALLER_ABORT + const aborted = new AbortController(); + aborted.abort(); + await openIndexedDbDatabase({ + factory: new MemoryIndexedDbFactory().factory, + databaseName: "kernel", + translate, + signal: aborted.signal, + }); + + // CLOSED + const handle = createIndexedDbConnection({ + open: () => new Promise(() => undefined), + translate, + }); + const closing = handle.acquire(); + handle.close(); + await closing; + + expect(new Set(seen)).toEqual( + new Set([ + "NATIVE_EXCEPTION", + "UPGRADE_REJECTED", + "BLOCKED", + "BLOCKED_DEADLINE", + "ADMISSION_REJECTED", + "CALLER_ABORT", + "CLOSED", + ]), + ); + }); +}); + +describe("single-flight IndexedDB connection handle", () => { + function handleFor( + memory: MemoryIndexedDbFactory, + overrides: Partial>[0]> = {}, + ) { + const opens = vi.fn((signal: AbortSignal | undefined) => + openIndexedDbDatabase({ + factory: memory.factory, + databaseName: "kernel", + version: 1, + translate: recordingTranslate([]), + signal, + upgrade: ({ database }) => { + if (!database.objectStoreNames.contains("rows")) { + database.createObjectStore("rows", { keyPath: "id" }); + } + return { kind: "APPLIED" }; + }, + }), + ); + return { + opens, + handle: createIndexedDbConnection({ + open: opens, + translate: recordingTranslate([]), + ...overrides, + }), + }; + } + + it("shares one in-flight open and then reuses the cached connection", async () => { + const memory = new MemoryIndexedDbFactory(); + const { handle, opens } = handleFor(memory); + + const [first, second] = await Promise.all([ + handle.acquire(), + handle.acquire(), + ]); + const third = await handle.acquire(); + + expect(opens).toHaveBeenCalledTimes(1); + expect(first.ok && second.ok && third.ok).toBe(true); + expect(handle.current()).not.toBeNull(); + }); + + it("drops and closes the cached connection on versionchange, then reopens", async () => { + const memory = new MemoryIndexedDbFactory(); + const events: string[] = []; + const { handle, opens } = handleFor(memory, { + onVersionChange: () => { + events.push("VERSION_CHANGE"); + throw new Error("a notification defect cannot keep the connection"); + }, + }); + + await handle.acquire(); + memory.triggerVersionChange(2); + + expect(events).toEqual(["VERSION_CHANGE"]); + expect(handle.current()).toBeNull(); + expect(memory.isConnectionClosed()).toBe(true); + + await handle.acquire(); + expect(opens).toHaveBeenCalledTimes(2); + }); + + it("drops the cached connection when the browser forces it closed", async () => { + const memory = new MemoryIndexedDbFactory(); + const events: string[] = []; + const { handle } = handleFor(memory, { + onForcedClose: () => events.push("FORCED"), + }); + + await handle.acquire(); + memory.triggerForcedClose(); + + expect(events).toEqual(["FORCED"]); + expect(handle.current()).toBeNull(); + expect(handle.isClosed()).toBe(false); + }); + + it("settles an in-flight open with CLOSED rather than CALLER_ABORT", async () => { + const seen: IndexedDbFailureCause["kind"][] = []; + let openSignal: AbortSignal | undefined; + const handle = createIndexedDbConnection({ + open: (signal) => { + openSignal = signal; + return new Promise(() => undefined); + }, + translate: recordingTranslate(seen), + }); + + const pending = handle.acquire(); + handle.close(); + handle.close(); + + await expect(pending).resolves.toEqual({ + ok: false, + error: { cause: "CLOSED" }, + }); + expect(seen).toEqual(["CLOSED"]); + expect(handle.isClosed()).toBe(true); + // The in-flight native request is still cancelled, so a pending upgrade + // transaction does not outlive the handle. + expect(openSignal?.aborted).toBe(true); + await expect(handle.acquire()).resolves.toEqual({ + ok: false, + error: { cause: "CLOSED" }, + }); + }); + + it("closes a connection that arrives after the handle was closed", async () => { + const memory = new MemoryIndexedDbFactory(); + let release: ((result: { ok: true; value: IDBDatabase }) => void) | undefined; + const handle = createIndexedDbConnection({ + open: () => + new Promise((resolve) => { + release = resolve as typeof release; + }), + translate: recordingTranslate([]), + }); + + const pending = handle.acquire(); + handle.close(); + await pending; + + const opened = await openIndexedDbDatabase({ + factory: memory.factory, + databaseName: "kernel", + version: 1, + translate: recordingTranslate([]), + upgrade: () => ({ kind: "APPLIED" }), + }); + expect(opened.ok).toBe(true); + if (!opened.ok) return; + release?.({ ok: true, value: opened.value }); + await flush(); + expect(memory.isConnectionClosed()).toBe(true); + expect(handle.current()).toBeNull(); + }); + + it("aborts one caller without cancelling the shared open", async () => { + const memory = new MemoryIndexedDbFactory(); + const { handle, opens } = handleFor(memory); + const controller = new AbortController(); + + const abandoned = handle.acquire(controller.signal); + const kept = handle.acquire(); + controller.abort(); + + await expect(abandoned).resolves.toEqual({ + ok: false, + error: { cause: "CALLER_ABORT" }, + }); + const survivor = await kept; + expect(survivor.ok).toBe(true); + expect(opens).toHaveBeenCalledTimes(1); + expect(handle.current()).not.toBeNull(); + }); + + it("reports CALLER_ABORT for an already aborted acquire without opening", async () => { + const memory = new MemoryIndexedDbFactory(); + const { handle, opens } = handleFor(memory); + const controller = new AbortController(); + controller.abort(); + + await expect(handle.acquire(controller.signal)).resolves.toEqual({ + ok: false, + error: { cause: "CALLER_ABORT" }, + }); + expect(opens).not.toHaveBeenCalled(); + }); + + it("closes the cached connection and reports it through current()", async () => { + const memory = new MemoryIndexedDbFactory(); + const { handle } = handleFor(memory); + + await handle.acquire(); + expect(handle.current()).not.toBeNull(); + handle.close(); + expect(handle.current()).toBeNull(); + expect(memory.isConnectionClosed()).toBe(true); + }); + + it("retries after a failed open instead of caching the failure", async () => { + const attempts: number[] = []; + const handle = createIndexedDbConnection({ + open: () => { + attempts.push(attempts.length); + return Promise.resolve({ + ok: false as const, + error: { cause: "NATIVE_EXCEPTION" as const }, + }); + }, + translate: recordingTranslate([]), + }); + + await handle.acquire(); + await handle.acquire(); + expect(attempts).toHaveLength(2); + }); +}); + +describe("IndexedDB database deletion", () => { + it("reports DELETED and settles the caller's registration exactly once", async () => { + const stub = stubFactory(); + const settled = vi.fn(); + const pending = deleteIndexedDbDatabase({ + factory: stub.factory, + databaseName: "kernel", + translate: recordingTranslate([]), + onSettled: settled, + }); + + const request = stub.deleted[0]; + expect(request).toBeDefined(); + request?.onsuccess?.(new Event("success")); + request?.onsuccess?.(new Event("success")); + + await expect(pending).resolves.toEqual({ ok: true, value: { kind: "DELETED" } }); + expect(settled).toHaveBeenCalledTimes(1); + }); + + it("routes a delete error through translate and still settles the registration", async () => { + const stub = stubFactory(); + const settled = vi.fn(); + const pending = deleteIndexedDbDatabase({ + factory: stub.factory, + databaseName: "kernel", + translate: recordingTranslate([]), + onSettled: settled, + }); + + const request = stub.deleted[0]; + if (!request) throw new Error("no delete request"); + request.error = new DOMException("nope", "UnknownError"); + request.onerror?.(new Event("error")); + + const result = await pending; + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.cause).toBe("NATIVE_EXCEPTION"); + expect(settled).toHaveBeenCalledTimes(1); + }); + + it("reports BLOCKED_DEADLINE without settling, then settles when the request lands", async () => { + const stub = stubFactory(); + const timers = manualTimers(); + const settled = vi.fn(); + const pending = deleteIndexedDbDatabase({ + factory: stub.factory, + databaseName: "kernel", + translate: recordingTranslate([]), + blockedTimeoutMs: 5_000, + timers: timers.snapshot, + onSettled: settled, + }); + + const request = stub.deleted[0]; + if (!request) throw new Error("no delete request"); + request.onblocked?.(versionChangeEvent(1, null)); + expect(timers.pending).toHaveLength(1); + timers.pending[0]?.callback(); + + await expect(pending).resolves.toEqual({ + ok: true, + value: { kind: "BLOCKED_DEADLINE" }, + }); + // A dispatched deleteDatabase cannot be cancelled, so the deadline is not + // the end of the request: the registration is released only when it lands. + expect(settled).not.toHaveBeenCalled(); + + request.onsuccess?.(new Event("success")); + expect(settled).toHaveBeenCalledTimes(1); + }); + + it("settles with BLOCKED when no deadline is configured", async () => { + const stub = stubFactory(); + const pending = deleteIndexedDbDatabase({ + factory: stub.factory, + databaseName: "kernel", + translate: recordingTranslate([]), + }); + + stub.deleted[0]?.onblocked?.(versionChangeEvent(2, null)); + await expect(pending).resolves.toEqual({ + ok: false, + error: { cause: "BLOCKED", detail: { oldVersion: 2, newVersion: null } }, + }); + }); +}); diff --git a/tests/unit/indexeddb-transaction.test.ts b/tests/unit/indexeddb-transaction.test.ts new file mode 100644 index 0000000..f36deb7 --- /dev/null +++ b/tests/unit/indexeddb-transaction.test.ts @@ -0,0 +1,716 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { + IndexedDbFailureCause, + IndexedDbTranslate, +} from "../../src/adapters/platform/indexeddb-connection.ts"; +import { openIndexedDbDatabase } from "../../src/adapters/platform/indexeddb-connection.ts"; +import { + onIndexedDbRequest, + openIndexedDbTransaction, + runIndexedDbTransaction, + walkIndexedDbCursor, + type IndexedDbCursorStep, + type IndexedDbRequestSink, + type IndexedDbWalkSummary, +} from "../../src/adapters/platform/indexeddb-transaction.ts"; +import { MemoryIndexedDbFactory } from "../helpers/memory-indexeddb.ts"; + +/** + * IDB-X-02 golden tests. Four hand-written copies of this state machine already + * disagree about request errors, value-less completions and durability. Every + * one of those choices has to remain expressible at the call site, so the tests + * assert the mechanics and never a taxonomy. + */ + +type TestFailure = Readonly<{ cause: IndexedDbFailureCause["kind"]; detail?: unknown }>; + +function recordingTranslate( + seen: IndexedDbFailureCause["kind"][] = [], +): IndexedDbTranslate { + return (cause) => { + seen.push(cause.kind); + switch (cause.kind) { + case "NATIVE_EXCEPTION": + return { cause: cause.kind, detail: cause.error }; + case "BLOCKED": + return { cause: cause.kind }; + case "BLOCKED_DEADLINE": + case "CALLER_ABORT": + case "CLOSED": + case "NO_VALUE_PRODUCED": + case "UNSUPPORTED": + return { cause: cause.kind }; + case "UPGRADE_REJECTED": + case "ADMISSION_REJECTED": + return { cause: cause.kind, detail: cause.detail }; + default: { + const exhaustive: never = cause; + return exhaustive; + } + } + }; +} + +type Row = Readonly<{ id: string; rank: number }>; + +const ROWS: readonly Row[] = Object.freeze([ + Object.freeze({ id: "a", rank: 1 }), + Object.freeze({ id: "b", rank: 2 }), + Object.freeze({ id: "c", rank: 3 }), + Object.freeze({ id: "d", rank: 4 }), +]); + +async function seededDatabase( + rows: readonly Row[] = ROWS, +): Promise> { + const memory = new MemoryIndexedDbFactory(); + const opened = await openIndexedDbDatabase({ + factory: memory.factory, + databaseName: "kernel", + version: 1, + translate: recordingTranslate(), + upgrade: ({ database }) => { + database.createObjectStore("rows", { keyPath: "id" }); + return { kind: "APPLIED" }; + }, + }); + if (!opened.ok) throw new Error("fixture database did not open"); + for (const row of rows) memory.seed("rows", row); + return { memory, database: opened.value }; +} + +type StubTransaction = { + error: DOMException | null; + abort: () => void; + oncomplete: ((event: Event) => unknown) | null; + onerror: ((event: Event) => unknown) | null; + onabort: ((event: Event) => unknown) | null; + objectStore: (name: string) => IDBObjectStore; +}; + +/** + * A transaction the test drives by hand. The memory fake aborts on every + * request error, so "recorded but not aborted" and "abort() itself throws" — + * the two behaviors the four copies disagree about — are only observable here. + */ +function stubDatabase( + options: Readonly<{ abortThrows?: boolean; rejectOptionsBag?: boolean }> = {}, +): Readonly<{ + database: IDBDatabase; + transactions: StubTransaction[]; + calls: unknown[][]; +}> { + const transactions: StubTransaction[] = []; + const calls: unknown[][] = []; + const database = { + transaction: (...args: unknown[]) => { + calls.push(args); + if (options.rejectOptionsBag === true && args.length > 2) { + throw new TypeError("This engine has no durability option."); + } + const transaction: StubTransaction = { + error: null, + abort: vi.fn(() => { + if (options.abortThrows === true) { + throw new DOMException("already finished", "InvalidStateError"); + } + }), + oncomplete: null, + onerror: null, + onabort: null, + objectStore: () => ({}) as IDBObjectStore, + }; + transactions.push(transaction); + return transaction as unknown as IDBTransaction; + }, + } as unknown as IDBDatabase; + return { database, transactions, calls }; +} + +function stubSink(): Readonly<{ + sink: IndexedDbRequestSink; + failures: TestFailure[]; + requestErrors: unknown[]; +}> { + const failures: TestFailure[] = []; + const requestErrors: unknown[] = []; + return { + failures, + requestErrors, + sink: { + fail: (failure) => failures.push(failure), + requestFailed: (error) => requestErrors.push(error), + }, + }; +} + +describe("shared IndexedDB transaction mechanics", () => { + it("returns the value the queue produced once the transaction commits", async () => { + const { database, memory } = await seededDatabase(); + + const result = await runIndexedDbTransaction({ + database, + stores: ["rows"], + mode: "readwrite", + translate: recordingTranslate(), + queue: (transaction, context) => { + const store = transaction.objectStore("rows"); + onIndexedDbRequest(store.put({ id: "e", rank: 5 }), context, () => { + context.succeed("written"); + }); + }, + }); + + expect(result).toEqual({ ok: true, value: "written" }); + expect(memory.readRaw("rows", "e")).toEqual({ id: "e", rank: 5 }); + }); + + it("reports NO_VALUE_PRODUCED when a transaction completes without succeed", async () => { + const { database } = await seededDatabase(); + const seen: IndexedDbFailureCause["kind"][] = []; + + const result = await runIndexedDbTransaction({ + database, + stores: ["rows"], + mode: "readonly", + translate: recordingTranslate(seen), + queue: (transaction, context) => { + onIndexedDbRequest( + transaction.objectStore("rows").get("a"), + context, + () => undefined, + ); + }, + }); + + expect(result).toEqual({ + ok: false, + error: { cause: "NO_VALUE_PRODUCED" }, + }); + expect(seen).toEqual(["NO_VALUE_PRODUCED"]); + }); + + it("aborts on fail() and reports the recorded failure", async () => { + const { database, memory } = await seededDatabase(); + + const result = await runIndexedDbTransaction({ + database, + stores: ["rows"], + mode: "readwrite", + translate: recordingTranslate(), + queue: (transaction, context) => { + const store = transaction.objectStore("rows"); + onIndexedDbRequest(store.put({ id: "e", rank: 5 }), context, () => { + context.fail({ cause: "ADMISSION_REJECTED", detail: "policy" }); + }); + }, + }); + + expect(result).toEqual({ + ok: false, + error: { cause: "ADMISSION_REJECTED", detail: "policy" }, + }); + // The aborted transaction must not have committed the write. + expect(memory.readRaw("rows", "e")).toBeUndefined(); + }); + + it("routes a request-level error into the sink", async () => { + const { database } = await seededDatabase(); + + const result = await runIndexedDbTransaction({ + database, + stores: ["rows"], + mode: "readwrite", + translate: recordingTranslate(), + queue: (transaction, context) => { + const store = transaction.objectStore("rows"); + // `add` on an existing key is a request-level ConstraintError, the + // exact case `indexeddb-opfs-journal.ts` never wires today. + onIndexedDbRequest(store.add({ id: "a", rank: 9 }), context, () => { + context.succeed("unreachable"); + }); + }, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.cause).toBe("NATIVE_EXCEPTION"); + expect((result.error.detail as DOMException).name).toBe("ConstraintError"); + }); + + it("records a request error without aborting, while fail() aborts", () => { + const stub = stubDatabase(); + void runIndexedDbTransaction({ + database: stub.database, + stores: ["rows"], + mode: "readwrite", + translate: recordingTranslate(), + queue: (_transaction, context) => { + context.requestFailed(new Error("recorded only")); + }, + }); + + const transaction = stub.transactions[0]; + expect(transaction).toBeDefined(); + expect(transaction?.abort).not.toHaveBeenCalled(); + }); + + it("never claims a caller abort when transaction.abort() throws", async () => { + // CP-4. A transaction can be durably committed while its completion event + // is still queued. Reporting ABORTED for it would call a committed write a + // failure, so the transaction's own events decide the outcome. + const stub = stubDatabase({ abortThrows: true }); + const controller = new AbortController(); + + const pending = runIndexedDbTransaction({ + database: stub.database, + stores: ["rows"], + mode: "readwrite", + translate: recordingTranslate(), + signal: controller.signal, + queue: (_transaction, context) => { + context.succeed("committed"); + }, + }); + + controller.abort(); + stub.transactions[0]?.oncomplete?.(new Event("complete")); + + await expect(pending).resolves.toEqual({ ok: true, value: "committed" }); + }); + + it("reports CALLER_ABORT when the signal aborts the transaction", async () => { + const stub = stubDatabase(); + const controller = new AbortController(); + + const pending = runIndexedDbTransaction({ + database: stub.database, + stores: ["rows"], + mode: "readwrite", + translate: recordingTranslate(), + signal: controller.signal, + queue: () => undefined, + }); + + controller.abort(); + expect(stub.transactions[0]?.abort).toHaveBeenCalledTimes(1); + stub.transactions[0]?.onabort?.(new Event("abort")); + + await expect(pending).resolves.toEqual({ + ok: false, + error: { cause: "CALLER_ABORT" }, + }); + }); + + it("returns CALLER_ABORT without opening a transaction for an aborted signal", async () => { + const stub = stubDatabase(); + const controller = new AbortController(); + controller.abort(); + + await expect( + runIndexedDbTransaction({ + database: stub.database, + stores: ["rows"], + mode: "readonly", + translate: recordingTranslate(), + signal: controller.signal, + queue: () => undefined, + }), + ).resolves.toEqual({ ok: false, error: { cause: "CALLER_ABORT" } }); + expect(stub.calls).toHaveLength(0); + }); + + it("maps a throw from transaction creation", async () => { + const boom = new DOMException("closed", "InvalidStateError"); + const database = { + transaction: () => { + throw boom; + }, + } as unknown as IDBDatabase; + + await expect( + runIndexedDbTransaction({ + database, + stores: ["rows"], + mode: "readonly", + translate: recordingTranslate(), + queue: () => undefined, + }), + ).resolves.toEqual({ + ok: false, + error: { cause: "NATIVE_EXCEPTION", detail: boom }, + }); + }); + + it("maps a throw from the queue callback and aborts", async () => { + const { database, memory } = await seededDatabase(); + const boom = new Error("queue defect"); + + const result = await runIndexedDbTransaction({ + database, + stores: ["rows"], + mode: "readwrite", + translate: recordingTranslate(), + queue: (transaction) => { + transaction.objectStore("rows").put({ id: "e", rank: 5 }); + throw boom; + }, + }); + + expect(result).toEqual({ + ok: false, + error: { cause: "NATIVE_EXCEPTION", detail: boom }, + }); + expect(memory.readRaw("rows", "e")).toBeUndefined(); + }); + + it("an abort after succeed reports a failure rather than the value", async () => { + const stub = stubDatabase(); + const pending = runIndexedDbTransaction({ + database: stub.database, + stores: ["rows"], + mode: "readwrite", + translate: recordingTranslate(), + queue: (_transaction, context) => { + context.succeed("value"); + context.fail({ cause: "CLOSED" }); + }, + }); + + const transaction = stub.transactions[0]; + if (!transaction) throw new Error("no transaction"); + transaction.error = new DOMException("aborted", "AbortError"); + transaction.onabort?.(new Event("abort")); + + const result = await pending; + expect(result.ok).toBe(false); + if (result.ok) return; + // The first candidate wins, so the later `fail` never becomes the value; + // an aborted transaction still cannot report the success it never kept. + expect(result.error.cause).toBe("NATIVE_EXCEPTION"); + }); +}); + +describe("IndexedDB transaction factory", () => { + it("omits the options bag entirely for an undefined durability", () => { + const stub = stubDatabase(); + openIndexedDbTransaction(stub.database, ["rows"], "readonly"); + expect(stub.calls).toEqual([[["rows"], "readonly"]]); + }); + + it("passes a named durability as an options bag", () => { + const stub = stubDatabase(); + openIndexedDbTransaction(stub.database, ["rows"], "readwrite", "strict"); + expect(stub.calls).toEqual([ + [["rows"], "readwrite", { durability: "strict" }], + ]); + }); + + it("falls back to the no-options form when the engine rejects the bag", () => { + const stub = stubDatabase({ rejectOptionsBag: true }); + openIndexedDbTransaction(stub.database, ["rows"], "readwrite", "relaxed"); + expect(stub.calls).toEqual([ + [["rows"], "readwrite", { durability: "relaxed" }], + [["rows"], "readwrite"], + ]); + }); + + it("rethrows a non-TypeError from transaction creation", () => { + const boom = new DOMException("closed", "InvalidStateError"); + const database = { + transaction: () => { + throw boom; + }, + } as unknown as IDBDatabase; + expect(() => + openIndexedDbTransaction(database, ["rows"], "readonly", "strict"), + ).toThrow(boom); + }); +}); + +describe("IndexedDB cursor pump", () => { + async function walk( + input: Readonly<{ + visit: ( + visit: Readonly<{ + cursor: IDBCursorWithValue; + scannedRows: number; + resume: (step: IndexedDbCursorStep) => void; + }>, + ) => IndexedDbCursorStep; + budget?: Parameters>[0]["budget"]; + signal?: AbortSignal; + store?: IDBObjectStore | undefined; + }>, + ): Promise< + Readonly<{ + result: Awaited>>; + }> + > { + const { database } = await seededDatabase(); + const result = await runIndexedDbTransaction< + IndexedDbWalkSummary, + TestFailure + >({ + database, + stores: ["rows"], + mode: "readwrite", + translate: recordingTranslate(), + queue: (transaction, context) => { + const store = transaction.objectStore("rows"); + walkIndexedDbCursor({ + request: store.openCursor(), + sink: context, + translate: recordingTranslate(), + ...(input.budget ? { budget: input.budget } : {}), + ...(input.signal ? { signal: input.signal } : {}), + visit: input.visit, + done: (summary) => context.succeed(summary), + }); + }, + }); + return { result }; + } + + it("visits every row and reports EXHAUSTED", async () => { + const visited: string[] = []; + const { result } = await walk({ + visit: ({ cursor }) => { + visited.push((cursor.value as Row).id); + return { kind: "CONTINUE" }; + }, + }); + + expect(visited).toEqual(["a", "b", "c", "d"]); + expect(result).toEqual({ + ok: true, + value: { reason: "EXHAUSTED", scannedRows: 4 }, + }); + }); + + it("ends the walk where the visitor stops it", async () => { + const { result } = await walk({ + visit: ({ scannedRows }) => + scannedRows === 2 ? { kind: "STOP" } : { kind: "CONTINUE" }, + }); + + expect(result).toEqual({ + ok: true, + value: { reason: "STOPPED", scannedRows: 2 }, + }); + }); + + it("resumes a row after the visitor's own nested request chain", async () => { + // Without SUSPEND the pump is unusable by runtime and maintenance: every + // walk there issues nested requests before advancing. + const order: string[] = []; + const { database } = await seededDatabase(); + const result = await runIndexedDbTransaction< + IndexedDbWalkSummary, + TestFailure + >({ + database, + stores: ["rows"], + mode: "readwrite", + translate: recordingTranslate(), + queue: (transaction, context) => { + const store = transaction.objectStore("rows"); + walkIndexedDbCursor({ + request: store.openCursor(), + sink: context, + translate: recordingTranslate(), + visit: ({ cursor, resume }) => { + const id = (cursor.value as Row).id; + order.push(`visit:${id}`); + onIndexedDbRequest(store.get(id), context, (value) => { + order.push(`nested:${(value as Row).id}`); + resume({ kind: "CONTINUE" }); + // Idempotent: a second resume cannot advance the cursor twice. + resume({ kind: "CONTINUE" }); + }); + return { kind: "SUSPEND" }; + }, + done: (summary) => context.succeed(summary), + }); + }, + }); + + expect(result).toEqual({ + ok: true, + value: { reason: "EXHAUSTED", scannedRows: 4 }, + }); + expect(order).toEqual([ + "visit:a", + "nested:a", + "visit:b", + "nested:b", + "visit:c", + "nested:c", + "visit:d", + "nested:d", + ]); + }); + + it("supports both keyed resume forms", async () => { + const skipped: string[] = []; + const { result } = await walk({ + visit: ({ cursor, scannedRows }) => { + skipped.push((cursor.value as Row).id); + if (scannedRows === 1) return { kind: "CONTINUE_FROM", key: "c" }; + if (scannedRows === 2) { + return { kind: "CONTINUE_PRIMARY", key: "d", primaryKey: "d" }; + } + return { kind: "CONTINUE" }; + }, + }); + + expect(skipped).toEqual(["a", "c", "d"]); + expect(result).toEqual({ + ok: true, + value: { reason: "EXHAUSTED", scannedRows: 3 }, + }); + }); + + it("stops on a row budget and on a time budget with distinct reasons", async () => { + const rowBudget = await walk({ + budget: { + admit: (scannedRows) => + scannedRows >= 2 + ? { ok: true, value: "ROW_BUDGET" } + : { ok: true, value: "CONTINUE" }, + }, + visit: () => ({ kind: "CONTINUE" }), + }); + expect(rowBudget.result).toEqual({ + ok: true, + value: { reason: "ROW_BUDGET", scannedRows: 2 }, + }); + + const timeBudget = await walk({ + budget: { + admit: (scannedRows) => + scannedRows >= 1 + ? { ok: true, value: "TIME_BUDGET" } + : { ok: true, value: "CONTINUE" }, + }, + visit: () => ({ kind: "CONTINUE" }), + }); + expect(timeBudget.result).toEqual({ + ok: true, + value: { reason: "TIME_BUDGET", scannedRows: 1 }, + }); + }); + + it("routes an unreadable budget clock into the sink instead of continuing", async () => { + const { result } = await walk({ + budget: { + admit: () => ({ ok: false, error: { cause: "UNSUPPORTED" } }), + }, + visit: () => ({ kind: "CONTINUE" }), + }); + + expect(result).toEqual({ ok: false, error: { cause: "UNSUPPORTED" } }); + }); + + it("aborts the transaction and ends the walk when the caller signal fired", async () => { + const controller = new AbortController(); + const { result } = await walk({ + signal: controller.signal, + visit: ({ cursor }) => { + if ((cursor.value as Row).id === "a") controller.abort(); + return { kind: "CONTINUE" }; + }, + }); + + expect(result).toEqual({ ok: false, error: { cause: "CALLER_ABORT" } }); + }); + + it("routes a native advance failure through the sink", async () => { + const { result } = await walk({ + visit: ({ cursor }) => { + // Advancing twice is an InvalidStateError in the engine; the pump must + // report it rather than let it escape the event handler. + cursor.continue(); + return { kind: "CONTINUE" }; + }, + }); + + expect(result.ok).toBe(false); + if (result.ok) return; + expect(result.error.cause).toBe("NATIVE_EXCEPTION"); + }); + + it("reports a cursor request error through requestFailed and stops", () => { + const stub = stubSink(); + const request = { + result: null, + error: new DOMException("gone", "UnknownError"), + onsuccess: null as ((event: Event) => unknown) | null, + onerror: null as ((event: Event) => unknown) | null, + }; + const done = vi.fn(); + walkIndexedDbCursor({ + request: request as unknown as IDBRequest, + sink: stub.sink, + translate: recordingTranslate(), + visit: () => ({ kind: "CONTINUE" }), + done, + }); + + request.onerror?.(new Event("error")); + expect(stub.requestErrors).toEqual([request.error]); + // The transaction's own outcome decides the result; the walk has no value. + expect(done).not.toHaveBeenCalled(); + }); + + it("routes a throw from the visitor into the sink", () => { + const stub = stubSink(); + const boom = new Error("visitor defect"); + const request = { + result: { value: { id: "a" }, continue: vi.fn() }, + error: null, + onsuccess: null as ((event: Event) => unknown) | null, + onerror: null as ((event: Event) => unknown) | null, + }; + walkIndexedDbCursor({ + request: request as unknown as IDBRequest, + sink: stub.sink, + translate: recordingTranslate(), + visit: () => { + throw boom; + }, + done: vi.fn(), + }); + + request.onsuccess?.(new Event("success")); + // A defective visitor must abort rather than let a partial write commit. + expect(stub.failures).toEqual([ + { cause: "NATIVE_EXCEPTION", detail: boom }, + ]); + }); + + it("ignores resume when the visitor did not suspend", () => { + const stub = stubSink(); + const advance = vi.fn(); + const request = { + result: { value: { id: "a" }, continue: advance }, + error: null, + onsuccess: null as ((event: Event) => unknown) | null, + onerror: null as ((event: Event) => unknown) | null, + }; + walkIndexedDbCursor({ + request: request as unknown as IDBRequest, + sink: stub.sink, + translate: recordingTranslate(), + visit: ({ resume }) => { + resume({ kind: "CONTINUE" }); + return { kind: "STOP" }; + }, + done: vi.fn(), + }); + + request.onsuccess?.(new Event("success")); + expect(advance).not.toHaveBeenCalled(); + }); +});