feat: add the shared IndexedDB connection and transaction kernel
네 어댑터가 각자 구현한 open/blocked/upgrade/트랜잭션 메커니즘을 커널로 모은다. 사본은 아직 이행하지 않았으므로 런타임 동작은 그대로다. 실패 분류는 커널에 넣지 않고 translate 콜백으로 주입한다. RT/MT/OP의 mapIndexedDbException과 CP의 mapBrowserDataException이 같은 에러에 다른 답을 내며, 그 차이를 통일하는 것은 별건이기 때문이다. IndexedDbFailureCause는 translate의 입력과 호출자가 주는 admit FAIL에만 나오고 공개 반환 타입에는 나오지 않는다. 기존 커널 abortable-operation.ts는 AbortTerminalReason 3멤버를 반환 타입에 박아서 5종이 필요한 http v3가 아예 쓰지 못했는데, 그 함정을 피하기 위한 설계다. 사양 3.4의 네 호출부를 실제 코드로 써서 tsc --strict로 컴파일해 수용을 확인했다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
0f157494cd
commit
cb62bfb9a9
@@ -74,68 +74,70 @@
|
|||||||
| 64 | `src/adapters/platform/browser-mutation-intent-factory.ts` | [Network/state](./01-network-and-state.md) |
|
| 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) |
|
| 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) |
|
| 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) |
|
| 67 | `src/adapters/platform/indexeddb-connection.ts` | [Network/state](./01-network-and-state.md) |
|
||||||
| 68 | `src/adapters/query-cache/conditional-validator-store.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/query-cache/cursor-pagination-runtime.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/index.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/server-state-scope-runtime.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/tanstack-cache-coordinator.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/tanstack-query-cache.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/realtime/event-codec.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
| 74 | `src/adapters/query-cache/tanstack-cache-coordinator.ts` | [Network/state](./01-network-and-state.md) |
|
||||||
| 75 | `src/adapters/realtime/event-consumer.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
| 75 | `src/adapters/query-cache/tanstack-query-cache.ts` | [Network/state](./01-network-and-state.md) |
|
||||||
| 76 | `src/adapters/realtime/index.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
| 76 | `src/adapters/realtime/event-codec.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) |
|
| 77 | `src/adapters/realtime/event-consumer.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) |
|
| 78 | `src/adapters/realtime/index.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) |
|
| 79 | `src/adapters/realtime/json-member-scanner.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||||
| 80 | `src/adapters/realtime/polling/index.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/reconnect-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/reconnect-policy.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/result.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/sse/fetch-sse-connection.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/sse/index.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/sse-parser.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/stream-coordinator.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/websocket/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/websocket/websocket-connection.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/websocket-protocol.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/service-worker/index.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
| 91 | `src/adapters/realtime/websocket/websocket-connection.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||||
| 92 | `src/adapters/service-worker/service-worker-entry.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
| 92 | `src/adapters/realtime/websocket/websocket-protocol.ts` | [Realtime/RPC](./02-realtime-and-browser-rpc.md) |
|
||||||
| 93 | `src/adapters/service-worker/service-worker-lifecycle.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
| 93 | `src/adapters/service-worker/index.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) |
|
| 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-protocol.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-removal.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-static-assets.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/storage/browser-storage-adapter.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
| 98 | `src/adapters/service-worker/service-worker-removal.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||||
| 99 | `src/adapters/storage/browser-storage-codec.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
| 99 | `src/adapters/service-worker/service-worker-static-assets.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
||||||
| 100 | `src/adapters/storage/index.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
| 100 | `src/adapters/storage/browser-storage-adapter.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||||
| 101 | `src/adapters/storage/indexeddb/index.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/indexeddb/indexeddb-failure.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/indexeddb-governance.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-maintenance.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-migrations.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-runtime.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-types.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/opfs/browser-opfs-runtime.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/opfs/index.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/indexeddb-opfs-journal.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/opfs-byte-store-adapter.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/opfs-policy.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-worker-client.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-worker-protocol.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-runtime.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/telemetry/best-effort-telemetry.ts` | [Network/state](./01-network-and-state.md) |
|
| 116 | `src/adapters/storage/opfs/opfs-worker-protocol.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||||
| 117 | `src/adapters/telemetry/index.ts` | [Network/state](./01-network-and-state.md) |
|
| 117 | `src/adapters/storage/opfs/opfs-worker-runtime.ts` | [Storage/files](./03-storage-and-browser-files.md) |
|
||||||
| 118 | `src/adapters/web-push/inbound/notification-click-adapter.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
| 118 | `src/adapters/telemetry/best-effort-telemetry.ts` | [Network/state](./01-network-and-state.md) |
|
||||||
| 119 | `src/adapters/web-push/inbound/push-event-adapter.ts` | [Worker/push](./05-service-worker-and-web-push.md) |
|
| 119 | `src/adapters/telemetry/index.ts` | [Network/state](./01-network-and-state.md) |
|
||||||
| 120 | `src/adapters/web-push/index.ts` | [Worker/push](./05-service-worker-and-web-push.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/notification-registry.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/push-association-fence-store.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/push-codec.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-registration-gateway.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-subscription-adapter.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/runtime-support.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/service-worker-runtime.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/service-worker-scope-host.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를 같은 변경에서 갱신한다.
|
||||||
|
|
||||||
|
|||||||
@@ -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<Failure> = (
|
||||||
|
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<Failure> = 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<Failure>;
|
||||||
|
/**
|
||||||
|
* 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<IndexedDbAdmission>;
|
||||||
|
/** 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<Failure> = 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<Result<IDBDatabase, Failure>>;
|
||||||
|
/** 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<Failure> = 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<Result<IDBDatabase, Failure>>;
|
||||||
|
translate: IndexedDbTranslate<Failure>;
|
||||||
|
/**
|
||||||
|
* 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<Failure> = Readonly<{
|
||||||
|
factory: IDBFactory;
|
||||||
|
databaseName: string;
|
||||||
|
translate: IndexedDbTranslate<Failure>;
|
||||||
|
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, Failure>(value: Value): Result<Value, Failure> {
|
||||||
|
return Object.freeze({ ok: true as const, value });
|
||||||
|
}
|
||||||
|
|
||||||
|
function failed<Value, Failure>(error: Failure): Result<Value, Failure> {
|
||||||
|
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<Failure>(
|
||||||
|
input: IndexedDbOpenInput<Failure>,
|
||||||
|
): Promise<Result<IDBDatabase, Failure>> {
|
||||||
|
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<Result<IDBDatabase, Failure>>((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
let blockedTimer: unknown;
|
||||||
|
let blockedTimerSet = false;
|
||||||
|
let upgradeRejection: IndexedDbFailureCause | null = null;
|
||||||
|
|
||||||
|
const settle = (result: Result<IDBDatabase, Failure>) => {
|
||||||
|
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<IndexedDbAdmission>;
|
||||||
|
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<Failure>(
|
||||||
|
input: IndexedDbConnectionInput<Failure>,
|
||||||
|
): IndexedDbConnection<Failure> {
|
||||||
|
let connection: IDBDatabase | null = null;
|
||||||
|
let attempt: Promise<Result<IDBDatabase, Failure>> | 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<Result<IDBDatabase, Failure>> => {
|
||||||
|
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<IDBDatabase, Failure>(
|
||||||
|
input.translate({ kind: "NATIVE_EXCEPTION", error }),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
attempt = started;
|
||||||
|
return started;
|
||||||
|
};
|
||||||
|
|
||||||
|
return Object.freeze({
|
||||||
|
acquire(signal) {
|
||||||
|
if (closed) {
|
||||||
|
return Promise.resolve(
|
||||||
|
failed<IDBDatabase, Failure>(input.translate({ kind: "CLOSED" })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (signal?.aborted) {
|
||||||
|
return Promise.resolve(
|
||||||
|
failed<IDBDatabase, Failure>(
|
||||||
|
input.translate({ kind: "CALLER_ABORT" }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const live = connection;
|
||||||
|
if (live) return Promise.resolve(succeeded<IDBDatabase, Failure>(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<Result<IDBDatabase, Failure>>((resolve) => {
|
||||||
|
let callerSettled = false;
|
||||||
|
const finishCaller = (result: Result<IDBDatabase, Failure>) => {
|
||||||
|
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<Failure>(
|
||||||
|
input: IndexedDbDeleteInput<Failure>,
|
||||||
|
): Promise<Result<IndexedDbDeleteOutcome, Failure>> {
|
||||||
|
const { factory, databaseName, translate } = input;
|
||||||
|
const bounded = assertBlockedDeadline(input.blockedTimeoutMs, input.timers);
|
||||||
|
|
||||||
|
return new Promise<Result<IndexedDbDeleteOutcome, Failure>>((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<IndexedDbDeleteOutcome, Failure>) => {
|
||||||
|
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 }));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<Failure> = 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<Value, Failure> =
|
||||||
|
IndexedDbRequestSink<Failure> &
|
||||||
|
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<Value, Failure> = Readonly<{
|
||||||
|
database: IDBDatabase;
|
||||||
|
stores: readonly string[];
|
||||||
|
mode: "readonly" | "readwrite";
|
||||||
|
translate: IndexedDbTranslate<Failure>;
|
||||||
|
/** 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<Value, Failure>,
|
||||||
|
) => 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<Failure> = 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<IndexedDbBudgetVerdict, Failure>;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
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<Failure> = Readonly<{
|
||||||
|
request: IDBRequest<IDBCursorWithValue | null>;
|
||||||
|
sink: IndexedDbRequestSink<Failure>;
|
||||||
|
translate: IndexedDbTranslate<Failure>;
|
||||||
|
budget?: IndexedDbBudget<Failure>;
|
||||||
|
/**
|
||||||
|
* 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, Failure>(value: Value): Result<Value, Failure> {
|
||||||
|
return Object.freeze({ ok: true as const, value });
|
||||||
|
}
|
||||||
|
|
||||||
|
function failed<Value, Failure>(error: Failure): Result<Value, Failure> {
|
||||||
|
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<Value, Failure>(
|
||||||
|
input: IndexedDbTransactionInput<Value, Failure>,
|
||||||
|
): Promise<Result<Value, Failure>> {
|
||||||
|
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<Result<Value, Failure>>((resolve) => {
|
||||||
|
let candidate: Result<Value, Failure> | undefined;
|
||||||
|
let requestError: unknown;
|
||||||
|
let hasRequestError = false;
|
||||||
|
let callerAborted = false;
|
||||||
|
let settled = false;
|
||||||
|
|
||||||
|
const finish = (result: Result<Value, Failure>) => {
|
||||||
|
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<Value, Failure> = 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<Value, Failure>(
|
||||||
|
request: IDBRequest<Value>,
|
||||||
|
sink: IndexedDbRequestSink<Failure>,
|
||||||
|
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<Failure>(
|
||||||
|
input: IndexedDbWalkInput<Failure>,
|
||||||
|
): 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);
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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<TestFailure> {
|
||||||
|
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<Readonly<{ callback: () => void; delayMs: number }>>;
|
||||||
|
cleared: unknown[];
|
||||||
|
}> {
|
||||||
|
const pending: Array<Readonly<{ callback: () => 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<void> {
|
||||||
|
for (let attempt = 0; attempt < 30; attempt += 1) {
|
||||||
|
await new Promise<void>((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<TestFailure>({
|
||||||
|
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<TestFailure>({
|
||||||
|
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<TestFailure>({
|
||||||
|
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<TestFailure>({
|
||||||
|
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<Readonly<{ oldVersion: number; newVersion: number | null }>> = [];
|
||||||
|
memory.blockNextOpen();
|
||||||
|
|
||||||
|
const result = await openIndexedDbDatabase<TestFailure>({
|
||||||
|
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<TestFailure>({
|
||||||
|
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<TestFailure>({
|
||||||
|
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<TestFailure>({
|
||||||
|
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<TestFailure>({
|
||||||
|
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<TestFailure>({
|
||||||
|
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<TestFailure>({
|
||||||
|
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<TestFailure>({
|
||||||
|
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<TestFailure>({
|
||||||
|
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<TestFailure>({
|
||||||
|
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<TestFailure>({
|
||||||
|
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<TestFailure>({
|
||||||
|
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<TestFailure>({
|
||||||
|
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<TestFailure>({
|
||||||
|
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<TestFailure>({
|
||||||
|
factory: {
|
||||||
|
open: () => {
|
||||||
|
throw new Error("boom");
|
||||||
|
},
|
||||||
|
} as unknown as IDBFactory,
|
||||||
|
databaseName: "kernel",
|
||||||
|
translate,
|
||||||
|
});
|
||||||
|
|
||||||
|
// UPGRADE_REJECTED
|
||||||
|
const rejecting = new MemoryIndexedDbFactory();
|
||||||
|
await openIndexedDbDatabase<TestFailure>({
|
||||||
|
factory: rejecting.factory,
|
||||||
|
databaseName: "kernel",
|
||||||
|
version: 1,
|
||||||
|
translate,
|
||||||
|
});
|
||||||
|
|
||||||
|
// BLOCKED
|
||||||
|
const blocking = new MemoryIndexedDbFactory();
|
||||||
|
blocking.blockNextOpen();
|
||||||
|
await openIndexedDbDatabase<TestFailure>({
|
||||||
|
factory: blocking.factory,
|
||||||
|
databaseName: "kernel",
|
||||||
|
version: 1,
|
||||||
|
translate,
|
||||||
|
});
|
||||||
|
|
||||||
|
// BLOCKED_DEADLINE
|
||||||
|
const deadline = new MemoryIndexedDbFactory();
|
||||||
|
const timers = manualTimers();
|
||||||
|
deadline.blockNextOpen();
|
||||||
|
const deadlinePending = openIndexedDbDatabase<TestFailure>({
|
||||||
|
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<TestFailure>({
|
||||||
|
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<TestFailure>({
|
||||||
|
factory: new MemoryIndexedDbFactory().factory,
|
||||||
|
databaseName: "kernel",
|
||||||
|
translate,
|
||||||
|
signal: aborted.signal,
|
||||||
|
});
|
||||||
|
|
||||||
|
// CLOSED
|
||||||
|
const handle = createIndexedDbConnection<TestFailure>({
|
||||||
|
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<Parameters<typeof createIndexedDbConnection<TestFailure>>[0]> = {},
|
||||||
|
) {
|
||||||
|
const opens = vi.fn((signal: AbortSignal | undefined) =>
|
||||||
|
openIndexedDbDatabase<TestFailure>({
|
||||||
|
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<TestFailure>({
|
||||||
|
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<TestFailure>({
|
||||||
|
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<TestFailure>({
|
||||||
|
open: () =>
|
||||||
|
new Promise((resolve) => {
|
||||||
|
release = resolve as typeof release;
|
||||||
|
}),
|
||||||
|
translate: recordingTranslate([]),
|
||||||
|
});
|
||||||
|
|
||||||
|
const pending = handle.acquire();
|
||||||
|
handle.close();
|
||||||
|
await pending;
|
||||||
|
|
||||||
|
const opened = await openIndexedDbDatabase<TestFailure>({
|
||||||
|
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<TestFailure>({
|
||||||
|
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<TestFailure>({
|
||||||
|
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<TestFailure>({
|
||||||
|
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<TestFailure>({
|
||||||
|
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<TestFailure>({
|
||||||
|
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 } },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<TestFailure> {
|
||||||
|
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<Readonly<{ memory: MemoryIndexedDbFactory; database: IDBDatabase }>> {
|
||||||
|
const memory = new MemoryIndexedDbFactory();
|
||||||
|
const opened = await openIndexedDbDatabase<TestFailure>({
|
||||||
|
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<TestFailure>;
|
||||||
|
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<string, TestFailure>({
|
||||||
|
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<string, TestFailure>({
|
||||||
|
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<string, TestFailure>({
|
||||||
|
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<string, TestFailure>({
|
||||||
|
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<string, TestFailure>({
|
||||||
|
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<string, TestFailure>({
|
||||||
|
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<string, TestFailure>({
|
||||||
|
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<string, TestFailure>({
|
||||||
|
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<string, TestFailure>({
|
||||||
|
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<string, TestFailure>({
|
||||||
|
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<string, TestFailure>({
|
||||||
|
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<typeof walkIndexedDbCursor<TestFailure>>[0]["budget"];
|
||||||
|
signal?: AbortSignal;
|
||||||
|
store?: IDBObjectStore | undefined;
|
||||||
|
}>,
|
||||||
|
): Promise<
|
||||||
|
Readonly<{
|
||||||
|
result: Awaited<ReturnType<typeof runIndexedDbTransaction<IndexedDbWalkSummary, TestFailure>>>;
|
||||||
|
}>
|
||||||
|
> {
|
||||||
|
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<TestFailure>({
|
||||||
|
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<TestFailure>({
|
||||||
|
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<TestFailure>({
|
||||||
|
request: request as unknown as IDBRequest<IDBCursorWithValue | null>,
|
||||||
|
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<TestFailure>({
|
||||||
|
request: request as unknown as IDBRequest<IDBCursorWithValue | null>,
|
||||||
|
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<TestFailure>({
|
||||||
|
request: request as unknown as IDBRequest<IDBCursorWithValue | null>,
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user