@minnowdb/core 0.9.1 → 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.
- package/dist/engine/auto-store.d.ts +40 -0
- package/dist/engine/auto-store.js +115 -0
- package/dist/engine/buffered-writer.d.ts +2 -0
- package/dist/engine/buffered-writer.js +15 -2
- package/dist/engine/client.d.ts +55 -6
- package/dist/engine/client.js +155 -40
- package/dist/engine/database.d.ts +15 -1
- package/dist/engine/database.js +1085 -227
- package/dist/engine/errors.d.ts +61 -2
- package/dist/engine/errors.js +116 -3
- package/dist/engine/index.d.ts +1 -0
- package/dist/engine/index.js +2 -0
- package/dist/engine/live.d.ts +24 -1
- package/dist/engine/live.js +33 -9
- package/dist/engine/scope-write-set.js +36 -0
- package/dist/engine/worker-auto.d.ts +1 -0
- package/dist/engine/worker-auto.js +3 -0
- package/dist/engine/worker-host.d.ts +2 -1
- package/dist/engine/worker-host.js +17 -1
- package/dist/engine/worker-server.d.ts +53 -1
- package/dist/engine/worker-server.js +118 -14
- package/dist/engine/worker-store-auto.js +36 -0
- package/dist/engine/worker-store-opfs.js +3 -2
- package/dist/engine/write-coordinator.js +24 -2
- package/dist/storage/indexeddb.js +494 -207
- package/dist/storage/opfs/leader.js +201 -14
- package/dist/storage/opfs/rpc.js +23 -43
- package/dist/storage/opfs/store.d.ts +20 -0
- package/dist/storage/opfs/store.js +479 -60
- package/dist/storage/toolkit/record-core.js +67 -38
- package/dist/storage/toolkit/wire.d.ts +1 -1
- package/dist/storage/toolkit/wire.js +1 -1
- package/dist/storage/types.d.ts +29 -8
- package/dist/storage/types.js +27 -16
- package/dist/testing/opfs-shim.js +14 -6
- package/dist/transactions/index.d.ts +12 -0
- package/dist/transactions/index.js +83 -23
- package/dist/worker-protocol/index.d.ts +50 -2
- package/dist/worker-protocol/index.js +106 -4
- 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) =>
|
|
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) =>
|
|
146
|
+
void this.#flushPending().catch((error) => {
|
|
147
|
+
this.#reportFlushError(error);
|
|
148
|
+
});
|
|
136
149
|
}, this.#maxAgeMs);
|
|
137
150
|
}
|
|
138
151
|
#clearTimer() {
|
package/dist/engine/client.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type LiveQueryPatch, type LiveQueryPatchOptions } from "./live-patch.js";
|
|
2
|
-
import { type CompactionJobRecord, type GarbageCollectionJobRecord, type
|
|
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
|
-
|
|
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
|
-
/**
|
|
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. */
|
package/dist/engine/client.js
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
import { createLiveQueryPatch } from "./live-patch.js";
|
|
2
|
-
import { BlockReadBatchTooLargeError, CompactionBacklogError, CompactionJobConflictError, GarbageCollectionJobConflictError, IndexedDbSchemaUpgradeBlockedError, LeaseConflictError, LeaseExpiredError, LeaseOwnerConflictError,
|
|
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,
|
|
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
|
-
|
|
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
|
-
#
|
|
118
|
-
|
|
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
|
-
|
|
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.#
|
|
127
|
-
|
|
128
|
-
transport
|
|
129
|
-
transport
|
|
130
|
-
|
|
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.#
|
|
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
|
-
|
|
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
|
|
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, {
|
|
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;
|