@korajs/store 1.0.0-beta.12 → 1.0.0-beta.14
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/README.md +29 -37
- package/dist/adapters/better-sqlite3.cjs +90 -4
- package/dist/adapters/better-sqlite3.cjs.map +1 -1
- package/dist/adapters/better-sqlite3.d.cts +21 -12
- package/dist/adapters/better-sqlite3.d.ts +21 -12
- package/dist/adapters/better-sqlite3.js +69 -5
- package/dist/adapters/better-sqlite3.js.map +1 -1
- package/dist/adapters/indexeddb.cjs +1316 -610
- package/dist/adapters/indexeddb.cjs.map +1 -1
- package/dist/adapters/indexeddb.d.cts +29 -3
- package/dist/adapters/indexeddb.d.ts +29 -3
- package/dist/adapters/indexeddb.js +247 -221
- package/dist/adapters/indexeddb.js.map +1 -1
- package/dist/adapters/sqlite-wasm-worker.cjs +856 -63
- package/dist/adapters/sqlite-wasm-worker.cjs.map +1 -1
- package/dist/adapters/sqlite-wasm-worker.js +529 -63
- package/dist/adapters/sqlite-wasm-worker.js.map +1 -1
- package/dist/adapters/sqlite-wasm.cjs +1032 -344
- package/dist/adapters/sqlite-wasm.cjs.map +1 -1
- package/dist/adapters/sqlite-wasm.d.cts +239 -18
- package/dist/adapters/sqlite-wasm.d.ts +239 -18
- package/dist/adapters/sqlite-wasm.js +111 -5
- package/dist/adapters/sqlite-wasm.js.map +1 -1
- package/dist/backup-IHKVMUAM.js +25 -0
- package/dist/chunk-5AQSGMJV.js +5471 -0
- package/dist/chunk-5AQSGMJV.js.map +1 -0
- package/dist/chunk-5JMGMPUE.js +591 -0
- package/dist/chunk-5JMGMPUE.js.map +1 -0
- package/dist/{chunk-CYO3YKFL.js → chunk-5ZRRF6R2.js} +2 -2
- package/dist/chunk-A6GFH5IG.js +36 -0
- package/dist/chunk-A6GFH5IG.js.map +1 -0
- package/dist/chunk-BILSA6XZ.js +608 -0
- package/dist/chunk-BILSA6XZ.js.map +1 -0
- package/dist/chunk-BQSIL6TT.js +1095 -0
- package/dist/chunk-BQSIL6TT.js.map +1 -0
- package/dist/{chunk-GSJ7EEUC.js → chunk-J5AGXF3D.js} +58 -9
- package/dist/chunk-J5AGXF3D.js.map +1 -0
- package/dist/chunk-KK3GBONL.js +112 -0
- package/dist/chunk-KK3GBONL.js.map +1 -0
- package/dist/chunk-KTVNANUH.js +876 -0
- package/dist/chunk-KTVNANUH.js.map +1 -0
- package/dist/chunk-QW4UU7NG.js +1003 -0
- package/dist/chunk-QW4UU7NG.js.map +1 -0
- package/dist/chunk-SRHDJF5B.js +186 -0
- package/dist/chunk-SRHDJF5B.js.map +1 -0
- package/dist/chunk-UC2LGFPV.js +810 -0
- package/dist/chunk-UC2LGFPV.js.map +1 -0
- package/dist/{export-audit-ODGQTQIK.js → export-audit-J2QXRHD2.js} +3 -3
- package/dist/index.cjs +9533 -4246
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1143 -110
- package/dist/index.d.ts +1143 -110
- package/dist/index.js +298 -3191
- package/dist/index.js.map +1 -1
- package/dist/internal.cjs +581 -264
- package/dist/internal.cjs.map +1 -1
- package/dist/internal.d.cts +15 -85
- package/dist/internal.d.ts +15 -85
- package/dist/internal.js +10 -8
- package/dist/restore-MNDPR557.js +299 -0
- package/dist/restore-MNDPR557.js.map +1 -0
- package/dist/sql-builder-D0tC3sv9.d.cts +615 -0
- package/dist/sql-builder-D5UMzHEo.d.ts +615 -0
- package/dist/sqlite-wasm-channel-DVX93Uiq.d.cts +141 -0
- package/dist/sqlite-wasm-channel-DVX93Uiq.d.ts +141 -0
- package/dist/sqlite-wasm-channel-QCCNBYCU.js +12 -0
- package/dist/store-HNGX4KTT.js +16 -0
- package/dist/store-HNGX4KTT.js.map +1 -0
- package/dist/{types-BslDOTk0.d.ts → types-Dh-xyUjP.d.cts} +99 -26
- package/dist/{types-BslDOTk0.d.cts → types-Dh-xyUjP.d.ts} +99 -26
- package/package.json +3 -2
- package/dist/backup-5S7K2Z35.js +0 -14
- package/dist/chunk-GSJ7EEUC.js.map +0 -1
- package/dist/chunk-HS6DBMST.js +0 -383
- package/dist/chunk-HS6DBMST.js.map +0 -1
- package/dist/chunk-RJIEKEN4.js +0 -751
- package/dist/chunk-RJIEKEN4.js.map +0 -1
- package/dist/chunk-TUR7RGEP.js +0 -84
- package/dist/chunk-TUR7RGEP.js.map +0 -1
- package/dist/chunk-VDBJQ75K.js +0 -78
- package/dist/chunk-VDBJQ75K.js.map +0 -1
- package/dist/chunk-YT7MRFCP.js +0 -687
- package/dist/chunk-YT7MRFCP.js.map +0 -1
- package/dist/sqlite-wasm-channel-GGHA2BXU.js +0 -12
- package/dist/sqlite-wasm-channel-L6o3bWQK.d.cts +0 -92
- package/dist/sqlite-wasm-channel-L6o3bWQK.d.ts +0 -92
- package/dist/types-BOO9EPt1.d.ts +0 -170
- package/dist/types-C_GDavG-.d.cts +0 -170
- /package/dist/{backup-5S7K2Z35.js.map → backup-IHKVMUAM.js.map} +0 -0
- /package/dist/{chunk-CYO3YKFL.js.map → chunk-5ZRRF6R2.js.map} +0 -0
- /package/dist/{export-audit-ODGQTQIK.js.map → export-audit-J2QXRHD2.js.map} +0 -0
- /package/dist/{sqlite-wasm-channel-GGHA2BXU.js.map → sqlite-wasm-channel-QCCNBYCU.js.map} +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { KoraEventEmitter, SchemaDefinition } from '@korajs/core';
|
|
2
|
-
import { S as StorageAdapter, T as Transaction, M as MigrationPlan, a as StorageOpenState } from '../types-
|
|
3
|
-
import { W as WorkerBridge } from '../sqlite-wasm-channel-
|
|
2
|
+
import { S as StorageAdapter, T as Transaction, M as MigrationPlan, a as StorageOpenState } from '../types-Dh-xyUjP.cjs';
|
|
3
|
+
import { W as WorkerBridge } from '../sqlite-wasm-channel-DVX93Uiq.cjs';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Options for creating an IndexedDbAdapter.
|
|
@@ -50,8 +50,18 @@ declare class IndexedDbAdapter implements StorageAdapter {
|
|
|
50
50
|
private readonly emitter;
|
|
51
51
|
private readonly scheduler;
|
|
52
52
|
private storageOpenState;
|
|
53
|
+
private readonly options;
|
|
54
|
+
/** True in browsers (a real worker), where the backend choice is recorded. */
|
|
55
|
+
private readonly usesRealWorker;
|
|
53
56
|
constructor(options?: IndexedDbAdapterOptions);
|
|
54
57
|
open(schema: SchemaDefinition): Promise<void>;
|
|
58
|
+
/**
|
|
59
|
+
* Read the OPFS copy of this database through a short-lived SQLite WASM
|
|
60
|
+
* adapter (as leader, or as a follower of a tab that has it open). Refuses
|
|
61
|
+
* loudly when OPFS cannot be read, instead of starting an empty copy.
|
|
62
|
+
*/
|
|
63
|
+
private readOpfsCopy;
|
|
64
|
+
private applyDump;
|
|
55
65
|
close(): Promise<void>;
|
|
56
66
|
execute(sql: string, params?: unknown[]): Promise<void>;
|
|
57
67
|
query<T>(sql: string, params?: unknown[]): Promise<T[]>;
|
|
@@ -63,9 +73,25 @@ declare class IndexedDbAdapter implements StorageAdapter {
|
|
|
63
73
|
* Useful before tab unload or in tests.
|
|
64
74
|
*/
|
|
65
75
|
flushPersistence(): Promise<void>;
|
|
76
|
+
/**
|
|
77
|
+
* Durability barrier (RT-35): resolves once every write committed before the call is
|
|
78
|
+
* in a snapshot persisted to IndexedDB, and rejects when the snapshot cannot be
|
|
79
|
+
* written. Sync calls it before an operation leaves the device, so the server never
|
|
80
|
+
* holds an operation this device could lose on reload.
|
|
81
|
+
*/
|
|
82
|
+
ensureDurable(): Promise<void>;
|
|
66
83
|
private writeSnapshot;
|
|
67
84
|
private handlePersistenceError;
|
|
68
|
-
|
|
85
|
+
/**
|
|
86
|
+
* Restore the persisted snapshot into the worker database unless one was restored
|
|
87
|
+
* into it already (this adapter or another one sharing the worker). The binary
|
|
88
|
+
* snapshot (Node / test workers that support export) is imported whole; the JSON dump
|
|
89
|
+
* (browsers) is restored in one transaction.
|
|
90
|
+
*/
|
|
91
|
+
private restoreIfFresh;
|
|
92
|
+
/** In `tx`: restore the JSON dump when no snapshot was restored into this database. */
|
|
93
|
+
private restoreDumpIfFresh;
|
|
94
|
+
/** A consistent snapshot: every table is read inside one transaction (STORE-7). */
|
|
69
95
|
private exportDump;
|
|
70
96
|
}
|
|
71
97
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { KoraEventEmitter, SchemaDefinition } from '@korajs/core';
|
|
2
|
-
import { S as StorageAdapter, T as Transaction, M as MigrationPlan, a as StorageOpenState } from '../types-
|
|
3
|
-
import { W as WorkerBridge } from '../sqlite-wasm-channel-
|
|
2
|
+
import { S as StorageAdapter, T as Transaction, M as MigrationPlan, a as StorageOpenState } from '../types-Dh-xyUjP.js';
|
|
3
|
+
import { W as WorkerBridge } from '../sqlite-wasm-channel-DVX93Uiq.js';
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Options for creating an IndexedDbAdapter.
|
|
@@ -50,8 +50,18 @@ declare class IndexedDbAdapter implements StorageAdapter {
|
|
|
50
50
|
private readonly emitter;
|
|
51
51
|
private readonly scheduler;
|
|
52
52
|
private storageOpenState;
|
|
53
|
+
private readonly options;
|
|
54
|
+
/** True in browsers (a real worker), where the backend choice is recorded. */
|
|
55
|
+
private readonly usesRealWorker;
|
|
53
56
|
constructor(options?: IndexedDbAdapterOptions);
|
|
54
57
|
open(schema: SchemaDefinition): Promise<void>;
|
|
58
|
+
/**
|
|
59
|
+
* Read the OPFS copy of this database through a short-lived SQLite WASM
|
|
60
|
+
* adapter (as leader, or as a follower of a tab that has it open). Refuses
|
|
61
|
+
* loudly when OPFS cannot be read, instead of starting an empty copy.
|
|
62
|
+
*/
|
|
63
|
+
private readOpfsCopy;
|
|
64
|
+
private applyDump;
|
|
55
65
|
close(): Promise<void>;
|
|
56
66
|
execute(sql: string, params?: unknown[]): Promise<void>;
|
|
57
67
|
query<T>(sql: string, params?: unknown[]): Promise<T[]>;
|
|
@@ -63,9 +73,25 @@ declare class IndexedDbAdapter implements StorageAdapter {
|
|
|
63
73
|
* Useful before tab unload or in tests.
|
|
64
74
|
*/
|
|
65
75
|
flushPersistence(): Promise<void>;
|
|
76
|
+
/**
|
|
77
|
+
* Durability barrier (RT-35): resolves once every write committed before the call is
|
|
78
|
+
* in a snapshot persisted to IndexedDB, and rejects when the snapshot cannot be
|
|
79
|
+
* written. Sync calls it before an operation leaves the device, so the server never
|
|
80
|
+
* holds an operation this device could lose on reload.
|
|
81
|
+
*/
|
|
82
|
+
ensureDurable(): Promise<void>;
|
|
66
83
|
private writeSnapshot;
|
|
67
84
|
private handlePersistenceError;
|
|
68
|
-
|
|
85
|
+
/**
|
|
86
|
+
* Restore the persisted snapshot into the worker database unless one was restored
|
|
87
|
+
* into it already (this adapter or another one sharing the worker). The binary
|
|
88
|
+
* snapshot (Node / test workers that support export) is imported whole; the JSON dump
|
|
89
|
+
* (browsers) is restored in one transaction.
|
|
90
|
+
*/
|
|
91
|
+
private restoreIfFresh;
|
|
92
|
+
/** In `tx`: restore the JSON dump when no snapshot was restored into this database. */
|
|
93
|
+
private restoreDumpIfFresh;
|
|
94
|
+
/** A consistent snapshot: every table is read inside one transaction (STORE-7). */
|
|
69
95
|
private exportDump;
|
|
70
96
|
}
|
|
71
97
|
|
|
@@ -1,15 +1,30 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
2
|
+
STORED_SCHEMA_VERSION_SQL,
|
|
3
|
+
storedSchemaVersion
|
|
4
|
+
} from "../chunk-A6GFH5IG.js";
|
|
5
|
+
import {
|
|
6
|
+
SqliteWasmAdapter,
|
|
7
|
+
deleteSnapshotFromIndexedDB,
|
|
8
|
+
exportDump,
|
|
9
|
+
isIndexedDbQuotaError,
|
|
10
|
+
isManifestAvailable,
|
|
11
|
+
loadDumpFromIndexedDB,
|
|
12
|
+
loadFromIndexedDB,
|
|
13
|
+
readManifestRecord,
|
|
14
|
+
recordDatabase,
|
|
15
|
+
restoreDumpStatements,
|
|
16
|
+
saveDumpToIndexedDB,
|
|
17
|
+
saveToIndexedDB
|
|
18
|
+
} from "../chunk-BQSIL6TT.js";
|
|
19
|
+
import "../chunk-KK3GBONL.js";
|
|
5
20
|
import "../chunk-TVJEC5S2.js";
|
|
21
|
+
import "../chunk-5JMGMPUE.js";
|
|
6
22
|
import {
|
|
7
23
|
AdapterError,
|
|
8
|
-
PersistenceError
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
import { quoteIdent } from "@korajs/core";
|
|
24
|
+
PersistenceError,
|
|
25
|
+
SchemaVersionAheadError,
|
|
26
|
+
StorageBackendMismatchError
|
|
27
|
+
} from "../chunk-SRHDJF5B.js";
|
|
13
28
|
|
|
14
29
|
// src/adapters/indexeddb-persistence-scheduler.ts
|
|
15
30
|
var DEFAULT_DEBOUNCE_MS = 500;
|
|
@@ -20,6 +35,15 @@ var IndexedDbPersistenceScheduler = class {
|
|
|
20
35
|
timer = null;
|
|
21
36
|
inFlight = null;
|
|
22
37
|
disposed = false;
|
|
38
|
+
/**
|
|
39
|
+
* Mutation generations (RT-35): `scheduled` counts schedule() calls (one per committed
|
|
40
|
+
* write), `persisted` is the generation the last SUCCESSFUL snapshot covered. A
|
|
41
|
+
* snapshot covers every generation scheduled before it started.
|
|
42
|
+
*/
|
|
43
|
+
scheduledGeneration = 0;
|
|
44
|
+
persistedGeneration = 0;
|
|
45
|
+
/** Error of the last snapshot attempt, cleared by the next success. */
|
|
46
|
+
lastFlushError = null;
|
|
23
47
|
onVisibilityChange;
|
|
24
48
|
constructor(options) {
|
|
25
49
|
this.debounceMs = options.debounceMs ?? DEFAULT_DEBOUNCE_MS;
|
|
@@ -37,6 +61,7 @@ var IndexedDbPersistenceScheduler = class {
|
|
|
37
61
|
/** Schedule a debounced snapshot write. */
|
|
38
62
|
schedule() {
|
|
39
63
|
if (this.disposed) return;
|
|
64
|
+
this.scheduledGeneration++;
|
|
40
65
|
if (this.debounceMs <= 0) {
|
|
41
66
|
void this.flushNow();
|
|
42
67
|
return;
|
|
@@ -49,22 +74,75 @@ var IndexedDbPersistenceScheduler = class {
|
|
|
49
74
|
void this.flushNow();
|
|
50
75
|
}, this.debounceMs);
|
|
51
76
|
}
|
|
52
|
-
/**
|
|
77
|
+
/**
|
|
78
|
+
* Cancel any pending debounce and persist immediately. Resolves only when a snapshot
|
|
79
|
+
* taken after every write scheduled so far was written (STORE-7): a snapshot already in
|
|
80
|
+
* flight may predate the latest writes, so the call waits for it and then writes again
|
|
81
|
+
* while anything is still dirty. A failed snapshot ends the loop (the failure is
|
|
82
|
+
* reported through `onError`; {@link flushBarrier} is the variant that rejects).
|
|
83
|
+
*/
|
|
53
84
|
async flushNow() {
|
|
54
85
|
if (this.disposed) return;
|
|
55
86
|
if (this.timer !== null) {
|
|
56
87
|
clearTimeout(this.timer);
|
|
57
88
|
this.timer = null;
|
|
58
89
|
}
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
90
|
+
let mustRun = this.inFlight === null;
|
|
91
|
+
for (; ; ) {
|
|
92
|
+
if (this.inFlight) {
|
|
93
|
+
await this.inFlight;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (!mustRun && this.persistedGeneration >= this.scheduledGeneration) return;
|
|
97
|
+
mustRun = false;
|
|
98
|
+
this.inFlight = this.runFlush();
|
|
99
|
+
try {
|
|
100
|
+
await this.inFlight;
|
|
101
|
+
} finally {
|
|
102
|
+
this.inFlight = null;
|
|
103
|
+
}
|
|
104
|
+
if (this.lastFlushError !== null) return;
|
|
62
105
|
}
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
106
|
+
}
|
|
107
|
+
/** Whether a write was scheduled that no successful snapshot covers yet. */
|
|
108
|
+
isDirty() {
|
|
109
|
+
return this.persistedGeneration < this.scheduledGeneration;
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Durability barrier (RT-35): resolve once every write scheduled before this call is
|
|
113
|
+
* in a snapshot persisted to IndexedDB. A snapshot already in flight may predate the
|
|
114
|
+
* latest write, so the barrier waits for it and then writes a fresh one when needed.
|
|
115
|
+
* Rejects when the snapshot cannot be written: the caller must treat the writes as
|
|
116
|
+
* not durable (for example, not upload them yet).
|
|
117
|
+
*/
|
|
118
|
+
async flushBarrier() {
|
|
119
|
+
const target = this.scheduledGeneration;
|
|
120
|
+
if (this.persistedGeneration >= target) return;
|
|
121
|
+
if (this.disposed) {
|
|
122
|
+
throw new Error("IndexedDB persistence was disposed before the writes were persisted");
|
|
123
|
+
}
|
|
124
|
+
if (this.timer !== null) {
|
|
125
|
+
clearTimeout(this.timer);
|
|
126
|
+
this.timer = null;
|
|
127
|
+
}
|
|
128
|
+
for (let round = 0; round < 3 && this.persistedGeneration < target; round++) {
|
|
129
|
+
if (this.inFlight) {
|
|
130
|
+
try {
|
|
131
|
+
await this.inFlight;
|
|
132
|
+
} catch {
|
|
133
|
+
}
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
this.inFlight = this.runFlush();
|
|
137
|
+
try {
|
|
138
|
+
await this.inFlight;
|
|
139
|
+
} finally {
|
|
140
|
+
this.inFlight = null;
|
|
141
|
+
}
|
|
142
|
+
if (this.persistedGeneration < target) break;
|
|
143
|
+
}
|
|
144
|
+
if (this.persistedGeneration < target) {
|
|
145
|
+
throw this.lastFlushError instanceof Error ? this.lastFlushError : new Error("IndexedDB snapshot was not persisted");
|
|
68
146
|
}
|
|
69
147
|
}
|
|
70
148
|
dispose() {
|
|
@@ -78,165 +156,18 @@ var IndexedDbPersistenceScheduler = class {
|
|
|
78
156
|
}
|
|
79
157
|
}
|
|
80
158
|
async runFlush() {
|
|
159
|
+
const generation = this.scheduledGeneration;
|
|
81
160
|
try {
|
|
82
161
|
await this.flush();
|
|
162
|
+
this.persistedGeneration = Math.max(this.persistedGeneration, generation);
|
|
163
|
+
this.lastFlushError = null;
|
|
83
164
|
} catch (error) {
|
|
165
|
+
this.lastFlushError = error;
|
|
84
166
|
this.onError?.(error);
|
|
85
167
|
}
|
|
86
168
|
}
|
|
87
169
|
};
|
|
88
170
|
|
|
89
|
-
// src/adapters/sqlite-wasm-persistence.ts
|
|
90
|
-
var IDB_DATABASE_NAME = "kora-persistence";
|
|
91
|
-
var IDB_STORE_NAME = "databases";
|
|
92
|
-
var IDB_VERSION = 1;
|
|
93
|
-
var DUMP_SUFFIX = "::dump";
|
|
94
|
-
function isIndexedDbQuotaError(error) {
|
|
95
|
-
if (error instanceof DOMException && error.name === "QuotaExceededError") {
|
|
96
|
-
return true;
|
|
97
|
-
}
|
|
98
|
-
if (error instanceof PersistenceError && error.context?.quotaExceeded === true) {
|
|
99
|
-
return true;
|
|
100
|
-
}
|
|
101
|
-
if (error instanceof Error && /quota/i.test(error.message)) {
|
|
102
|
-
return true;
|
|
103
|
-
}
|
|
104
|
-
return false;
|
|
105
|
-
}
|
|
106
|
-
function rejectWithIdbError(reject, message, context, event) {
|
|
107
|
-
const domError = event.target?.error;
|
|
108
|
-
if (domError && isIndexedDbQuotaError(domError)) {
|
|
109
|
-
reject(
|
|
110
|
-
new PersistenceError(message, {
|
|
111
|
-
...context,
|
|
112
|
-
quotaExceeded: true,
|
|
113
|
-
cause: domError.message
|
|
114
|
-
})
|
|
115
|
-
);
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
reject(new PersistenceError(message, context));
|
|
119
|
-
}
|
|
120
|
-
function openIdb() {
|
|
121
|
-
return new Promise((resolve, reject) => {
|
|
122
|
-
const request = indexedDB.open(IDB_DATABASE_NAME, IDB_VERSION);
|
|
123
|
-
request.onupgradeneeded = () => {
|
|
124
|
-
const db = request.result;
|
|
125
|
-
if (!db.objectStoreNames.contains(IDB_STORE_NAME)) {
|
|
126
|
-
db.createObjectStore(IDB_STORE_NAME);
|
|
127
|
-
}
|
|
128
|
-
};
|
|
129
|
-
request.onsuccess = () => resolve(request.result);
|
|
130
|
-
request.onerror = () => reject(
|
|
131
|
-
new PersistenceError(
|
|
132
|
-
`Failed to open IndexedDB: ${request.error?.message ?? "unknown error"}`,
|
|
133
|
-
{ database: IDB_DATABASE_NAME }
|
|
134
|
-
)
|
|
135
|
-
);
|
|
136
|
-
});
|
|
137
|
-
}
|
|
138
|
-
async function saveToIndexedDB(dbName, data) {
|
|
139
|
-
const idb = await openIdb();
|
|
140
|
-
try {
|
|
141
|
-
await new Promise((resolve, reject) => {
|
|
142
|
-
const tx = idb.transaction(IDB_STORE_NAME, "readwrite");
|
|
143
|
-
const store = tx.objectStore(IDB_STORE_NAME);
|
|
144
|
-
store.put(data, dbName);
|
|
145
|
-
tx.oncomplete = () => resolve();
|
|
146
|
-
tx.onerror = (event) => rejectWithIdbError(
|
|
147
|
-
reject,
|
|
148
|
-
`Failed to save database "${dbName}" to IndexedDB`,
|
|
149
|
-
{ dbName },
|
|
150
|
-
event
|
|
151
|
-
);
|
|
152
|
-
});
|
|
153
|
-
} finally {
|
|
154
|
-
idb.close();
|
|
155
|
-
}
|
|
156
|
-
}
|
|
157
|
-
async function saveDumpToIndexedDB(dbName, dump) {
|
|
158
|
-
const idb = await openIdb();
|
|
159
|
-
try {
|
|
160
|
-
await new Promise((resolve, reject) => {
|
|
161
|
-
const tx = idb.transaction(IDB_STORE_NAME, "readwrite");
|
|
162
|
-
const store = tx.objectStore(IDB_STORE_NAME);
|
|
163
|
-
store.put(dump, `${dbName}${DUMP_SUFFIX}`);
|
|
164
|
-
tx.oncomplete = () => resolve();
|
|
165
|
-
tx.onerror = (event) => rejectWithIdbError(
|
|
166
|
-
reject,
|
|
167
|
-
`Failed to save dump for database "${dbName}"`,
|
|
168
|
-
{ dbName },
|
|
169
|
-
event
|
|
170
|
-
);
|
|
171
|
-
});
|
|
172
|
-
} finally {
|
|
173
|
-
idb.close();
|
|
174
|
-
}
|
|
175
|
-
}
|
|
176
|
-
async function loadFromIndexedDB(dbName) {
|
|
177
|
-
const idb = await openIdb();
|
|
178
|
-
try {
|
|
179
|
-
return await new Promise((resolve, reject) => {
|
|
180
|
-
const tx = idb.transaction(IDB_STORE_NAME, "readonly");
|
|
181
|
-
const store = tx.objectStore(IDB_STORE_NAME);
|
|
182
|
-
const request = store.get(dbName);
|
|
183
|
-
request.onsuccess = () => {
|
|
184
|
-
const result = request.result;
|
|
185
|
-
if (result instanceof Uint8Array) {
|
|
186
|
-
resolve(result);
|
|
187
|
-
} else if (result) {
|
|
188
|
-
resolve(new Uint8Array(result));
|
|
189
|
-
} else {
|
|
190
|
-
resolve(null);
|
|
191
|
-
}
|
|
192
|
-
};
|
|
193
|
-
request.onerror = () => reject(
|
|
194
|
-
new PersistenceError(`Failed to load database "${dbName}" from IndexedDB`, { dbName })
|
|
195
|
-
);
|
|
196
|
-
});
|
|
197
|
-
} finally {
|
|
198
|
-
idb.close();
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
async function loadDumpFromIndexedDB(dbName) {
|
|
202
|
-
const idb = await openIdb();
|
|
203
|
-
try {
|
|
204
|
-
return await new Promise((resolve, reject) => {
|
|
205
|
-
const tx = idb.transaction(IDB_STORE_NAME, "readonly");
|
|
206
|
-
const store = tx.objectStore(IDB_STORE_NAME);
|
|
207
|
-
const request = store.get(`${dbName}${DUMP_SUFFIX}`);
|
|
208
|
-
request.onsuccess = () => {
|
|
209
|
-
resolve(request.result ?? null);
|
|
210
|
-
};
|
|
211
|
-
request.onerror = () => reject(
|
|
212
|
-
new PersistenceError(`Failed to load dump for database "${dbName}" from IndexedDB`, {
|
|
213
|
-
dbName
|
|
214
|
-
})
|
|
215
|
-
);
|
|
216
|
-
});
|
|
217
|
-
} finally {
|
|
218
|
-
idb.close();
|
|
219
|
-
}
|
|
220
|
-
}
|
|
221
|
-
async function deleteSnapshotFromIndexedDB(dbName) {
|
|
222
|
-
const idb = await openIdb();
|
|
223
|
-
try {
|
|
224
|
-
await new Promise((resolve, reject) => {
|
|
225
|
-
const tx = idb.transaction(IDB_STORE_NAME, "readwrite");
|
|
226
|
-
const store = tx.objectStore(IDB_STORE_NAME);
|
|
227
|
-
store.delete(dbName);
|
|
228
|
-
tx.oncomplete = () => resolve();
|
|
229
|
-
tx.onerror = () => reject(
|
|
230
|
-
new PersistenceError(`Failed to delete database snapshot "${dbName}" from IndexedDB`, {
|
|
231
|
-
dbName
|
|
232
|
-
})
|
|
233
|
-
);
|
|
234
|
-
});
|
|
235
|
-
} finally {
|
|
236
|
-
idb.close();
|
|
237
|
-
}
|
|
238
|
-
}
|
|
239
|
-
|
|
240
171
|
// src/adapters/indexeddb-adapter.ts
|
|
241
172
|
var IndexedDbAdapter = class {
|
|
242
173
|
inner;
|
|
@@ -244,14 +175,29 @@ var IndexedDbAdapter = class {
|
|
|
244
175
|
emitter;
|
|
245
176
|
scheduler;
|
|
246
177
|
storageOpenState = null;
|
|
178
|
+
options;
|
|
179
|
+
/** True in browsers (a real worker), where the backend choice is recorded. */
|
|
180
|
+
usesRealWorker;
|
|
247
181
|
constructor(options = {}) {
|
|
182
|
+
this.options = options;
|
|
248
183
|
this.dbName = options.dbName ?? "kora-db";
|
|
249
184
|
this.emitter = options.emitter;
|
|
185
|
+
this.usesRealWorker = !options.bridge && options.workerUrl !== void 0;
|
|
250
186
|
this.inner = new SqliteWasmAdapter({
|
|
251
187
|
bridge: options.bridge,
|
|
252
188
|
workerUrl: options.workerUrl,
|
|
253
189
|
dbName: this.dbName,
|
|
254
|
-
workerResponseTimeoutMs: options.workerResponseTimeoutMs
|
|
190
|
+
workerResponseTimeoutMs: options.workerResponseTimeoutMs,
|
|
191
|
+
// The inner SQLite database is in memory by design: this adapter makes it
|
|
192
|
+
// durable by persisting snapshots to IndexedDB, so the inner adapter must
|
|
193
|
+
// not refuse writes as non-durable, and must never touch OPFS (which would
|
|
194
|
+
// create a second, disjoint copy of the data).
|
|
195
|
+
allowNonDurable: true,
|
|
196
|
+
storage: "memory",
|
|
197
|
+
skipBackendRecord: true,
|
|
198
|
+
// A browser leader restores the snapshot into its own fresh worker database
|
|
199
|
+
// before serving other tabs (first open and promotion, STORE-6).
|
|
200
|
+
onLeaderWorkerOpened: (tx) => this.restoreDumpIfFresh(tx)
|
|
255
201
|
});
|
|
256
202
|
this.scheduler = new IndexedDbPersistenceScheduler({
|
|
257
203
|
debounceMs: options.persistenceDebounceMs,
|
|
@@ -260,19 +206,91 @@ var IndexedDbAdapter = class {
|
|
|
260
206
|
});
|
|
261
207
|
}
|
|
262
208
|
async open(schema) {
|
|
209
|
+
const tracksBackend = this.usesRealWorker && isManifestAvailable();
|
|
210
|
+
const recorded = tracksBackend ? await readManifestRecord(this.dbName).catch(() => null) : null;
|
|
211
|
+
const carried = recorded?.backend === "opfs" ? await this.readOpfsCopy(schema) : null;
|
|
263
212
|
await this.inner.open(schema);
|
|
264
213
|
this.storageOpenState = { persistent: true, mode: "indexeddb" };
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
214
|
+
if (carried && this.inner.isLeader()) {
|
|
215
|
+
await this.inner.transaction(async (tx) => {
|
|
216
|
+
await markRestored(tx);
|
|
217
|
+
});
|
|
218
|
+
await this.applyDump(carried);
|
|
219
|
+
await this.scheduler.flushNow();
|
|
220
|
+
await recordDatabase({ dbName: this.dbName, backend: "indexeddb" });
|
|
221
|
+
this.emitter?.emit({
|
|
222
|
+
type: "store:storage-migrated",
|
|
223
|
+
dbName: this.dbName,
|
|
224
|
+
from: "opfs",
|
|
225
|
+
to: "indexeddb",
|
|
226
|
+
message: `Database "${this.dbName}" moved from OPFS into IndexedDB because OPFS is not usable in this session.`
|
|
227
|
+
});
|
|
268
228
|
return;
|
|
269
229
|
}
|
|
230
|
+
if (this.inner.isLeader()) {
|
|
231
|
+
await this.restoreIfFresh();
|
|
232
|
+
}
|
|
233
|
+
const stored = storedSchemaVersion(
|
|
234
|
+
await this.inner.query(STORED_SCHEMA_VERSION_SQL)
|
|
235
|
+
);
|
|
236
|
+
if (stored > schema.version) {
|
|
237
|
+
this.scheduler.dispose();
|
|
238
|
+
await this.inner.close().catch(() => void 0);
|
|
239
|
+
throw new SchemaVersionAheadError(this.dbName, stored, schema.version);
|
|
240
|
+
}
|
|
241
|
+
if (tracksBackend) {
|
|
242
|
+
await recordDatabase({ dbName: this.dbName, backend: "indexeddb" }).catch(
|
|
243
|
+
(error) => console.warn(`[kora] Could not record the storage backend of "${this.dbName}":`, error)
|
|
244
|
+
);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Read the OPFS copy of this database through a short-lived SQLite WASM
|
|
249
|
+
* adapter (as leader, or as a follower of a tab that has it open). Refuses
|
|
250
|
+
* loudly when OPFS cannot be read, instead of starting an empty copy.
|
|
251
|
+
*/
|
|
252
|
+
async readOpfsCopy(schema) {
|
|
253
|
+
const reader = new SqliteWasmAdapter({
|
|
254
|
+
dbName: this.dbName,
|
|
255
|
+
workerUrl: this.options.workerUrl,
|
|
256
|
+
workerResponseTimeoutMs: this.options.workerResponseTimeoutMs,
|
|
257
|
+
skipBackendRecord: true,
|
|
258
|
+
releaseOnFreeze: false
|
|
259
|
+
});
|
|
270
260
|
try {
|
|
271
|
-
await
|
|
272
|
-
|
|
273
|
-
|
|
261
|
+
await reader.open(schema);
|
|
262
|
+
if (reader.getStorageOpenState()?.persistent !== true) {
|
|
263
|
+
throw new StorageBackendMismatchError(
|
|
264
|
+
this.dbName,
|
|
265
|
+
"OPFS",
|
|
266
|
+
"IndexedDB",
|
|
267
|
+
`OPFS reported ${reader.getStorageOpenState()?.fallbackReason ?? "no durable storage"}`
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
let dump = { tables: [] };
|
|
271
|
+
await reader.transaction(async (tx) => {
|
|
272
|
+
dump = await exportDump((sql, params) => tx.query(sql, params));
|
|
273
|
+
});
|
|
274
|
+
return dump;
|
|
275
|
+
} catch (error) {
|
|
276
|
+
if (error instanceof StorageBackendMismatchError) throw error;
|
|
277
|
+
throw new StorageBackendMismatchError(
|
|
278
|
+
this.dbName,
|
|
279
|
+
"OPFS",
|
|
280
|
+
"IndexedDB",
|
|
281
|
+
error instanceof Error ? error.message : String(error)
|
|
282
|
+
);
|
|
283
|
+
} finally {
|
|
284
|
+
await reader.close().catch(() => void 0);
|
|
274
285
|
}
|
|
275
286
|
}
|
|
287
|
+
async applyDump(dump) {
|
|
288
|
+
await this.inner.transaction(async (tx) => {
|
|
289
|
+
for (const statement of restoreDumpStatements(dump)) {
|
|
290
|
+
await tx.execute(statement.sql, statement.params);
|
|
291
|
+
}
|
|
292
|
+
});
|
|
293
|
+
}
|
|
276
294
|
async close() {
|
|
277
295
|
await this.scheduler.flushNow();
|
|
278
296
|
this.scheduler.dispose();
|
|
@@ -303,6 +321,15 @@ var IndexedDbAdapter = class {
|
|
|
303
321
|
async flushPersistence() {
|
|
304
322
|
await this.scheduler.flushNow();
|
|
305
323
|
}
|
|
324
|
+
/**
|
|
325
|
+
* Durability barrier (RT-35): resolves once every write committed before the call is
|
|
326
|
+
* in a snapshot persisted to IndexedDB, and rejects when the snapshot cannot be
|
|
327
|
+
* written. Sync calls it before an operation leaves the device, so the server never
|
|
328
|
+
* holds an operation this device could lose on reload.
|
|
329
|
+
*/
|
|
330
|
+
async ensureDurable() {
|
|
331
|
+
await this.scheduler.flushBarrier();
|
|
332
|
+
}
|
|
306
333
|
async writeSnapshot() {
|
|
307
334
|
const dump = await this.exportDump();
|
|
308
335
|
await saveDumpToIndexedDB(this.dbName, dump);
|
|
@@ -334,59 +361,58 @@ var IndexedDbAdapter = class {
|
|
|
334
361
|
code: quotaExceeded ? "QUOTA_EXCEEDED" : code
|
|
335
362
|
});
|
|
336
363
|
}
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
await this.inner.
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
364
|
+
/**
|
|
365
|
+
* Restore the persisted snapshot into the worker database unless one was restored
|
|
366
|
+
* into it already (this adapter or another one sharing the worker). The binary
|
|
367
|
+
* snapshot (Node / test workers that support export) is imported whole; the JSON dump
|
|
368
|
+
* (browsers) is restored in one transaction.
|
|
369
|
+
*/
|
|
370
|
+
async restoreIfFresh() {
|
|
371
|
+
const persisted = await loadFromIndexedDB(this.dbName);
|
|
372
|
+
if (persisted) {
|
|
373
|
+
const fresh = { value: false };
|
|
374
|
+
await this.inner.transaction(async (tx) => {
|
|
375
|
+
fresh.value = !await isRestored(tx);
|
|
376
|
+
});
|
|
377
|
+
if (!fresh.value) return;
|
|
378
|
+
try {
|
|
379
|
+
await this.inner.importDatabase(persisted);
|
|
380
|
+
await this.inner.transaction(markRestored);
|
|
381
|
+
return;
|
|
382
|
+
} catch {
|
|
356
383
|
}
|
|
357
384
|
}
|
|
385
|
+
await this.inner.transaction((tx) => this.restoreDumpIfFresh(tx));
|
|
358
386
|
}
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
);
|
|
363
|
-
const
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
`PRAGMA table_info(${quoteIdent(tableName)})`
|
|
368
|
-
);
|
|
369
|
-
const columnNames = columns.map((column) => column.name);
|
|
370
|
-
const rows = await this.inner.query(
|
|
371
|
-
`SELECT * FROM ${quoteIdent(tableName)}`
|
|
372
|
-
);
|
|
373
|
-
tables.push({
|
|
374
|
-
name: tableName,
|
|
375
|
-
columns: columnNames,
|
|
376
|
-
rows
|
|
377
|
-
});
|
|
387
|
+
/** In `tx`: restore the JSON dump when no snapshot was restored into this database. */
|
|
388
|
+
async restoreDumpIfFresh(tx) {
|
|
389
|
+
if (await isRestored(tx)) return;
|
|
390
|
+
await markRestored(tx);
|
|
391
|
+
const dump = await loadDumpFromIndexedDB(this.dbName);
|
|
392
|
+
if (!dump) return;
|
|
393
|
+
for (const statement of restoreDumpStatements(dump)) {
|
|
394
|
+
await tx.execute(statement.sql, statement.params);
|
|
378
395
|
}
|
|
379
|
-
return { tables };
|
|
380
396
|
}
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
identifier
|
|
397
|
+
/** A consistent snapshot: every table is read inside one transaction (STORE-7). */
|
|
398
|
+
async exportDump() {
|
|
399
|
+
let dump = { tables: [] };
|
|
400
|
+
await this.inner.transaction(async (tx) => {
|
|
401
|
+
dump = await exportDump((sql, params) => tx.query(sql, params));
|
|
387
402
|
});
|
|
403
|
+
return dump;
|
|
388
404
|
}
|
|
389
|
-
|
|
405
|
+
};
|
|
406
|
+
var RESTORED_MARKER = "_kora_idb_restored";
|
|
407
|
+
async function isRestored(tx) {
|
|
408
|
+
const rows = await tx.query(
|
|
409
|
+
"SELECT name FROM sqlite_temp_master WHERE type = 'table' AND name = ?",
|
|
410
|
+
[RESTORED_MARKER]
|
|
411
|
+
);
|
|
412
|
+
return rows.length > 0;
|
|
413
|
+
}
|
|
414
|
+
async function markRestored(tx) {
|
|
415
|
+
await tx.execute(`CREATE TEMP TABLE IF NOT EXISTS ${RESTORED_MARKER} (restored INTEGER)`);
|
|
390
416
|
}
|
|
391
417
|
function isUnsupportedWorkerExport(error) {
|
|
392
418
|
return error instanceof AdapterError && error.context?.code === "EXPORT_NOT_SUPPORTED";
|