@korajs/store 1.0.0-beta.11 → 1.0.0-beta.13
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 +803 -62
- package/dist/adapters/sqlite-wasm-worker.cjs.map +1 -1
- package/dist/adapters/sqlite-wasm-worker.js +480 -62
- 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-4NUPDEMB.js +25 -0
- package/dist/chunk-32EDKWMI.js +876 -0
- package/dist/chunk-32EDKWMI.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-DUKTWVZW.js +175 -0
- package/dist/chunk-DUKTWVZW.js.map +1 -0
- package/dist/chunk-HZTDBLDB.js +607 -0
- package/dist/chunk-HZTDBLDB.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-MCQKEHOD.js +112 -0
- package/dist/chunk-MCQKEHOD.js.map +1 -0
- package/dist/chunk-P4V375FY.js +810 -0
- package/dist/chunk-P4V375FY.js.map +1 -0
- package/dist/chunk-QDUSJMFV.js +1095 -0
- package/dist/chunk-QDUSJMFV.js.map +1 -0
- package/dist/chunk-SJP2HTYZ.js +1003 -0
- package/dist/chunk-SJP2HTYZ.js.map +1 -0
- package/dist/chunk-W5F2LWG6.js +591 -0
- package/dist/chunk-W5F2LWG6.js.map +1 -0
- package/dist/chunk-Y5LRH7KI.js +5341 -0
- package/dist/chunk-Y5LRH7KI.js.map +1 -0
- package/dist/{export-audit-ODGQTQIK.js → export-audit-J2QXRHD2.js} +3 -3
- package/dist/index.cjs +9354 -4157
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +1064 -108
- package/dist/index.d.ts +1064 -108
- package/dist/index.js +249 -3092
- package/dist/index.js.map +1 -1
- package/dist/internal.cjs +580 -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-FR7XIKT2.js +299 -0
- package/dist/restore-FR7XIKT2.js.map +1 -0
- package/dist/sql-builder-C4u55jKW.d.cts +608 -0
- package/dist/sql-builder-CGckdBDL.d.ts +608 -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-MLHLKEED.js +12 -0
- package/dist/store-IMWKZLWB.js +16 -0
- package/dist/store-IMWKZLWB.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-4NUPDEMB.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-MLHLKEED.js.map} +0 -0
|
@@ -0,0 +1,1095 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Mutex
|
|
3
|
+
} from "./chunk-TVJEC5S2.js";
|
|
4
|
+
import {
|
|
5
|
+
FollowerBroadcastBridge,
|
|
6
|
+
acquireTabStorageSession
|
|
7
|
+
} from "./chunk-W5F2LWG6.js";
|
|
8
|
+
import {
|
|
9
|
+
AdapterError,
|
|
10
|
+
BridgeTerminatedError,
|
|
11
|
+
PersistenceError,
|
|
12
|
+
StorageDurabilityError,
|
|
13
|
+
StoreNotOpenError
|
|
14
|
+
} from "./chunk-DUKTWVZW.js";
|
|
15
|
+
|
|
16
|
+
// src/adapters/sqlite-wasm-adapter.ts
|
|
17
|
+
import { generateFullDDL } from "@korajs/core";
|
|
18
|
+
|
|
19
|
+
// src/adapters/database-dump.ts
|
|
20
|
+
import { quoteIdent } from "@korajs/core";
|
|
21
|
+
function ensureSafeIdentifier(identifier) {
|
|
22
|
+
if (!/^[a-zA-Z0-9_]+$/.test(identifier)) {
|
|
23
|
+
throw new PersistenceError(`Unsafe SQL identifier: ${identifier}`, {
|
|
24
|
+
code: "UNSAFE_IDENTIFIER",
|
|
25
|
+
identifier
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
return identifier;
|
|
29
|
+
}
|
|
30
|
+
async function exportDump(query) {
|
|
31
|
+
const tableRows = await query(
|
|
32
|
+
"SELECT name, sql FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'"
|
|
33
|
+
);
|
|
34
|
+
const tables = [];
|
|
35
|
+
for (const tableRow of tableRows) {
|
|
36
|
+
const tableName = ensureSafeIdentifier(tableRow.name);
|
|
37
|
+
const columns = await query(`PRAGMA table_info(${quoteIdent(tableName)})`);
|
|
38
|
+
const rows = await query(`SELECT * FROM ${quoteIdent(tableName)}`);
|
|
39
|
+
tables.push({
|
|
40
|
+
name: tableName,
|
|
41
|
+
...typeof tableRow.sql === "string" ? { sql: tableRow.sql } : {},
|
|
42
|
+
columns: columns.map((column) => column.name),
|
|
43
|
+
rows
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
return { tables };
|
|
47
|
+
}
|
|
48
|
+
function restoreDumpStatements(dump, _createMissingTables = true) {
|
|
49
|
+
const statements = [];
|
|
50
|
+
for (const table of dump.tables) {
|
|
51
|
+
const name = quoteIdent(ensureSafeIdentifier(table.name));
|
|
52
|
+
const create = createTableIfNotExists(table.name, table.sql);
|
|
53
|
+
if (create) {
|
|
54
|
+
statements.push({ sql: create });
|
|
55
|
+
} else {
|
|
56
|
+
const columns = table.columns.map((column) => quoteIdent(ensureSafeIdentifier(column)));
|
|
57
|
+
if (columns.length === 0) continue;
|
|
58
|
+
statements.push({ sql: `CREATE TABLE IF NOT EXISTS ${name} (${columns.join(", ")})` });
|
|
59
|
+
}
|
|
60
|
+
statements.push({ sql: `DELETE FROM ${name}` });
|
|
61
|
+
for (const row of table.rows) {
|
|
62
|
+
const columns = table.columns.filter(
|
|
63
|
+
(column) => Object.prototype.hasOwnProperty.call(row, column)
|
|
64
|
+
);
|
|
65
|
+
if (columns.length === 0) continue;
|
|
66
|
+
const quotedColumns = columns.map((column) => quoteIdent(ensureSafeIdentifier(column))).join(", ");
|
|
67
|
+
statements.push({
|
|
68
|
+
sql: `INSERT INTO ${name} (${quotedColumns}) VALUES (${columns.map(() => "?").join(", ")})`,
|
|
69
|
+
params: columns.map((column) => row[column])
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return statements;
|
|
74
|
+
}
|
|
75
|
+
function createTableIfNotExists(tableName, sql) {
|
|
76
|
+
if (typeof sql !== "string" || sql.includes(";")) return null;
|
|
77
|
+
const match = /^\s*CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?("?)([A-Za-z0-9_]+)\1\s*\(/i.exec(
|
|
78
|
+
sql
|
|
79
|
+
);
|
|
80
|
+
if (!match || match[2] !== tableName) return null;
|
|
81
|
+
return `CREATE TABLE IF NOT EXISTS ${quoteIdent(tableName)} ${sql.slice(match[0].length - 1)}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// src/adapters/sqlite-wasm-persistence.ts
|
|
85
|
+
var IDB_DATABASE_NAME = "kora-persistence";
|
|
86
|
+
var IDB_STORE_NAME = "databases";
|
|
87
|
+
var IDB_VERSION = 1;
|
|
88
|
+
var DUMP_SUFFIX = "::dump";
|
|
89
|
+
function isIndexedDbQuotaError(error) {
|
|
90
|
+
if (error instanceof DOMException && error.name === "QuotaExceededError") {
|
|
91
|
+
return true;
|
|
92
|
+
}
|
|
93
|
+
if (error instanceof PersistenceError && error.context?.quotaExceeded === true) {
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
if (error instanceof Error && /quota/i.test(error.message)) {
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
function rejectWithIdbError(reject, message, context, event) {
|
|
102
|
+
const domError = event.target?.error;
|
|
103
|
+
if (domError && isIndexedDbQuotaError(domError)) {
|
|
104
|
+
reject(
|
|
105
|
+
new PersistenceError(message, {
|
|
106
|
+
...context,
|
|
107
|
+
quotaExceeded: true,
|
|
108
|
+
cause: domError.message
|
|
109
|
+
})
|
|
110
|
+
);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
reject(new PersistenceError(message, context));
|
|
114
|
+
}
|
|
115
|
+
function openIdb() {
|
|
116
|
+
return new Promise((resolve, reject) => {
|
|
117
|
+
const request = indexedDB.open(IDB_DATABASE_NAME, IDB_VERSION);
|
|
118
|
+
request.onupgradeneeded = () => {
|
|
119
|
+
const db = request.result;
|
|
120
|
+
if (!db.objectStoreNames.contains(IDB_STORE_NAME)) {
|
|
121
|
+
db.createObjectStore(IDB_STORE_NAME);
|
|
122
|
+
}
|
|
123
|
+
};
|
|
124
|
+
request.onsuccess = () => resolve(request.result);
|
|
125
|
+
request.onerror = () => reject(
|
|
126
|
+
new PersistenceError(
|
|
127
|
+
`Failed to open IndexedDB: ${request.error?.message ?? "unknown error"}`,
|
|
128
|
+
{ database: IDB_DATABASE_NAME }
|
|
129
|
+
)
|
|
130
|
+
);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
async function saveToIndexedDB(dbName, data) {
|
|
134
|
+
const idb = await openIdb();
|
|
135
|
+
try {
|
|
136
|
+
await new Promise((resolve, reject) => {
|
|
137
|
+
const tx = idb.transaction(IDB_STORE_NAME, "readwrite");
|
|
138
|
+
const store = tx.objectStore(IDB_STORE_NAME);
|
|
139
|
+
store.put(data, dbName);
|
|
140
|
+
tx.oncomplete = () => resolve();
|
|
141
|
+
tx.onerror = (event) => rejectWithIdbError(
|
|
142
|
+
reject,
|
|
143
|
+
`Failed to save database "${dbName}" to IndexedDB`,
|
|
144
|
+
{ dbName },
|
|
145
|
+
event
|
|
146
|
+
);
|
|
147
|
+
});
|
|
148
|
+
} finally {
|
|
149
|
+
idb.close();
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
async function saveDumpToIndexedDB(dbName, dump) {
|
|
153
|
+
const idb = await openIdb();
|
|
154
|
+
try {
|
|
155
|
+
await new Promise((resolve, reject) => {
|
|
156
|
+
const tx = idb.transaction(IDB_STORE_NAME, "readwrite");
|
|
157
|
+
const store = tx.objectStore(IDB_STORE_NAME);
|
|
158
|
+
store.put(dump, `${dbName}${DUMP_SUFFIX}`);
|
|
159
|
+
tx.oncomplete = () => resolve();
|
|
160
|
+
tx.onerror = (event) => rejectWithIdbError(
|
|
161
|
+
reject,
|
|
162
|
+
`Failed to save dump for database "${dbName}"`,
|
|
163
|
+
{ dbName },
|
|
164
|
+
event
|
|
165
|
+
);
|
|
166
|
+
});
|
|
167
|
+
} finally {
|
|
168
|
+
idb.close();
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
async function loadFromIndexedDB(dbName) {
|
|
172
|
+
const idb = await openIdb();
|
|
173
|
+
try {
|
|
174
|
+
return await new Promise((resolve, reject) => {
|
|
175
|
+
const tx = idb.transaction(IDB_STORE_NAME, "readonly");
|
|
176
|
+
const store = tx.objectStore(IDB_STORE_NAME);
|
|
177
|
+
const request = store.get(dbName);
|
|
178
|
+
request.onsuccess = () => {
|
|
179
|
+
const result = request.result;
|
|
180
|
+
if (result instanceof Uint8Array) {
|
|
181
|
+
resolve(result);
|
|
182
|
+
} else if (result) {
|
|
183
|
+
resolve(new Uint8Array(result));
|
|
184
|
+
} else {
|
|
185
|
+
resolve(null);
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
request.onerror = () => reject(
|
|
189
|
+
new PersistenceError(`Failed to load database "${dbName}" from IndexedDB`, { dbName })
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
} finally {
|
|
193
|
+
idb.close();
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
async function loadDumpFromIndexedDB(dbName) {
|
|
197
|
+
const idb = await openIdb();
|
|
198
|
+
try {
|
|
199
|
+
return await new Promise((resolve, reject) => {
|
|
200
|
+
const tx = idb.transaction(IDB_STORE_NAME, "readonly");
|
|
201
|
+
const store = tx.objectStore(IDB_STORE_NAME);
|
|
202
|
+
const request = store.get(`${dbName}${DUMP_SUFFIX}`);
|
|
203
|
+
request.onsuccess = () => {
|
|
204
|
+
resolve(request.result ?? null);
|
|
205
|
+
};
|
|
206
|
+
request.onerror = () => reject(
|
|
207
|
+
new PersistenceError(`Failed to load dump for database "${dbName}" from IndexedDB`, {
|
|
208
|
+
dbName
|
|
209
|
+
})
|
|
210
|
+
);
|
|
211
|
+
});
|
|
212
|
+
} finally {
|
|
213
|
+
idb.close();
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
async function deleteSnapshotFromIndexedDB(dbName) {
|
|
217
|
+
const idb = await openIdb();
|
|
218
|
+
try {
|
|
219
|
+
await new Promise((resolve, reject) => {
|
|
220
|
+
const tx = idb.transaction(IDB_STORE_NAME, "readwrite");
|
|
221
|
+
const store = tx.objectStore(IDB_STORE_NAME);
|
|
222
|
+
store.delete(dbName);
|
|
223
|
+
tx.oncomplete = () => resolve();
|
|
224
|
+
tx.onerror = () => reject(
|
|
225
|
+
new PersistenceError(`Failed to delete database snapshot "${dbName}" from IndexedDB`, {
|
|
226
|
+
dbName
|
|
227
|
+
})
|
|
228
|
+
);
|
|
229
|
+
});
|
|
230
|
+
} finally {
|
|
231
|
+
idb.close();
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
async function deleteFromIndexedDB(dbName) {
|
|
235
|
+
const idb = await openIdb();
|
|
236
|
+
try {
|
|
237
|
+
await new Promise((resolve, reject) => {
|
|
238
|
+
const tx = idb.transaction(IDB_STORE_NAME, "readwrite");
|
|
239
|
+
const store = tx.objectStore(IDB_STORE_NAME);
|
|
240
|
+
store.delete(dbName);
|
|
241
|
+
store.delete(`${dbName}${DUMP_SUFFIX}`);
|
|
242
|
+
tx.oncomplete = () => resolve();
|
|
243
|
+
tx.onerror = () => reject(
|
|
244
|
+
new PersistenceError(`Failed to delete database "${dbName}" from IndexedDB`, { dbName })
|
|
245
|
+
);
|
|
246
|
+
});
|
|
247
|
+
} finally {
|
|
248
|
+
idb.close();
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// src/adapters/storage-manifest.ts
|
|
253
|
+
var MANIFEST_DB_NAME = "kora-manifest";
|
|
254
|
+
var MANIFEST_STORE = "databases";
|
|
255
|
+
var MANIFEST_VERSION = 1;
|
|
256
|
+
function isManifestAvailable() {
|
|
257
|
+
return typeof indexedDB !== "undefined";
|
|
258
|
+
}
|
|
259
|
+
function openManifest() {
|
|
260
|
+
return new Promise((resolve, reject) => {
|
|
261
|
+
const request = indexedDB.open(MANIFEST_DB_NAME, MANIFEST_VERSION);
|
|
262
|
+
request.onupgradeneeded = () => {
|
|
263
|
+
const db = request.result;
|
|
264
|
+
if (!db.objectStoreNames.contains(MANIFEST_STORE)) {
|
|
265
|
+
db.createObjectStore(MANIFEST_STORE, { keyPath: "dbName" });
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
request.onsuccess = () => resolve(request.result);
|
|
269
|
+
request.onerror = () => reject(request.error ?? new Error("Failed to open the Kora storage manifest"));
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
async function withStore(mode, run) {
|
|
273
|
+
const db = await openManifest();
|
|
274
|
+
try {
|
|
275
|
+
return await new Promise((resolve, reject) => {
|
|
276
|
+
const tx = db.transaction(MANIFEST_STORE, mode);
|
|
277
|
+
const request = run(tx.objectStore(MANIFEST_STORE));
|
|
278
|
+
let result;
|
|
279
|
+
if (request) {
|
|
280
|
+
request.onsuccess = () => {
|
|
281
|
+
result = request.result;
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
tx.oncomplete = () => resolve(result);
|
|
285
|
+
tx.onerror = () => reject(tx.error ?? new Error("Kora storage manifest transaction failed"));
|
|
286
|
+
tx.onabort = () => reject(tx.error ?? new Error("Kora storage manifest transaction aborted"));
|
|
287
|
+
});
|
|
288
|
+
} finally {
|
|
289
|
+
db.close();
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
function isRecord(value) {
|
|
293
|
+
if (typeof value !== "object" || value === null) return false;
|
|
294
|
+
const record = value;
|
|
295
|
+
return typeof record.dbName === "string" && (record.backend === "opfs" || record.backend === "indexeddb");
|
|
296
|
+
}
|
|
297
|
+
async function readManifestRecord(dbName) {
|
|
298
|
+
if (!isManifestAvailable()) return null;
|
|
299
|
+
const value = await withStore("readonly", (store) => store.get(dbName));
|
|
300
|
+
return isRecord(value) ? value : null;
|
|
301
|
+
}
|
|
302
|
+
async function listManifestRecords() {
|
|
303
|
+
if (!isManifestAvailable()) return [];
|
|
304
|
+
const values = await withStore("readonly", (store) => store.getAll());
|
|
305
|
+
return (values ?? []).filter(isRecord).sort((a, b) => a.dbName < b.dbName ? -1 : 1);
|
|
306
|
+
}
|
|
307
|
+
async function recordDatabase(entry) {
|
|
308
|
+
if (!isManifestAvailable()) return;
|
|
309
|
+
const existing = await readManifestRecord(entry.dbName);
|
|
310
|
+
const now = Date.now();
|
|
311
|
+
const record = {
|
|
312
|
+
dbName: entry.dbName,
|
|
313
|
+
backend: entry.backend,
|
|
314
|
+
...entry.poolName ? { poolName: entry.poolName } : {},
|
|
315
|
+
createdAt: existing?.createdAt ?? now,
|
|
316
|
+
lastOpenedAt: now
|
|
317
|
+
};
|
|
318
|
+
await withStore("readwrite", (store) => store.put(record));
|
|
319
|
+
}
|
|
320
|
+
async function deleteManifestRecord(dbName) {
|
|
321
|
+
if (!isManifestAvailable()) return;
|
|
322
|
+
await withStore("readwrite", (store) => store.delete(dbName));
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// src/adapters/sqlite-wasm-adapter.ts
|
|
326
|
+
var warnedSharedWorkerDeprecated = false;
|
|
327
|
+
function createGate() {
|
|
328
|
+
let open = () => {
|
|
329
|
+
};
|
|
330
|
+
const promise = new Promise((resolve) => {
|
|
331
|
+
open = resolve;
|
|
332
|
+
});
|
|
333
|
+
return { promise, open };
|
|
334
|
+
}
|
|
335
|
+
var SqliteWasmAdapter = class {
|
|
336
|
+
bridge = null;
|
|
337
|
+
/** The dedicated worker this tab runs while it is the database's leader. */
|
|
338
|
+
ownWorker = null;
|
|
339
|
+
opened = false;
|
|
340
|
+
mutex = new Mutex();
|
|
341
|
+
injectedBridge;
|
|
342
|
+
workerUrl;
|
|
343
|
+
sharedWorkerUrl;
|
|
344
|
+
workerResponseTimeoutMs;
|
|
345
|
+
dbName;
|
|
346
|
+
emitter;
|
|
347
|
+
tabSession = null;
|
|
348
|
+
emitNonPersistentDiagnostic;
|
|
349
|
+
storageOpenState = null;
|
|
350
|
+
/** Retained so a follower promoted to leader can re-open its own worker. */
|
|
351
|
+
schema = null;
|
|
352
|
+
promoting = false;
|
|
353
|
+
closing = false;
|
|
354
|
+
suspended = false;
|
|
355
|
+
/** Requests wait here while leadership changes hands (promotion, resume). */
|
|
356
|
+
gate = null;
|
|
357
|
+
removeLifecycleListeners = null;
|
|
358
|
+
allowNonDurable;
|
|
359
|
+
deferOpenDurabilityCheck;
|
|
360
|
+
releaseOnFreeze;
|
|
361
|
+
storage;
|
|
362
|
+
skipBackendRecord;
|
|
363
|
+
/** Set when this adapter lost (or never had) durable storage; writes are refused. */
|
|
364
|
+
durabilityLoss = null;
|
|
365
|
+
onLeaderWorkerOpened;
|
|
366
|
+
constructor(options = {}) {
|
|
367
|
+
this.injectedBridge = options.bridge;
|
|
368
|
+
this.workerUrl = options.workerUrl;
|
|
369
|
+
this.sharedWorkerUrl = options.sharedWorkerUrl;
|
|
370
|
+
this.workerResponseTimeoutMs = options.workerResponseTimeoutMs ?? 3e4;
|
|
371
|
+
this.dbName = options.dbName ?? "kora-db";
|
|
372
|
+
this.emitter = options.emitter;
|
|
373
|
+
this.emitNonPersistentDiagnostic = options.emitNonPersistentDiagnostic ?? true;
|
|
374
|
+
this.allowNonDurable = options.allowNonDurable ?? false;
|
|
375
|
+
this.deferOpenDurabilityCheck = options.deferOpenDurabilityCheck ?? false;
|
|
376
|
+
this.releaseOnFreeze = options.releaseOnFreeze ?? true;
|
|
377
|
+
this.storage = options.storage ?? "opfs";
|
|
378
|
+
this.skipBackendRecord = options.skipBackendRecord ?? false;
|
|
379
|
+
this.onLeaderWorkerOpened = options.onLeaderWorkerOpened;
|
|
380
|
+
}
|
|
381
|
+
async open(schema) {
|
|
382
|
+
if (this.opened) return;
|
|
383
|
+
const ddlStatements = generateFullDDL(schema);
|
|
384
|
+
if (this.injectedBridge) {
|
|
385
|
+
this.bridge = this.injectedBridge;
|
|
386
|
+
const response = await this.openCurrentBridge(ddlStatements);
|
|
387
|
+
this.reportStorageMode(response.data);
|
|
388
|
+
this.enforceDurability("open");
|
|
389
|
+
this.opened = true;
|
|
390
|
+
return;
|
|
391
|
+
}
|
|
392
|
+
if (this.workerUrl) {
|
|
393
|
+
this.warnSharedWorkerDeprecated();
|
|
394
|
+
this.schema = schema;
|
|
395
|
+
try {
|
|
396
|
+
const response = await this.attachAndOpen(ddlStatements);
|
|
397
|
+
this.reportStorageMode(response.data);
|
|
398
|
+
this.enforceDurability("open");
|
|
399
|
+
await this.afterLeaderOpen(response.data);
|
|
400
|
+
this.opened = true;
|
|
401
|
+
} catch (error) {
|
|
402
|
+
await this.teardown();
|
|
403
|
+
throw error;
|
|
404
|
+
}
|
|
405
|
+
return;
|
|
406
|
+
}
|
|
407
|
+
throw new AdapterError(
|
|
408
|
+
'SqliteWasmAdapter requires either a bridge (for testing) or a workerUrl (for browsers). Pass { bridge: new MockWorkerBridge() } for tests, or { workerUrl: "/worker.js" } for browsers.'
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
/**
|
|
412
|
+
* Join the database as leader (own worker) or follower (RPC to the leader's
|
|
413
|
+
* worker), then open it. The open waits, without a timeout, while another
|
|
414
|
+
* holder still has the storage; the worker reports that as a blocking state.
|
|
415
|
+
*/
|
|
416
|
+
async attachAndOpen(ddlStatements) {
|
|
417
|
+
const workerUrl = this.workerUrl;
|
|
418
|
+
if (!workerUrl) {
|
|
419
|
+
throw new AdapterError("Durable SQLite WASM storage requires workerUrl.");
|
|
420
|
+
}
|
|
421
|
+
const session = await acquireTabStorageSession(this.dbName, {
|
|
422
|
+
onPromote: () => {
|
|
423
|
+
void this.promoteToLeader();
|
|
424
|
+
}
|
|
425
|
+
});
|
|
426
|
+
this.tabSession = session;
|
|
427
|
+
if (session.role === "leader") {
|
|
428
|
+
const { WebWorkerBridge } = await import("./sqlite-wasm-channel-MLHLKEED.js");
|
|
429
|
+
const worker = new WebWorkerBridge(workerUrl, this.workerResponseTimeoutMs, {
|
|
430
|
+
onEvent: (event) => this.onWorkerEvent(event)
|
|
431
|
+
});
|
|
432
|
+
this.ownWorker = worker;
|
|
433
|
+
this.bridge = worker;
|
|
434
|
+
const response = await this.openLeaderWorker(worker, ddlStatements);
|
|
435
|
+
this.installLifecycleListeners();
|
|
436
|
+
return response;
|
|
437
|
+
}
|
|
438
|
+
const followerBridge = new FollowerBroadcastBridge(
|
|
439
|
+
session.channelName,
|
|
440
|
+
this.workerResponseTimeoutMs
|
|
441
|
+
);
|
|
442
|
+
this.bridge = followerBridge;
|
|
443
|
+
this.emitter?.emit({
|
|
444
|
+
type: "store:db-name-collision",
|
|
445
|
+
dbName: this.dbName,
|
|
446
|
+
message: `Another runtime on this origin is already using database "${this.dbName}"; this runtime is sharing it as a follower. If these are separate apps, give each a distinct store name.`
|
|
447
|
+
});
|
|
448
|
+
await followerBridge.waitForLeader();
|
|
449
|
+
this.installLifecycleListeners();
|
|
450
|
+
return this.checkedOpen(followerBridge, ddlStatements);
|
|
451
|
+
}
|
|
452
|
+
/** Open the database in this tab's own worker and start serving followers. */
|
|
453
|
+
async openLeaderWorker(worker, ddlStatements) {
|
|
454
|
+
const hook = this.onLeaderWorkerOpened;
|
|
455
|
+
if (!hook) {
|
|
456
|
+
const pending = this.checkedOpen(worker, ddlStatements);
|
|
457
|
+
worker.post({ id: 0, type: "serve", channelName: this.tabSession?.channelName ?? "" });
|
|
458
|
+
return pending;
|
|
459
|
+
}
|
|
460
|
+
const response = await this.checkedOpen(worker, ddlStatements);
|
|
461
|
+
await runWorkerTransaction(worker, hook);
|
|
462
|
+
worker.post({ id: 0, type: "serve", channelName: this.tabSession?.channelName ?? "" });
|
|
463
|
+
return response;
|
|
464
|
+
}
|
|
465
|
+
/**
|
|
466
|
+
* Whether this runtime owns the database's worker: the storage leader in a browser,
|
|
467
|
+
* or any adapter with an injected bridge (tests, Node). A follower tab relays every
|
|
468
|
+
* request to the leader's worker (STORE-6).
|
|
469
|
+
*/
|
|
470
|
+
isLeader() {
|
|
471
|
+
if (this.injectedBridge) return true;
|
|
472
|
+
return this.tabSession?.role === "leader";
|
|
473
|
+
}
|
|
474
|
+
async checkedOpen(bridge, ddlStatements) {
|
|
475
|
+
const response = await bridge.send(
|
|
476
|
+
{
|
|
477
|
+
id: 0,
|
|
478
|
+
type: "open",
|
|
479
|
+
ddlStatements,
|
|
480
|
+
dbName: this.dbName,
|
|
481
|
+
...this.storage === "memory" ? { storage: "memory" } : {}
|
|
482
|
+
},
|
|
483
|
+
void 0,
|
|
484
|
+
{ timeoutMs: Number.POSITIVE_INFINITY }
|
|
485
|
+
);
|
|
486
|
+
if (response.type === "error") {
|
|
487
|
+
throw new AdapterError(`Failed to open database: ${response.message}`, {
|
|
488
|
+
code: response.code,
|
|
489
|
+
dbName: this.dbName,
|
|
490
|
+
...response.context ?? {}
|
|
491
|
+
});
|
|
492
|
+
}
|
|
493
|
+
return response;
|
|
494
|
+
}
|
|
495
|
+
async openCurrentBridge(ddlStatements) {
|
|
496
|
+
const bridge = this.bridge;
|
|
497
|
+
if (!bridge) throw new StoreNotOpenError();
|
|
498
|
+
return this.checkedOpen(bridge, ddlStatements);
|
|
499
|
+
}
|
|
500
|
+
onWorkerEvent(event) {
|
|
501
|
+
if (!this.emitter) return;
|
|
502
|
+
const holder = event.resource === "legacy-pool" ? "the shared OPFS pool used by older Kora versions (a tab running an older version of this app may still be open)" : "another tab or worker that is still releasing it";
|
|
503
|
+
if (event.kind === "storage-blocked") {
|
|
504
|
+
this.emitter.emit({
|
|
505
|
+
type: "store:storage-blocked",
|
|
506
|
+
dbName: this.dbName,
|
|
507
|
+
resource: event.resource,
|
|
508
|
+
state: "waiting",
|
|
509
|
+
message: `Database "${this.dbName}" is waiting for ${holder}. Close other tabs of this app if this persists; Kora will not fall back to non-durable storage.`
|
|
510
|
+
});
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
this.emitter.emit({
|
|
514
|
+
type: "store:storage-blocked",
|
|
515
|
+
dbName: this.dbName,
|
|
516
|
+
resource: event.resource,
|
|
517
|
+
state: "resolved",
|
|
518
|
+
waitedMs: event.waitedMs,
|
|
519
|
+
message: `Database "${this.dbName}" obtained its storage after waiting ${event.waitedMs}ms.`
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
/**
|
|
523
|
+
* Leader-only bookkeeping after a durable open: report a legacy-pool
|
|
524
|
+
* migration, move an IndexedDB-held copy into OPFS explicitly (never two
|
|
525
|
+
* disjoint copies), and record OPFS as this database's backend.
|
|
526
|
+
*/
|
|
527
|
+
async afterLeaderOpen(data) {
|
|
528
|
+
if (!this.ownWorker || this.skipBackendRecord || this.storageOpenState?.persistent !== true) {
|
|
529
|
+
return;
|
|
530
|
+
}
|
|
531
|
+
const info = typeof data === "object" && data !== null ? data : {};
|
|
532
|
+
if (info.migratedFromLegacy === true) {
|
|
533
|
+
this.emitter?.emit({
|
|
534
|
+
type: "store:storage-migrated",
|
|
535
|
+
dbName: this.dbName,
|
|
536
|
+
from: "legacy-opfs-pool",
|
|
537
|
+
to: "opfs",
|
|
538
|
+
message: `Database "${this.dbName}" moved from the shared pre-1.0 OPFS pool into its own pool.`
|
|
539
|
+
});
|
|
540
|
+
}
|
|
541
|
+
if (!isManifestAvailable()) return;
|
|
542
|
+
let recordedBackend = null;
|
|
543
|
+
try {
|
|
544
|
+
recordedBackend = (await readManifestRecord(this.dbName))?.backend ?? null;
|
|
545
|
+
} catch (error) {
|
|
546
|
+
console.warn(`[kora] Could not read the storage manifest for "${this.dbName}":`, error);
|
|
547
|
+
}
|
|
548
|
+
if (recordedBackend === "indexeddb") {
|
|
549
|
+
const dump = await loadDumpFromIndexedDB(this.dbName);
|
|
550
|
+
if (dump) {
|
|
551
|
+
await this.applyDumpThroughWorker(this.ownWorker, dump);
|
|
552
|
+
}
|
|
553
|
+
this.emitter?.emit({
|
|
554
|
+
type: "store:storage-migrated",
|
|
555
|
+
dbName: this.dbName,
|
|
556
|
+
from: "indexeddb",
|
|
557
|
+
to: "opfs",
|
|
558
|
+
message: `Database "${this.dbName}" moved from IndexedDB into OPFS.`
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
try {
|
|
562
|
+
await recordDatabase({
|
|
563
|
+
dbName: this.dbName,
|
|
564
|
+
backend: "opfs",
|
|
565
|
+
...this.storageOpenState.poolName ? { poolName: this.storageOpenState.poolName } : {}
|
|
566
|
+
});
|
|
567
|
+
if (recordedBackend === "indexeddb") {
|
|
568
|
+
await deleteFromIndexedDB(this.dbName);
|
|
569
|
+
}
|
|
570
|
+
} catch (error) {
|
|
571
|
+
console.warn(`[kora] Could not update the storage manifest for "${this.dbName}":`, error);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
async applyDumpThroughWorker(worker, dump) {
|
|
575
|
+
const check = async (request, what) => {
|
|
576
|
+
const response = await worker.send(request);
|
|
577
|
+
if (response.type === "error") {
|
|
578
|
+
throw new AdapterError(`${what} failed: ${response.message}`, { dbName: this.dbName });
|
|
579
|
+
}
|
|
580
|
+
};
|
|
581
|
+
await check({ id: 0, type: "begin" }, "BEGIN IndexedDB import");
|
|
582
|
+
try {
|
|
583
|
+
for (const statement of restoreDumpStatements(dump)) {
|
|
584
|
+
await check(
|
|
585
|
+
{ id: 0, type: "execute", sql: statement.sql, params: statement.params },
|
|
586
|
+
"IndexedDB import"
|
|
587
|
+
);
|
|
588
|
+
}
|
|
589
|
+
await check({ id: 0, type: "commit" }, "COMMIT IndexedDB import");
|
|
590
|
+
} catch (error) {
|
|
591
|
+
await worker.send({ id: 0, type: "rollback" }).catch(() => void 0);
|
|
592
|
+
throw error;
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
/**
|
|
596
|
+
* Surface a `store:opfs-unavailable` diagnostic when the worker reported that
|
|
597
|
+
* persistence silently degraded to an in-memory database, so a data-loss
|
|
598
|
+
* condition is observable instead of failing silently. A persistent open (or a
|
|
599
|
+
* bridge that does not report a mode, e.g. the Node mock) emits nothing.
|
|
600
|
+
*/
|
|
601
|
+
reportStorageMode(data) {
|
|
602
|
+
this.storageOpenState = parseStorageOpenState(data);
|
|
603
|
+
if (!this.emitNonPersistentDiagnostic || !this.emitter || !this.storageOpenState) {
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
if (this.storageOpenState.persistent === false && this.storage === "opfs") {
|
|
607
|
+
const reason = this.storageOpenState.fallbackReason ?? "unsupported";
|
|
608
|
+
this.emitter.emit({
|
|
609
|
+
type: "store:opfs-unavailable",
|
|
610
|
+
dbName: this.dbName,
|
|
611
|
+
reason,
|
|
612
|
+
message: `OPFS persistence is unavailable (${reason}) for database "${this.dbName}"; the store is running in memory and data will not survive a reload.`
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
getStorageOpenState() {
|
|
617
|
+
return this.storageOpenState;
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* Durable or loud, never silent (NEW-STORE-6). When the open or a promotion
|
|
621
|
+
* ended on non-persistent storage and the app did not opt into that, emit the
|
|
622
|
+
* blocking `store:durability-lost` event and refuse every later write. A
|
|
623
|
+
* bridge that reports no mode (the Node mock) is not judged.
|
|
624
|
+
*/
|
|
625
|
+
enforceDurability(phase, failure) {
|
|
626
|
+
if (this.allowNonDurable) return;
|
|
627
|
+
if (phase === "open" && this.deferOpenDurabilityCheck && !failure) return;
|
|
628
|
+
let reason;
|
|
629
|
+
if (failure) {
|
|
630
|
+
reason = failure.reason;
|
|
631
|
+
} else if (this.storageOpenState?.persistent === false) {
|
|
632
|
+
reason = this.storageOpenState.fallbackReason ?? "unsupported";
|
|
633
|
+
} else {
|
|
634
|
+
this.durabilityLoss = null;
|
|
635
|
+
return;
|
|
636
|
+
}
|
|
637
|
+
this.durabilityLoss = { phase, reason };
|
|
638
|
+
this.emitter?.emit({
|
|
639
|
+
type: "store:durability-lost",
|
|
640
|
+
dbName: this.dbName,
|
|
641
|
+
phase,
|
|
642
|
+
reason,
|
|
643
|
+
message: `Database "${this.dbName}" has no durable storage (${reason} during ${phase}); writes are refused until the app reloads with durable storage.${failure ? ` ${failure.message}` : ""}`
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
guardDurableWrite() {
|
|
647
|
+
if (this.durabilityLoss) {
|
|
648
|
+
throw new StorageDurabilityError(
|
|
649
|
+
this.dbName,
|
|
650
|
+
this.durabilityLoss.phase,
|
|
651
|
+
this.durabilityLoss.reason
|
|
652
|
+
);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
warnSharedWorkerDeprecated() {
|
|
656
|
+
if (!this.sharedWorkerUrl || warnedSharedWorkerDeprecated) {
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
659
|
+
warnedSharedWorkerDeprecated = true;
|
|
660
|
+
console.warn(
|
|
661
|
+
"[kora] sharedWorkerUrl is deprecated and ignored: SharedWorker-hosted SQLite cannot use OPFS and is never durable. Kora uses the durable dedicated-worker leader/follower path for multi-tab storage."
|
|
662
|
+
);
|
|
663
|
+
}
|
|
664
|
+
/**
|
|
665
|
+
* Close the database. A leader closes the database file and releases the OPFS
|
|
666
|
+
* pool inside its worker, terminates the worker, and only then releases the
|
|
667
|
+
* tab leader lock, so the next leader never races a still-running worker for
|
|
668
|
+
* the file handles (NEW-STORE-10). A follower just leaves.
|
|
669
|
+
*/
|
|
670
|
+
async close() {
|
|
671
|
+
if (!this.bridge && !this.tabSession) return;
|
|
672
|
+
this.closing = true;
|
|
673
|
+
try {
|
|
674
|
+
if (this.injectedBridge && this.bridge) {
|
|
675
|
+
await this.bridge.send({ id: 0, type: "close" });
|
|
676
|
+
} else if (this.ownWorker && !this.ownWorker.isTerminated()) {
|
|
677
|
+
const response = await this.ownWorker.send({ id: 0, type: "close" }, void 0, { timeoutMs: 5e3 }).catch((error) => ({
|
|
678
|
+
id: 0,
|
|
679
|
+
type: "error",
|
|
680
|
+
message: error instanceof Error ? error.message : String(error),
|
|
681
|
+
code: "CLOSE_FAILED"
|
|
682
|
+
}));
|
|
683
|
+
if (response.type === "error") {
|
|
684
|
+
console.warn(`[kora] Closing "${this.dbName}" in its worker failed:`, response.message);
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
} finally {
|
|
688
|
+
await this.teardown();
|
|
689
|
+
this.closing = false;
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
/**
|
|
693
|
+
* Release everything this adapter holds, in ownership order: lifecycle
|
|
694
|
+
* listeners, then the worker (which holds the OPFS pool and its Web Lock),
|
|
695
|
+
* then the tab leader lock or queued promotion request.
|
|
696
|
+
*/
|
|
697
|
+
async teardown() {
|
|
698
|
+
this.removeLifecycleListeners?.();
|
|
699
|
+
this.removeLifecycleListeners = null;
|
|
700
|
+
const session = this.tabSession;
|
|
701
|
+
this.tabSession = null;
|
|
702
|
+
session?.stopRelay?.();
|
|
703
|
+
this.bridge?.terminate();
|
|
704
|
+
this.bridge = null;
|
|
705
|
+
this.ownWorker = null;
|
|
706
|
+
if (session) {
|
|
707
|
+
if (session.releaseLock) {
|
|
708
|
+
await session.releaseLock();
|
|
709
|
+
}
|
|
710
|
+
session.cancelPromotionWatch?.();
|
|
711
|
+
}
|
|
712
|
+
this.opened = false;
|
|
713
|
+
this.suspended = false;
|
|
714
|
+
const gate = this.gate;
|
|
715
|
+
this.gate = null;
|
|
716
|
+
gate?.open();
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* Promotes a follower to leader after the previous leader released the storage
|
|
720
|
+
* lock (its tab closed, crashed, or suspended on freeze). The new worker waits
|
|
721
|
+
* on the pool's Web Lock and handles, so it never races the previous worker.
|
|
722
|
+
* Requests issued during the hand-off wait for it; requests that were in
|
|
723
|
+
* flight to the old leader fail with a retriable `BridgeTerminatedError`.
|
|
724
|
+
*/
|
|
725
|
+
async promoteToLeader() {
|
|
726
|
+
if (this.promoting || this.closing || !this.opened || this.workerUrl === void 0 || this.schema === null) {
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
this.promoting = true;
|
|
730
|
+
const gate = createGate();
|
|
731
|
+
this.gate = gate;
|
|
732
|
+
try {
|
|
733
|
+
const { WebWorkerBridge } = await import("./sqlite-wasm-channel-MLHLKEED.js");
|
|
734
|
+
if (this.closing || !this.opened) return;
|
|
735
|
+
const worker = new WebWorkerBridge(this.workerUrl, this.workerResponseTimeoutMs, {
|
|
736
|
+
onEvent: (event) => this.onWorkerEvent(event)
|
|
737
|
+
});
|
|
738
|
+
const previousBridge = this.bridge;
|
|
739
|
+
this.bridge = worker;
|
|
740
|
+
this.ownWorker = worker;
|
|
741
|
+
if (this.tabSession) {
|
|
742
|
+
this.tabSession.role = "leader";
|
|
743
|
+
}
|
|
744
|
+
previousBridge?.terminate();
|
|
745
|
+
const ddlStatements = generateFullDDL(this.schema);
|
|
746
|
+
let response;
|
|
747
|
+
try {
|
|
748
|
+
response = await this.openLeaderWorker(worker, ddlStatements);
|
|
749
|
+
} catch (error) {
|
|
750
|
+
this.storageOpenState = { persistent: false, mode: "memory" };
|
|
751
|
+
this.enforceDurability("promotion", {
|
|
752
|
+
reason: "open-failed",
|
|
753
|
+
message: error instanceof Error ? error.message : String(error)
|
|
754
|
+
});
|
|
755
|
+
return;
|
|
756
|
+
}
|
|
757
|
+
this.storageOpenState = parseStorageOpenState(response.data) ?? this.storageOpenState;
|
|
758
|
+
this.enforceDurability("promotion");
|
|
759
|
+
try {
|
|
760
|
+
await this.afterLeaderOpen(response.data);
|
|
761
|
+
} catch (error) {
|
|
762
|
+
this.enforceDurability("promotion", {
|
|
763
|
+
reason: "open-failed",
|
|
764
|
+
message: error instanceof Error ? error.message : String(error)
|
|
765
|
+
});
|
|
766
|
+
}
|
|
767
|
+
} finally {
|
|
768
|
+
this.promoting = false;
|
|
769
|
+
if (this.gate === gate) this.gate = null;
|
|
770
|
+
gate.open();
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Page Lifecycle: a frozen or bfcached page cannot run, so it must not keep
|
|
775
|
+
* other tabs waiting on its storage. Leaders terminate their worker (freeing
|
|
776
|
+
* the OPFS pool) and then release the leader lock, so a visible tab is
|
|
777
|
+
* promoted; followers withdraw their queued lock request so a frozen tab is
|
|
778
|
+
* never granted leadership it cannot use. Never uses Web Locks `steal`.
|
|
779
|
+
*/
|
|
780
|
+
installLifecycleListeners() {
|
|
781
|
+
if (!this.releaseOnFreeze || this.removeLifecycleListeners) return;
|
|
782
|
+
if (typeof addEventListener !== "function" || typeof document === "undefined") return;
|
|
783
|
+
const onFreeze = () => this.suspend();
|
|
784
|
+
const onPageHide = () => this.suspend();
|
|
785
|
+
const onResume = () => {
|
|
786
|
+
void this.resume();
|
|
787
|
+
};
|
|
788
|
+
const onPageShow = (event) => {
|
|
789
|
+
if (event.persisted) void this.resume();
|
|
790
|
+
};
|
|
791
|
+
document.addEventListener("freeze", onFreeze);
|
|
792
|
+
document.addEventListener("resume", onResume);
|
|
793
|
+
addEventListener("pagehide", onPageHide);
|
|
794
|
+
addEventListener("pageshow", onPageShow);
|
|
795
|
+
this.removeLifecycleListeners = () => {
|
|
796
|
+
document.removeEventListener("freeze", onFreeze);
|
|
797
|
+
document.removeEventListener("resume", onResume);
|
|
798
|
+
removeEventListener("pagehide", onPageHide);
|
|
799
|
+
removeEventListener("pageshow", onPageShow);
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
/** Synchronous by necessity: a `freeze` handler gets no further turns. */
|
|
803
|
+
suspend() {
|
|
804
|
+
if (!this.opened || this.suspended || this.closing || this.promoting) return;
|
|
805
|
+
this.suspended = true;
|
|
806
|
+
this.gate = createGate();
|
|
807
|
+
const session = this.tabSession;
|
|
808
|
+
this.tabSession = null;
|
|
809
|
+
this.bridge?.terminate();
|
|
810
|
+
this.bridge = null;
|
|
811
|
+
this.ownWorker = null;
|
|
812
|
+
if (session) {
|
|
813
|
+
void session.releaseLock?.();
|
|
814
|
+
session.cancelPromotionWatch?.();
|
|
815
|
+
}
|
|
816
|
+
}
|
|
817
|
+
async resume() {
|
|
818
|
+
if (!this.suspended || this.closing || this.schema === null) return;
|
|
819
|
+
const gate = this.gate;
|
|
820
|
+
try {
|
|
821
|
+
const response = await this.attachAndOpen(generateFullDDL(this.schema));
|
|
822
|
+
this.reportStorageMode(response.data);
|
|
823
|
+
this.enforceDurability("promotion");
|
|
824
|
+
await this.afterLeaderOpen(response.data);
|
|
825
|
+
} catch (error) {
|
|
826
|
+
this.enforceDurability("promotion", {
|
|
827
|
+
reason: "open-failed",
|
|
828
|
+
message: error instanceof Error ? error.message : String(error)
|
|
829
|
+
});
|
|
830
|
+
} finally {
|
|
831
|
+
this.suspended = false;
|
|
832
|
+
if (this.gate === gate) this.gate = null;
|
|
833
|
+
gate?.open();
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
/**
|
|
837
|
+
* Non-transactional write. Takes the same mutex as {@link transaction}, so it
|
|
838
|
+
* never lands inside this tab's open transaction and is never rolled back
|
|
839
|
+
* with it (STORE-8). Inside a transaction callback, use the `tx` handle.
|
|
840
|
+
*/
|
|
841
|
+
async execute(sql, params, options) {
|
|
842
|
+
this.guardOpen();
|
|
843
|
+
this.guardDurableWrite();
|
|
844
|
+
const release = await this.mutex.acquire();
|
|
845
|
+
try {
|
|
846
|
+
const response = await this.sendRequest(
|
|
847
|
+
{
|
|
848
|
+
id: 0,
|
|
849
|
+
type: "execute",
|
|
850
|
+
sql,
|
|
851
|
+
params,
|
|
852
|
+
...options?.requestId ? { requestId: options.requestId } : {}
|
|
853
|
+
},
|
|
854
|
+
options?.signal ? { signal: options.signal } : void 0
|
|
855
|
+
);
|
|
856
|
+
if (response.type === "error") {
|
|
857
|
+
throw new AdapterError(`Execute failed: ${response.message}`, { sql, params });
|
|
858
|
+
}
|
|
859
|
+
} finally {
|
|
860
|
+
release();
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Non-transactional read. Waits for this tab's open transaction to finish, so
|
|
865
|
+
* it never observes uncommitted (possibly rolled-back) rows (STORE-8).
|
|
866
|
+
*/
|
|
867
|
+
async query(sql, params, options) {
|
|
868
|
+
this.guardOpen();
|
|
869
|
+
const release = await this.mutex.acquire();
|
|
870
|
+
try {
|
|
871
|
+
const response = await this.sendRequest(
|
|
872
|
+
{
|
|
873
|
+
id: 0,
|
|
874
|
+
type: "query",
|
|
875
|
+
sql,
|
|
876
|
+
params,
|
|
877
|
+
...options?.requestId ? { requestId: options.requestId } : {}
|
|
878
|
+
},
|
|
879
|
+
options?.signal ? { signal: options.signal } : void 0
|
|
880
|
+
);
|
|
881
|
+
if (response.type === "error") {
|
|
882
|
+
throw new AdapterError(`Query failed: ${response.message}`, { sql, params });
|
|
883
|
+
}
|
|
884
|
+
return response.data ?? [];
|
|
885
|
+
} finally {
|
|
886
|
+
release();
|
|
887
|
+
}
|
|
888
|
+
}
|
|
889
|
+
async transaction(fn) {
|
|
890
|
+
this.guardOpen();
|
|
891
|
+
this.guardDurableWrite();
|
|
892
|
+
const release = await this.mutex.acquire();
|
|
893
|
+
let txBridge = null;
|
|
894
|
+
const sendInSpan = async (request) => {
|
|
895
|
+
if (txBridge === null || this.bridge !== txBridge) {
|
|
896
|
+
throw new BridgeTerminatedError(request.type, "storage leader changed during transaction");
|
|
897
|
+
}
|
|
898
|
+
return txBridge.send(request);
|
|
899
|
+
};
|
|
900
|
+
try {
|
|
901
|
+
while (this.gate) {
|
|
902
|
+
await this.gate.promise;
|
|
903
|
+
}
|
|
904
|
+
txBridge = this.bridge;
|
|
905
|
+
if (!txBridge) throw new StoreNotOpenError();
|
|
906
|
+
const begin = await sendInSpan({ id: 0, type: "begin" });
|
|
907
|
+
if (begin.type === "error") {
|
|
908
|
+
throw new AdapterError(`BEGIN transaction failed: ${begin.message}`);
|
|
909
|
+
}
|
|
910
|
+
const tx = {
|
|
911
|
+
execute: async (sql, params) => {
|
|
912
|
+
const response = await sendInSpan({ id: 0, type: "execute", sql, params });
|
|
913
|
+
if (response.type === "error") {
|
|
914
|
+
throw new AdapterError(`Transaction execute failed: ${response.message}`, {
|
|
915
|
+
sql,
|
|
916
|
+
params
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
},
|
|
920
|
+
query: async (sql, params) => {
|
|
921
|
+
const response = await sendInSpan({ id: 0, type: "query", sql, params });
|
|
922
|
+
if (response.type === "error") {
|
|
923
|
+
throw new AdapterError(`Transaction query failed: ${response.message}`, { sql, params });
|
|
924
|
+
}
|
|
925
|
+
return response.data ?? [];
|
|
926
|
+
}
|
|
927
|
+
};
|
|
928
|
+
await fn(tx);
|
|
929
|
+
const commit = await sendInSpan({ id: 0, type: "commit" });
|
|
930
|
+
if (commit.type === "error") {
|
|
931
|
+
throw new AdapterError(`COMMIT transaction failed: ${commit.message}`);
|
|
932
|
+
}
|
|
933
|
+
} catch (error) {
|
|
934
|
+
try {
|
|
935
|
+
if (txBridge && this.bridge === txBridge) {
|
|
936
|
+
await txBridge.send({ id: 0, type: "rollback" });
|
|
937
|
+
}
|
|
938
|
+
} catch {
|
|
939
|
+
}
|
|
940
|
+
throw error;
|
|
941
|
+
} finally {
|
|
942
|
+
release();
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
async migrate(from, to, migration) {
|
|
946
|
+
this.guardOpen();
|
|
947
|
+
this.guardDurableWrite();
|
|
948
|
+
const release = await this.mutex.acquire();
|
|
949
|
+
try {
|
|
950
|
+
await this.sendChecked({ id: 0, type: "begin" }, "BEGIN migration");
|
|
951
|
+
for (const sql of migration.statements) {
|
|
952
|
+
const response = await this.sendRequest({ id: 0, type: "execute", sql });
|
|
953
|
+
if (response.type === "error") {
|
|
954
|
+
throw new AdapterError(`Migration from v${from} to v${to} failed: ${response.message}`, {
|
|
955
|
+
from,
|
|
956
|
+
to
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
await this.sendChecked({ id: 0, type: "commit" }, "COMMIT migration");
|
|
961
|
+
} catch (error) {
|
|
962
|
+
try {
|
|
963
|
+
await this.sendRequest({ id: 0, type: "rollback" });
|
|
964
|
+
} catch {
|
|
965
|
+
}
|
|
966
|
+
if (error instanceof AdapterError) throw error;
|
|
967
|
+
throw new AdapterError(
|
|
968
|
+
`Migration from v${from} to v${to} failed: ${error.message}`,
|
|
969
|
+
{ from, to }
|
|
970
|
+
);
|
|
971
|
+
} finally {
|
|
972
|
+
release();
|
|
973
|
+
}
|
|
974
|
+
}
|
|
975
|
+
/**
|
|
976
|
+
* Export the database as a Uint8Array (for IndexedDB persistence).
|
|
977
|
+
* Only available when the database is open.
|
|
978
|
+
*/
|
|
979
|
+
async exportDatabase() {
|
|
980
|
+
this.guardOpen();
|
|
981
|
+
const release = await this.mutex.acquire();
|
|
982
|
+
try {
|
|
983
|
+
const response = await this.sendRequest({ id: 0, type: "export" });
|
|
984
|
+
if (response.type === "error") {
|
|
985
|
+
throw new AdapterError(`Export failed: ${response.message}`, {
|
|
986
|
+
code: response.code,
|
|
987
|
+
context: response.context
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
return response.data;
|
|
991
|
+
} finally {
|
|
992
|
+
release();
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
/**
|
|
996
|
+
* Import a serialized database snapshot.
|
|
997
|
+
*/
|
|
998
|
+
async importDatabase(data) {
|
|
999
|
+
this.guardOpen();
|
|
1000
|
+
this.guardDurableWrite();
|
|
1001
|
+
const release = await this.mutex.acquire();
|
|
1002
|
+
try {
|
|
1003
|
+
const response = await this.sendRequest({ id: 0, type: "import", data });
|
|
1004
|
+
if (response.type === "error") {
|
|
1005
|
+
throw new AdapterError(`Import failed: ${response.message}`);
|
|
1006
|
+
}
|
|
1007
|
+
} finally {
|
|
1008
|
+
release();
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
guardOpen() {
|
|
1012
|
+
if (!this.opened || !this.bridge && !this.gate) {
|
|
1013
|
+
throw new StoreNotOpenError();
|
|
1014
|
+
}
|
|
1015
|
+
}
|
|
1016
|
+
async sendRequest(request, options) {
|
|
1017
|
+
while (this.gate) {
|
|
1018
|
+
await this.gate.promise;
|
|
1019
|
+
}
|
|
1020
|
+
const bridge = this.bridge;
|
|
1021
|
+
if (!bridge) {
|
|
1022
|
+
throw new StoreNotOpenError();
|
|
1023
|
+
}
|
|
1024
|
+
return bridge.send(request, void 0, options);
|
|
1025
|
+
}
|
|
1026
|
+
async sendChecked(request, description) {
|
|
1027
|
+
const response = await this.sendRequest(request);
|
|
1028
|
+
if (response.type === "error") {
|
|
1029
|
+
throw new AdapterError(`${description} failed: ${response.message}`);
|
|
1030
|
+
}
|
|
1031
|
+
}
|
|
1032
|
+
};
|
|
1033
|
+
async function runWorkerTransaction(worker, fn) {
|
|
1034
|
+
const send = async (request, what) => {
|
|
1035
|
+
const response = await worker.send(request);
|
|
1036
|
+
if (response.type === "error") {
|
|
1037
|
+
throw new AdapterError(`${what} failed: ${response.message}`, {
|
|
1038
|
+
code: response.code,
|
|
1039
|
+
...request.type === "execute" || request.type === "query" ? { sql: request.sql } : {}
|
|
1040
|
+
});
|
|
1041
|
+
}
|
|
1042
|
+
return response;
|
|
1043
|
+
};
|
|
1044
|
+
await send({ id: 0, type: "begin" }, "BEGIN");
|
|
1045
|
+
try {
|
|
1046
|
+
await fn({
|
|
1047
|
+
execute: async (sql, params) => {
|
|
1048
|
+
await send({ id: 0, type: "execute", sql, params }, "Leader open execute");
|
|
1049
|
+
},
|
|
1050
|
+
query: async (sql, params) => {
|
|
1051
|
+
const response = await send({ id: 0, type: "query", sql, params }, "Leader open query");
|
|
1052
|
+
return response.data ?? [];
|
|
1053
|
+
}
|
|
1054
|
+
});
|
|
1055
|
+
await send({ id: 0, type: "commit" }, "COMMIT");
|
|
1056
|
+
} catch (error) {
|
|
1057
|
+
await worker.send({ id: 0, type: "rollback" }).catch(() => void 0);
|
|
1058
|
+
throw error;
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
function parseStorageOpenState(data) {
|
|
1062
|
+
if (typeof data !== "object" || data === null) {
|
|
1063
|
+
return null;
|
|
1064
|
+
}
|
|
1065
|
+
const mode = data;
|
|
1066
|
+
if (typeof mode.persistent !== "boolean") {
|
|
1067
|
+
return null;
|
|
1068
|
+
}
|
|
1069
|
+
return {
|
|
1070
|
+
persistent: mode.persistent,
|
|
1071
|
+
mode: mode.persistent ? "opfs" : "memory",
|
|
1072
|
+
...mode.fallbackReason ? { fallbackReason: mode.fallbackReason } : {},
|
|
1073
|
+
...typeof mode.poolName === "string" ? { poolName: mode.poolName } : {},
|
|
1074
|
+
...typeof mode.journalMode === "string" ? { journalMode: mode.journalMode } : {}
|
|
1075
|
+
};
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
export {
|
|
1079
|
+
exportDump,
|
|
1080
|
+
restoreDumpStatements,
|
|
1081
|
+
isIndexedDbQuotaError,
|
|
1082
|
+
saveToIndexedDB,
|
|
1083
|
+
saveDumpToIndexedDB,
|
|
1084
|
+
loadFromIndexedDB,
|
|
1085
|
+
loadDumpFromIndexedDB,
|
|
1086
|
+
deleteSnapshotFromIndexedDB,
|
|
1087
|
+
deleteFromIndexedDB,
|
|
1088
|
+
isManifestAvailable,
|
|
1089
|
+
readManifestRecord,
|
|
1090
|
+
listManifestRecords,
|
|
1091
|
+
recordDatabase,
|
|
1092
|
+
deleteManifestRecord,
|
|
1093
|
+
SqliteWasmAdapter
|
|
1094
|
+
};
|
|
1095
|
+
//# sourceMappingURL=chunk-QDUSJMFV.js.map
|