@minnowdb/core 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/dist/engine/auto-store.d.ts +40 -0
  2. package/dist/engine/auto-store.js +115 -0
  3. package/dist/engine/buffered-writer.d.ts +2 -0
  4. package/dist/engine/buffered-writer.js +15 -2
  5. package/dist/engine/client.d.ts +55 -6
  6. package/dist/engine/client.js +155 -40
  7. package/dist/engine/database.d.ts +15 -1
  8. package/dist/engine/database.js +1085 -227
  9. package/dist/engine/errors.d.ts +61 -2
  10. package/dist/engine/errors.js +116 -3
  11. package/dist/engine/index.d.ts +1 -0
  12. package/dist/engine/index.js +2 -0
  13. package/dist/engine/live.d.ts +24 -1
  14. package/dist/engine/live.js +33 -9
  15. package/dist/engine/scope-write-set.js +36 -0
  16. package/dist/engine/worker-auto.d.ts +1 -0
  17. package/dist/engine/worker-auto.js +3 -0
  18. package/dist/engine/worker-host.d.ts +2 -1
  19. package/dist/engine/worker-host.js +17 -1
  20. package/dist/engine/worker-server.d.ts +53 -1
  21. package/dist/engine/worker-server.js +118 -14
  22. package/dist/engine/worker-store-auto.js +36 -0
  23. package/dist/engine/worker-store-opfs.js +3 -2
  24. package/dist/engine/write-coordinator.js +24 -2
  25. package/dist/storage/indexeddb.js +494 -207
  26. package/dist/storage/opfs/leader.js +201 -14
  27. package/dist/storage/opfs/rpc.js +23 -43
  28. package/dist/storage/opfs/store.d.ts +20 -0
  29. package/dist/storage/opfs/store.js +501 -70
  30. package/dist/storage/toolkit/record-core.js +67 -38
  31. package/dist/storage/toolkit/wire.d.ts +1 -1
  32. package/dist/storage/toolkit/wire.js +1 -1
  33. package/dist/storage/types.d.ts +29 -8
  34. package/dist/storage/types.js +27 -16
  35. package/dist/testing/opfs-shim.js +14 -6
  36. package/dist/transactions/index.d.ts +12 -0
  37. package/dist/transactions/index.js +83 -23
  38. package/dist/worker-protocol/index.d.ts +50 -2
  39. package/dist/worker-protocol/index.js +106 -4
  40. package/package.json +7 -2
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The `{ kind: "auto" }` store: OPFS where this context can hold synchronous access handles,
3
+ * IndexedDB where it cannot (Safari's private browsing, a page context, an older build).
4
+ *
5
+ * The choice is made once per database name and remembered in a small IndexedDB record, so a
6
+ * database created on one store is never silently reopened, empty, on the other: a remembered
7
+ * store that cannot open now is reported as `DatabaseStoreUnavailableError` instead. Delete the
8
+ * record with `forgetStoreChoice` when the database itself is deleted.
9
+ */
10
+ export type AutoStoreKind = "opfs" | "indexeddb";
11
+ /** Test seams: the OPFS probe and the IndexedDB factory that keeps the choices. */
12
+ export declare const autoStoreTestHooks: {
13
+ opfsAvailable?: () => Promise<boolean>;
14
+ indexedDB?: IDBFactory;
15
+ };
16
+ /**
17
+ * Whether OPFS is usable here: the directory handle resolves, and file handles expose
18
+ * synchronous access — which only a dedicated worker has, and Safari's private browsing never.
19
+ */
20
+ export declare function opfsAvailable(): Promise<boolean>;
21
+ /**
22
+ * Resolves `auto` for one database name: the remembered store, or the best one available.
23
+ * A fresh choice is reserved with an insert-only write, so two workers deciding at the same
24
+ * instant in contexts that disagree still end up on the one store the record names; the
25
+ * caller reports through `settleAutoStoreChoice` whether that reservation produced a database.
26
+ */
27
+ export declare function resolveAutoStoreKind(name: string): Promise<{
28
+ kind: AutoStoreKind;
29
+ reserved: boolean;
30
+ }>;
31
+ /**
32
+ * Opens the store `auto` resolves to. A reservation that produced no database is released
33
+ * again, so a first open that failed does not pin the name to a store that never held data.
34
+ */
35
+ export declare function openAutoStore<Store>(name: string, open: (kind: AutoStoreKind) => Promise<Store>): Promise<{
36
+ store: Store;
37
+ kind: AutoStoreKind;
38
+ }>;
39
+ /** Forgets which store `auto` chose for a database; call it when deleting the database. */
40
+ export declare function forgetStoreChoice(name: string): Promise<void>;
@@ -0,0 +1,115 @@
1
+ import { DatabaseStoreUnavailableError } from "./errors.js";
2
+ const CHOICE_DATABASE = "minnowdb-store-choice";
3
+ const CHOICE_STORE = "choices";
4
+ const autoStoreTestHooks = {};
5
+ async function opfsAvailable() {
6
+ if (autoStoreTestHooks.opfsAvailable !== void 0)
7
+ return autoStoreTestHooks.opfsAvailable();
8
+ const storage = globalThis.navigator?.storage;
9
+ if (typeof storage?.getDirectory !== "function")
10
+ return false;
11
+ const fileHandle = globalThis.FileSystemFileHandle;
12
+ if (fileHandle?.prototype === void 0 || typeof fileHandle.prototype.createSyncAccessHandle !== "function") {
13
+ return false;
14
+ }
15
+ try {
16
+ await storage.getDirectory();
17
+ return true;
18
+ } catch {
19
+ return false;
20
+ }
21
+ }
22
+ async function resolveAutoStoreKind(name) {
23
+ const remembered = await readChoice(name);
24
+ if (remembered === "indexeddb")
25
+ return { kind: "indexeddb", reserved: false };
26
+ if (remembered === "opfs") {
27
+ if (await opfsAvailable())
28
+ return { kind: "opfs", reserved: false };
29
+ throw new DatabaseStoreUnavailableError("opfs", name, `Database "${name}" lives on the OPFS store, which this context cannot open; it is not reopened on IndexedDB, where it would be empty`);
30
+ }
31
+ const kind = await opfsAvailable() ? "opfs" : "indexeddb";
32
+ const reserved = await reserveChoice(name, kind);
33
+ if (reserved)
34
+ return { kind, reserved: true };
35
+ return resolveAutoStoreKind(name);
36
+ }
37
+ async function openAutoStore(name, open) {
38
+ const { kind, reserved } = await resolveAutoStoreKind(name);
39
+ try {
40
+ return { store: await open(kind), kind };
41
+ } catch (error) {
42
+ if (reserved)
43
+ await forgetStoreChoice(name).catch(() => void 0);
44
+ throw error;
45
+ }
46
+ }
47
+ async function forgetStoreChoice(name) {
48
+ const database = await openChoices();
49
+ if (database === void 0)
50
+ return;
51
+ try {
52
+ await complete(database.transaction(CHOICE_STORE, "readwrite").objectStore(CHOICE_STORE).delete(name));
53
+ } finally {
54
+ database.close();
55
+ }
56
+ }
57
+ function choiceFactory() {
58
+ return autoStoreTestHooks.indexedDB ?? globalThis.indexedDB ?? void 0;
59
+ }
60
+ async function openChoices() {
61
+ const factory = choiceFactory();
62
+ if (factory === void 0)
63
+ return void 0;
64
+ const request = factory.open(CHOICE_DATABASE, 1);
65
+ request.addEventListener("upgradeneeded", () => {
66
+ if (!request.result.objectStoreNames.contains(CHOICE_STORE)) {
67
+ request.result.createObjectStore(CHOICE_STORE);
68
+ }
69
+ });
70
+ try {
71
+ return await complete(request);
72
+ } catch {
73
+ return void 0;
74
+ }
75
+ }
76
+ async function readChoice(name) {
77
+ const database = await openChoices();
78
+ if (database === void 0)
79
+ return void 0;
80
+ try {
81
+ const value = await complete(database.transaction(CHOICE_STORE, "readonly").objectStore(CHOICE_STORE).get(name));
82
+ const kind = typeof value === "object" && value !== null ? value.kind : void 0;
83
+ return kind === "opfs" || kind === "indexeddb" ? kind : void 0;
84
+ } finally {
85
+ database.close();
86
+ }
87
+ }
88
+ async function reserveChoice(name, kind) {
89
+ const database = await openChoices();
90
+ if (database === void 0)
91
+ return true;
92
+ try {
93
+ await complete(database.transaction(CHOICE_STORE, "readwrite").objectStore(CHOICE_STORE).add({ kind }, name));
94
+ return true;
95
+ } catch (error) {
96
+ if (error instanceof Error && error.name === "ConstraintError")
97
+ return false;
98
+ throw error;
99
+ } finally {
100
+ database.close();
101
+ }
102
+ }
103
+ function complete(request) {
104
+ return new Promise((resolve, reject) => {
105
+ request.addEventListener("success", () => resolve(request.result));
106
+ request.addEventListener("error", () => reject(request.error ?? new Error("IndexedDB request failed")));
107
+ });
108
+ }
109
+ export {
110
+ autoStoreTestHooks,
111
+ forgetStoreChoice,
112
+ openAutoStore,
113
+ opfsAvailable,
114
+ resolveAutoStoreKind
115
+ };
@@ -6,6 +6,8 @@ export interface BufferedWriterOptions {
6
6
  maxBytes?: number;
7
7
  maxAgeMs?: number;
8
8
  onError?: (error: unknown) => void;
9
+ /** @internal The database's background-error hook, heard when no `onError` is given. */
10
+ onBackgroundError?: (error: unknown, context: string) => void;
9
11
  }
10
12
  export type BufferedFlushResult = InsertBatchResult | UpsertBatchResult;
11
13
  export interface LifecycleFlushRequester {
@@ -9,6 +9,7 @@ class BufferedTableWriter {
9
9
  #maxBytes;
10
10
  #maxAgeMs;
11
11
  #onError;
12
+ #onBackgroundError;
12
13
  #rows = [];
13
14
  #estimatedBytes = 0;
14
15
  #timer;
@@ -25,6 +26,7 @@ class BufferedTableWriter {
25
26
  this.#maxBytes = positiveWholeNumber(options.maxBytes ?? 1024 * 1024, "Buffered byte limit");
26
27
  this.#maxAgeMs = positiveWholeNumber(options.maxAgeMs ?? 1e3, "Buffered age limit");
27
28
  this.#onError = options.onError;
29
+ this.#onBackgroundError = options.onBackgroundError;
28
30
  }
29
31
  get pendingRowCount() {
30
32
  return this.#rows.length;
@@ -79,7 +81,16 @@ class BufferedTableWriter {
79
81
  requestFlush() {
80
82
  if (this.#closed)
81
83
  return;
82
- void this.#flushPending().catch((error) => this.#onError?.(error));
84
+ void this.#flushPending().catch((error) => {
85
+ this.#reportFlushError(error);
86
+ });
87
+ }
88
+ #reportFlushError(error) {
89
+ if (this.#onError !== void 0) {
90
+ this.#onError(error);
91
+ return;
92
+ }
93
+ this.#onBackgroundError?.(error, `buffered writer flush for ${this.tableName}`);
83
94
  }
84
95
  async close() {
85
96
  if (this.#closed)
@@ -132,7 +143,9 @@ class BufferedTableWriter {
132
143
  return;
133
144
  this.#timer = setTimeout(() => {
134
145
  this.#timer = void 0;
135
- void this.#flushPending().catch((error) => this.#onError?.(error));
146
+ void this.#flushPending().catch((error) => {
147
+ this.#reportFlushError(error);
148
+ });
136
149
  }, this.#maxAgeMs);
137
150
  }
138
151
  #clearTimer() {
@@ -1,5 +1,6 @@
1
1
  import { type LiveQueryPatch, type LiveQueryPatchOptions } from "./live-patch.js";
2
- import { type CompactionJobRecord, type GarbageCollectionJobRecord, type StorageIntegrityMode, type StorageIntegrityReport, type StorageStats, type InterruptedSnapshotImport, type InterruptedSnapshotImportAbortResult } from "../storage/types.js";
2
+ import { type CompactionJobRecord, ConnectionLostError, type GarbageCollectionJobRecord, type InterruptedSnapshotImport, type InterruptedSnapshotImportAbortResult, type StorageIntegrityMode, type StorageIntegrityReport, type StorageStats } from "../storage/types.js";
3
+ import { type WorkerErrorKind } from "../worker-protocol/index.js";
3
4
  import { type BatchRow } from "./batch.js";
4
5
  import type { Catalog } from "./catalog.js";
5
6
  import type { BufferPoolStats, StagedWriteResult, StagedUpsertResult, BufferedFlushResult, BufferedWriterOptions, CancelCompactionJobResult, CollectGarbageOptions, CollectGarbageStepOptions, CompactTableOptions, CompactTableResult, CompactTableStepOptions, CompactionJobProgress, CreateTableInput, MigrateOptions, DeleteBatchResult, ExecuteOptions, ExecuteResult, GarbageCollectionProgress, GarbageCollectionResult, MaintenanceStatus, InsertBatchResult, QueryOptions, QueryExecutionStats, QueryCursorOptions, QuerySpillCleanupOptions, QuerySpillCleanupResult, RunStatementOptions, SnapshotExportOptions, SnapshotImportOptions, TableDefinition, UpdateBatchResult, UpsertBatchResult, VisibleSegmentPage, VisibleSegmentPageOptions } from "./database.js";
@@ -7,7 +8,7 @@ import type { LiveQueryDelivery, LiveQueryInput, LiveQueryInvalidation, LiveQuer
7
8
  import type { CompiledQuery, CompiledStatement, QueryResult, QueryValue } from "./query.js";
8
9
  import type { AnySchema, UntypedSchema, AnyTable, BatchColumnName, BatchDeleteInput, BatchInsertInput, BatchInsertRow, BatchKeyValue, BatchReadOptions, BatchReadRow, BatchUpdateChanges, BatchUpdateInput, BatchUpsertOptions, SchemaDefinition, TableName } from "./schema.js";
9
10
  import { type WireMigrationStep } from "./schema-wire.js";
10
- import type { StoreDescriptor, WireDatabaseOptions } from "./worker-host.js";
11
+ import type { OpenedStoreKind, StoreDescriptor, WireDatabaseOptions } from "./worker-host.js";
11
12
  /**
12
13
  * Main-thread async proxy of the full database API, talking to a worker that runs
13
14
  * `@minnowdb/core/worker` (or a custom entry built on
@@ -27,9 +28,14 @@ export interface ClientTransport {
27
28
  transfer: ArrayBuffer[];
28
29
  }): void;
29
30
  addEventListener(type: "message", listener: (event: MessageEvent<unknown>) => void): void;
30
- addEventListener(type: "error" | "messageerror", listener: () => void): void;
31
+ /**
32
+ * The client's listeners accept the Worker's own `ErrorEvent` and `MessageEvent`, and read
33
+ * the message, file, and line off an error event when they are there. A transport that has
34
+ * no such events to give may invoke them with no argument.
35
+ */
36
+ addEventListener(type: "error" | "messageerror", listener: (event?: ErrorEvent | MessageEvent<unknown>) => void): void;
31
37
  removeEventListener?(type: "message", listener: (event: MessageEvent<unknown>) => void): void;
32
- removeEventListener?(type: "error" | "messageerror", listener: () => void): void;
38
+ removeEventListener?(type: "error" | "messageerror", listener: (event?: ErrorEvent | MessageEvent<unknown>) => void): void;
33
39
  terminate?(): void;
34
40
  }
35
41
  export interface MinnowDatabaseClientOptions<TSchema extends AnySchema = UntypedSchema> {
@@ -47,8 +53,35 @@ export interface MinnowDatabaseClientOptions<TSchema extends AnySchema = Untyped
47
53
  store?: StoreDescriptor;
48
54
  /** Cloneable database options applied when the worker constructs the database. */
49
55
  databaseOptions?: WireDatabaseOptions;
50
- /** Maximum response wait, including initialization; defaults to 60 seconds. */
56
+ /**
57
+ * How long a call may go without a word from the worker before the connection is declared
58
+ * dead; defaults to 60 seconds and also covers initialization. The worker reports every few
59
+ * seconds while it works on a call, so a large batch write is bounded by progress, not by wall
60
+ * time — up to ten times this value in all.
61
+ */
51
62
  requestTimeoutMs?: number;
63
+ /**
64
+ * Hears every failure inside the worker that belongs to no call: uncaught exceptions,
65
+ * unhandled rejections, failed background maintenance, and multi-tab coordination errors,
66
+ * plus the transport's own error events. Without a handler each one is written to
67
+ * `console.error`, so nothing that goes wrong in the worker stays in the worker.
68
+ */
69
+ onWorkerError?: (event: DatabaseWorkerErrorEvent) => void;
70
+ /**
71
+ * Called once when the connection is lost — the worker raised an unhandled error, sent an
72
+ * unreadable frame, or fell silent past `requestTimeoutMs`. Every later call on this client
73
+ * fails the same way until `reopen()`; use this hook to reopen and rebuild handles.
74
+ */
75
+ onConnectionLost?: (error: ConnectionLostError) => void;
76
+ }
77
+ /** A worker failure that belongs to no call, delivered to `onWorkerError`. */
78
+ export interface DatabaseWorkerErrorEvent {
79
+ /** `"transport"` is the Worker object's own error or messageerror event. */
80
+ kind: WorkerErrorKind | "transport";
81
+ /** Where in the worker it happened: "opfs election", "auto collection", a file and line. */
82
+ context: string;
83
+ /** Rehydrated like a call failure: `instanceof` works, the stack is the worker's. */
84
+ error: Error;
52
85
  }
53
86
  export interface ClientLiveQueryOptions {
54
87
  /** BroadcastChannel name the worker uses to exchange cross-tab commit hints. */
@@ -86,9 +119,23 @@ interface RpcCallControls {
86
119
  }
87
120
  export declare class MinnowDatabaseClient<TSchema extends AnySchema = UntypedSchema> {
88
121
  #private;
89
- constructor(transport: ClientTransport, options?: MinnowDatabaseClientOptions<TSchema>);
122
+ constructor(transport: ClientTransport | (() => ClientTransport), options?: MinnowDatabaseClientOptions<TSchema>);
123
+ /**
124
+ * Replaces the connection after it was lost: a fresh worker, the same store and options. Pass
125
+ * the new transport, or construct the client with a transport factory
126
+ * (`new MinnowDatabaseClient(() => new Worker(...))`) and call this with no argument. The old
127
+ * transport is terminated when it can be. Every handle from before — write scopes, live
128
+ * sets, cursors, writers — belongs to the old worker and must be recreated; a pending call
129
+ * still in flight fails as a connection loss. Resolves once the new worker is ready.
130
+ */
131
+ reopen(transport?: ClientTransport): Promise<void>;
90
132
  /** Resolves once the worker has opened the store and constructed the database. */
91
133
  ready(): Promise<void>;
134
+ /**
135
+ * The kind of store the worker opened: what `{ kind: "auto" }` resolved to, or the kind the
136
+ * descriptor named. Undefined only when a custom worker entry opened a store without saying.
137
+ */
138
+ storeKind(): Promise<OpenedStoreKind | undefined>;
92
139
  createTable(input: CreateTableInput): Promise<void>;
93
140
  createView(name: string, sql: string, options?: {
94
141
  orReplace?: boolean;
@@ -252,6 +299,8 @@ export declare class ClientBufferedWriter<TRow extends BatchRow = BatchRow> {
252
299
  private readonly client;
253
300
  private readonly handleId;
254
301
  constructor(client: MinnowDatabaseClient, handleId: string, created: Promise<unknown>);
302
+ /** @internal Runs once when the writer closes, however it closes. */
303
+ _onClose(cleanup: () => void): void;
255
304
  add(row: TRow): Promise<BufferedFlushResult | undefined>;
256
305
  flush(): Promise<BufferedFlushResult | undefined>;
257
306
  /** Fire-and-forget: flush failures surface through onError, matching the in-worker contract. */
@@ -1,9 +1,9 @@
1
1
  import { createLiveQueryPatch } from "./live-patch.js";
2
- import { BlockReadBatchTooLargeError, CompactionBacklogError, CompactionJobConflictError, GarbageCollectionJobConflictError, IndexedDbSchemaUpgradeBlockedError, LeaseConflictError, LeaseExpiredError, LeaseOwnerConflictError, PostingBuildConflictError, SnapshotManifestMissingError, SnapshotImportConflictError, SchemaConflictError, StorageResourceLimitError, TableInUseError, TableRecordConflictError, TempOwnerConflictError, TransactionRecordConflictError, UniqueKeyBuildConflictError, UniqueKeyConflictError, UniqueIndexCoverageError, WriteConflictError, StorageCorruptionError, StorageFormatVersionError, OpfsCoordinationError, OpfsDatabaseInUseError, OpfsUncertainOutcomeError } from "../storage/types.js";
2
+ import { BlockReadBatchTooLargeError, CompactionBacklogError, CompactionJobConflictError, ConnectionLostError, GarbageCollectionJobConflictError, IndexedDbSchemaUpgradeBlockedError, LeaseConflictError, LeaseExpiredError, LeaseOwnerConflictError, OpfsCoordinationError, OpfsDatabaseInUseError, OpfsUncertainOutcomeError, PostingBuildConflictError, SchemaConflictError, SnapshotImportConflictError, SnapshotManifestMissingError, StorageCorruptionError, StorageFormatVersionError, StorageResourceLimitError, TableInUseError, TableRecordConflictError, TempOwnerConflictError, TransactionRecordConflictError, UniqueIndexCoverageError, UniqueKeyBuildConflictError, UniqueKeyConflictError, UnknownOutcomeError, WriteConflictError } from "../storage/types.js";
3
3
  import { MAX_SNAPSHOT_STREAM_CHUNK_BYTES } from "../storage/snapshot.js";
4
- import { parseRpcResponse, MAX_DATABASE_RPC_IN_FLIGHT, protocolVersion } from "../worker-protocol/index.js";
4
+ import { MAX_DATABASE_RPC_IN_FLIGHT, WORKER_DIAGNOSTIC_HANDLE_ID, isWorkerErrorReport, parseRpcResponse, MIN_WORKER_KEEPALIVE_INTERVAL_MS, WORKER_KEEPALIVE_INTERVAL_MS, protocolVersion, rehydrateError as rehydrateSerializedError } from "../worker-protocol/index.js";
5
5
  import { definedVectors, toColumnarBatch } from "./batch.js";
6
- import { CompactionJobCancelledError, CompactionMemoryBudgetError, CompactionWriteAmplificationError, MaintenanceBacklogError, DatabaseReadBacklogError, TransactionExpiredError, DatabaseWorkerTimeoutError, DatabaseWorkerOutcomeUnknownError, LiveQueryLimitError, MissingKeyError, SqlCompileError, UnknownTableError, UniqueConstraintError, VisibleSegmentCursorStaleError } from "./errors.js";
6
+ import { CompactionJobCancelledError, CompactionMemoryBudgetError, CompactionWriteAmplificationError, MaintenanceBacklogError, DatabaseReadBacklogError, TransactionExpiredError, DatabaseWorkerTimeoutError, DatabaseWorkerFailedError, DatabaseStoreUnavailableError, DatabaseWorkerOutcomeUnknownError, LiveQueryLimitError, MissingKeyError, SqlCompileError, UnknownTableError, UniqueConstraintError, VisibleSegmentCursorStaleError } from "./errors.js";
7
7
  import { QueryMemoryBudgetError } from "./memory.js";
8
8
  import { decodeQueryResult } from "./result-wire.js";
9
9
  import { serializeSchema } from "./schema-wire.js";
@@ -23,6 +23,7 @@ function throwIfClientSnapshotAborted(signal) {
23
23
  error.name = "AbortError";
24
24
  throw error;
25
25
  }
26
+ const MAX_KEEPALIVE_EXTENSION = 10;
26
27
  const errorRegistry = new Map([
27
28
  UniqueConstraintError,
28
29
  MissingKeyError,
@@ -36,6 +37,8 @@ const errorRegistry = new Map([
36
37
  TransactionExpiredError,
37
38
  DatabaseWorkerTimeoutError,
38
39
  DatabaseWorkerOutcomeUnknownError,
40
+ DatabaseWorkerFailedError,
41
+ DatabaseStoreUnavailableError,
39
42
  LiveQueryLimitError,
40
43
  SqlCompileError,
41
44
  QueryMemoryBudgetError,
@@ -64,31 +67,12 @@ const errorRegistry = new Map([
64
67
  StorageFormatVersionError,
65
68
  OpfsCoordinationError,
66
69
  OpfsDatabaseInUseError,
67
- OpfsUncertainOutcomeError
70
+ OpfsUncertainOutcomeError,
71
+ UnknownOutcomeError,
72
+ ConnectionLostError
68
73
  ].map((constructor) => [constructor.name, constructor]));
69
74
  function rehydrateError(serialized) {
70
- const constructor = errorRegistry.get(serialized.name);
71
- const error = constructor === void 0 ? new Error(serialized.message) : Object.create(constructor.prototype);
72
- Object.defineProperty(error, "message", {
73
- value: serialized.message,
74
- writable: true,
75
- configurable: true
76
- });
77
- Object.defineProperty(error, "name", {
78
- value: serialized.name,
79
- writable: true,
80
- configurable: true
81
- });
82
- if (serialized.stack !== void 0) {
83
- Object.defineProperty(error, "stack", {
84
- value: serialized.stack,
85
- writable: true,
86
- configurable: true
87
- });
88
- }
89
- if (serialized.props !== void 0)
90
- Object.assign(error, serialized.props);
91
- return error;
75
+ return rehydrateSerializedError(serialized, errorRegistry);
92
76
  }
93
77
  function rehydrateResponseError(payload) {
94
78
  const candidate = payload;
@@ -103,36 +87,57 @@ class MinnowDatabaseClient {
103
87
  return this;
104
88
  }
105
89
  #transport;
90
+ #transportFactory;
91
+ #initPayload;
92
+ #onConnectionLost;
106
93
  #requestTimeoutMs;
107
94
  #closePromise;
108
95
  #pending = /* @__PURE__ */ new Map();
109
96
  #events = /* @__PURE__ */ new Map();
110
97
  #ready;
98
+ #storeKind;
111
99
  #fatal;
112
100
  #closed = false;
113
101
  #onVisibilityChange;
114
102
  #onMessage = (event) => {
115
103
  this.#receive(event.data);
116
104
  };
117
- #onError = () => {
118
- this.#fail(new Error("The database worker failed; see the worker's own error output"));
105
+ #onWorkerError;
106
+ #onError = (event) => {
107
+ const detail = event !== void 0 && "filename" in event ? event : void 0;
108
+ const where = detail?.filename === void 0 || detail.filename === "" ? "" : ` at ${detail.filename}:${String(detail.lineno)}:${String(detail.colno)}`;
109
+ const message = detail?.message === void 0 || detail.message === "" ? "" : `: ${detail.message}`;
110
+ const error = new DatabaseWorkerFailedError("error", `The database worker failed: it raised an error it did not handle${message}${where}`, detail?.error === void 0 ? void 0 : { cause: detail.error });
111
+ this.#reportWorkerError({ kind: "transport", context: "worker error event", error });
112
+ this.#fail(error);
119
113
  };
120
114
  #onMessageError = () => {
121
- this.#fail(new Error("A database worker message could not be deserialized"));
115
+ const error = new DatabaseWorkerFailedError("messageerror", "A database worker message could not be deserialized");
116
+ this.#reportWorkerError({ kind: "transport", context: "worker messageerror event", error });
117
+ this.#fail(error);
122
118
  };
119
+ #reportWorkerError(event) {
120
+ if (this.#onWorkerError !== void 0) {
121
+ this.#onWorkerError(event);
122
+ return;
123
+ }
124
+ if (typeof console === "undefined")
125
+ return;
126
+ console.error(`[minnowdb] worker ${event.kind} (${event.context}):`, event.error);
127
+ }
123
128
  constructor(transport, options = {}) {
124
129
  this.#requestTimeoutMs = clientDeadline(options.requestTimeoutMs ?? 6e4);
125
130
  this.#schema = options.schema;
126
- this.#transport = transport;
127
- transport.addEventListener("message", this.#onMessage);
128
- transport.addEventListener("error", this.#onError);
129
- transport.addEventListener("messageerror", this.#onMessageError);
130
- const payload = {
131
+ this.#onWorkerError = options.onWorkerError;
132
+ this.#onConnectionLost = options.onConnectionLost;
133
+ this.#transportFactory = typeof transport === "function" ? transport : void 0;
134
+ this.#transport = typeof transport === "function" ? transport() : transport;
135
+ this.#initPayload = {
131
136
  store: options.store ?? { kind: "indexeddb", name: "minnow" },
132
- ...options.databaseOptions === void 0 ? {} : { options: options.databaseOptions }
137
+ ...options.databaseOptions === void 0 ? {} : { options: options.databaseOptions },
138
+ keepaliveIntervalMs: keepalivePace(this.#requestTimeoutMs)
133
139
  };
134
- this.#ready = this.#post("rpc-init", null, "init", [payload]).then(() => void 0);
135
- this.#ready.catch(() => void 0);
140
+ this.#ready = this.#attach(this.#transport);
136
141
  if (typeof document !== "undefined" && typeof document.addEventListener === "function") {
137
142
  const report = () => {
138
143
  if (this.#closed)
@@ -146,9 +151,53 @@ class MinnowDatabaseClient {
146
151
  report();
147
152
  }
148
153
  }
154
+ #attach(transport) {
155
+ transport.addEventListener("message", this.#onMessage);
156
+ transport.addEventListener("error", this.#onError);
157
+ transport.addEventListener("messageerror", this.#onMessageError);
158
+ const ready = this.#post("rpc-init", null, "init", [this.#initPayload]).then((result) => {
159
+ const kind = result?.store;
160
+ this.#storeKind = kind === "indexeddb" || kind === "opfs" || kind === "memory" ? kind : void 0;
161
+ });
162
+ ready.catch(() => void 0);
163
+ return ready;
164
+ }
165
+ #detach(transport) {
166
+ transport.removeEventListener?.("message", this.#onMessage);
167
+ transport.removeEventListener?.("error", this.#onError);
168
+ transport.removeEventListener?.("messageerror", this.#onMessageError);
169
+ }
170
+ async reopen(transport) {
171
+ const next = transport ?? this.#transportFactory?.();
172
+ if (next === void 0) {
173
+ throw new TypeError("reopen() needs a transport: pass one, or construct the client with a transport factory");
174
+ }
175
+ const previous = this.#transport;
176
+ this.#detach(previous);
177
+ this.#transport = next;
178
+ if (this.#fatal === void 0) {
179
+ this.#fail(new DatabaseWorkerFailedError("reopened", "The database client was reopened"));
180
+ }
181
+ try {
182
+ previous.terminate?.();
183
+ } catch {
184
+ }
185
+ this.#fatal = void 0;
186
+ this.#closed = false;
187
+ this.#closePromise = void 0;
188
+ this.#events.clear();
189
+ this.#transport = next;
190
+ this.#ready = this.#attach(next);
191
+ this.#onVisibilityChange?.();
192
+ await this.#ready;
193
+ }
149
194
  async ready() {
150
195
  return this.#ready;
151
196
  }
197
+ async storeKind() {
198
+ await this.#ready;
199
+ return this.#storeKind;
200
+ }
152
201
  async createTable(input) {
153
202
  await this.#call("createTable", [input]);
154
203
  }
@@ -223,7 +272,24 @@ class MinnowDatabaseClient {
223
272
  ...onError === void 0 ? {} : { onError }
224
273
  });
225
274
  const created = this.#call("bufferedWriter", [handleId, tableName, wireOptions]);
226
- return new ClientBufferedWriter(this.#erased, handleId, created);
275
+ const writer = new ClientBufferedWriter(this.#erased, handleId, created);
276
+ if (typeof document !== "undefined" && typeof document.addEventListener === "function") {
277
+ const onHidden = () => {
278
+ if (document.visibilityState === "hidden")
279
+ writer.requestFlush();
280
+ };
281
+ const onPageHide = () => {
282
+ writer.requestFlush();
283
+ };
284
+ document.addEventListener("visibilitychange", onHidden);
285
+ const page = typeof window === "undefined" ? void 0 : window;
286
+ page?.addEventListener("pagehide", onPageHide);
287
+ writer._onClose(() => {
288
+ document.removeEventListener("visibilitychange", onHidden);
289
+ page?.removeEventListener("pagehide", onPageHide);
290
+ });
291
+ }
292
+ return writer;
227
293
  }
228
294
  async readTable(tableName, versionOrOptions) {
229
295
  return decodeQueryResult(await this.#call("readTable", versionOrOptions === void 0 ? [tableName] : [tableName, versionOrOptions])).rows;
@@ -552,8 +618,19 @@ class MinnowDatabaseClient {
552
618
  const timeoutMs = controls.timeoutMs ?? this.#requestTimeoutMs;
553
619
  const mayPublish = rpcMayPublish(method, args);
554
620
  return new Promise((resolve, reject) => {
555
- const timer = setTimeout(() => this.#fail(new DatabaseWorkerTimeoutError(method, timeoutMs)), timeoutMs);
621
+ const startedAt = Date.now();
622
+ const expire = () => this.#fail(new DatabaseWorkerTimeoutError(method, timeoutMs));
623
+ let timer = setTimeout(expire, timeoutMs);
556
624
  timer.unref?.();
625
+ const keepalive = () => {
626
+ const elapsed = Date.now() - startedAt;
627
+ const remaining = timeoutMs * MAX_KEEPALIVE_EXTENSION - elapsed;
628
+ if (remaining <= 0)
629
+ return;
630
+ clearTimeout(timer);
631
+ timer = setTimeout(expire, Math.min(timeoutMs, remaining));
632
+ timer.unref?.();
633
+ };
557
634
  const onAbort = () => {
558
635
  try {
559
636
  this.#transport.postMessage({ version: protocolVersion, requestId, kind: "rpc-cancel" });
@@ -580,7 +657,15 @@ class MinnowDatabaseClient {
580
657
  if (controls.onStats !== void 0) {
581
658
  this.#events.set(requestId, { onStats: controls.onStats });
582
659
  }
583
- this.#pending.set(requestId, { resolve, reject, cleanup, method, requestId, mayPublish });
660
+ this.#pending.set(requestId, {
661
+ resolve,
662
+ reject,
663
+ cleanup,
664
+ method,
665
+ requestId,
666
+ mayPublish,
667
+ keepalive
668
+ });
584
669
  try {
585
670
  this.#transport.postMessage(kind === "rpc-init" ? { version: protocolVersion, requestId, kind, payload: args[0] } : { version: protocolVersion, requestId, kind, handleId, method, args }, transfer === void 0 ? void 0 : { transfer });
586
671
  } catch (error) {
@@ -601,6 +686,20 @@ class MinnowDatabaseClient {
601
686
  if (response === null)
602
687
  return;
603
688
  if (response.kind === "rpc-event") {
689
+ if (response.handleId === WORKER_DIAGNOSTIC_HANDLE_ID) {
690
+ if (response.event === "keepalive" && typeof response.requestId === "string") {
691
+ this.#pending.get(response.requestId)?.keepalive?.();
692
+ return;
693
+ }
694
+ if (response.event === "error" && isWorkerErrorReport(response.payload)) {
695
+ this.#reportWorkerError({
696
+ kind: response.payload.kind,
697
+ context: response.payload.context,
698
+ error: rehydrateError(response.payload.error)
699
+ });
700
+ }
701
+ return;
702
+ }
604
703
  const route = this.#events.get(response.handleId);
605
704
  if (route === void 0)
606
705
  return;
@@ -665,6 +764,7 @@ class MinnowDatabaseClient {
665
764
  pending.reject(pending.mayPublish ? new DatabaseWorkerOutcomeUnknownError(pending.method, requestId, { cause: error }) : error);
666
765
  }
667
766
  #fail(error) {
767
+ const first = this.#fatal === void 0;
668
768
  this.#fatal = error;
669
769
  const pending = [...this.#pending.values()];
670
770
  this.#pending.clear();
@@ -673,8 +773,17 @@ class MinnowDatabaseClient {
673
773
  call.cleanup?.();
674
774
  call.reject(call.mayPublish ? new DatabaseWorkerOutcomeUnknownError(call.method, call.requestId, { cause: error }) : error);
675
775
  }
776
+ if (first && error instanceof ConnectionLostError && !(error instanceof DatabaseWorkerFailedError && error.reason === "reopened") && this.#onConnectionLost !== void 0) {
777
+ try {
778
+ this.#onConnectionLost(error);
779
+ } catch {
780
+ }
781
+ }
676
782
  }
677
783
  }
784
+ function keepalivePace(deadlineMs) {
785
+ return Math.min(WORKER_KEEPALIVE_INTERVAL_MS, Math.max(MIN_WORKER_KEEPALIVE_INTERVAL_MS, Math.floor(deadlineMs / 3)));
786
+ }
678
787
  function clientDeadline(value) {
679
788
  if (!Number.isSafeInteger(value) || value < 1 || value > 2147483647)
680
789
  throw new RangeError("Worker timeout must be a positive timer interval");
@@ -695,6 +804,10 @@ class ClientBufferedWriter {
695
804
  created.catch(() => void 0);
696
805
  }
697
806
  #created;
807
+ #onClose;
808
+ _onClose(cleanup) {
809
+ this.#onClose = cleanup;
810
+ }
698
811
  async add(row) {
699
812
  await this.#created;
700
813
  return await this.client._invoke(this.handleId, "add", [row]);
@@ -720,6 +833,8 @@ class ClientBufferedWriter {
720
833
  return await this.client._invoke(this.handleId, "close", []);
721
834
  } finally {
722
835
  this.client._unrouteEvents(this.handleId);
836
+ this.#onClose?.();
837
+ this.#onClose = void 0;
723
838
  }
724
839
  }
725
840
  }
@@ -4,7 +4,7 @@ export { attachLifecycleFlush, BufferedTableWriter, MAX_BUFFERED_WRITER_PENDING_
4
4
  import { CompactionJobCancelledError, CompactionMemoryBudgetError, CompactionWriteAmplificationError, MaintenanceBacklogError, MissingKeyError, SqlCompileError, UnknownTableError, UniqueConstraintError, VisibleSegmentCursorStaleError } from "./errors.js";
5
5
  export { DatabaseReadBacklogError } from "./errors.js";
6
6
  import { type Compression } from "../block-format/index.js";
7
- import { type BlockStore, type ColumnDefault, type ColumnGenerated, CompactionBacklogError, type CompactionJobRecord, type CompactionJobState, type GarbageCollectionJobRecord, type GarbageCollectionJobState, type SimpleDataType, type SqlDomain, type StorageIntegrityMode, type StorageIntegrityReport, type StorageStats, type InterruptedSnapshotImport, type InterruptedSnapshotImportAbortResult, TableInUseError } from "../storage/types.js";
7
+ import { type BlockStore, type ColumnDefault, type ColumnGenerated, CompactionBacklogError, type CompactionJobRecord, type CompactionJobState, type GarbageCollectionJobRecord, type GarbageCollectionJobState, type SimpleDataType, type SqlDomain, type StorageIntegrityMode, type StorageIntegrityReport, type StorageStats, type InterruptedSnapshotImport, type InterruptedSnapshotImportAbortResult, type TableColumnRecord, type TableRecord, TableInUseError } from "../storage/types.js";
8
8
  import type { SnapshotExportProgress, SnapshotLoadProgress } from "../storage/snapshot.js";
9
9
  import { type ComparisonOperator, type CompiledQuery, type CompiledStatement, type ForeignKeyDefinition, type QueryResult, type QueryRow, type QueryValue, type UniqueConstraintDefinition } from "./query.js";
10
10
  import { LiveQuerySet, type LiveQuerySetOptions } from "./live.js";
@@ -476,6 +476,13 @@ export interface MinnowDatabaseOptions<TSchema extends AnySchema = UntypedSchema
476
476
  coordinateWrites?: boolean;
477
477
  now?: () => Date;
478
478
  createId?: () => string;
479
+ /**
480
+ * Hears failures in work no caller awaits: a background collection pass that failed, a live
481
+ * sweep that failed with nobody subscribed. `maintenanceStatus().lastError` still records the
482
+ * last one; this hook sees every one, as it happens. Inside the worker host it feeds the
483
+ * client's `onWorkerError`.
484
+ */
485
+ onBackgroundError?: (error: unknown, context: string) => void;
479
486
  /** Durable spill-owner lease lifetime; renewed while a spilling query runs. */
480
487
  spillOwnerLeaseMs?: number;
481
488
  /** Durable active-writer deadline; renewed every third while the writer is live. */
@@ -657,6 +664,13 @@ export interface RunStatementOptions {
657
664
  */
658
665
  export interface StatementWriter extends WriteSession {
659
666
  queryPlan(plan: CompiledQuery): Promise<QueryResult>;
667
+ /**
668
+ * Which of the given keys the scope sees as present — staged by it and not removed, or
669
+ * committed — as key tokens, answered from the scope's key ledger and a keyed probe so a plain
670
+ * INSERT or an `ON CONFLICT DO NOTHING` never has to stage its buffered predecessors just to
671
+ * find a duplicate. Undefined when the writer cannot answer without a read.
672
+ */
673
+ stagedKeyPresence?(table: TableRecord, keyColumn: TableColumnRecord, keys: ReadonlyArray<Exclude<BatchValue, null>>): Promise<ReadonlySet<string> | undefined>;
660
674
  }
661
675
  export interface VisibleSegment {
662
676
  id: string;