@korajs/store 1.0.0-beta.0 → 1.0.0-beta.10
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/adapters/better-sqlite3.cjs.map +1 -1
- package/dist/adapters/better-sqlite3.d.cts +1 -1
- package/dist/adapters/better-sqlite3.d.ts +1 -1
- package/dist/adapters/better-sqlite3.js +1 -1
- package/dist/adapters/indexeddb.cjs +523 -148
- package/dist/adapters/indexeddb.cjs.map +1 -1
- package/dist/adapters/indexeddb.d.cts +4 -2
- package/dist/adapters/indexeddb.d.ts +4 -2
- package/dist/adapters/indexeddb.js +55 -11
- package/dist/adapters/indexeddb.js.map +1 -1
- package/dist/adapters/sqlite-wasm-worker.cjs +207 -187
- package/dist/adapters/sqlite-wasm-worker.cjs.map +1 -1
- package/dist/adapters/sqlite-wasm-worker.js +208 -188
- package/dist/adapters/sqlite-wasm-worker.js.map +1 -1
- package/dist/adapters/sqlite-wasm.cjs +474 -140
- package/dist/adapters/sqlite-wasm.cjs.map +1 -1
- package/dist/adapters/sqlite-wasm.d.cts +45 -8
- package/dist/adapters/sqlite-wasm.d.ts +45 -8
- package/dist/adapters/sqlite-wasm.js +3 -3
- package/dist/{backup-5XZ7THNJ.js → backup-5S7K2Z35.js} +3 -3
- package/dist/blob-fs.cjs +166 -0
- package/dist/blob-fs.cjs.map +1 -0
- package/dist/blob-fs.d.cts +29 -0
- package/dist/blob-fs.d.ts +29 -0
- package/dist/blob-fs.js +128 -0
- package/dist/blob-fs.js.map +1 -0
- package/dist/{chunk-EIVXWF4B.js → chunk-CYO3YKFL.js} +4 -3
- package/dist/chunk-CYO3YKFL.js.map +1 -0
- package/dist/{chunk-ETDJEH2I.js → chunk-GSJ7EEUC.js} +12 -1
- package/dist/chunk-GSJ7EEUC.js.map +1 -0
- package/dist/{chunk-5R4QMY3Z.js → chunk-HS6DBMST.js} +13 -10
- package/dist/chunk-HS6DBMST.js.map +1 -0
- package/dist/chunk-RJIEKEN4.js +751 -0
- package/dist/chunk-RJIEKEN4.js.map +1 -0
- package/dist/{chunk-QL2XVZRN.js → chunk-TUR7RGEP.js} +12 -1
- package/dist/{chunk-QL2XVZRN.js.map → chunk-TUR7RGEP.js.map} +1 -1
- package/dist/{chunk-IOEFNHHP.js → chunk-VDBJQ75K.js} +2 -2
- package/dist/chunk-VDBJQ75K.js.map +1 -0
- package/dist/chunk-W4MTTNEZ.js +55 -0
- package/dist/chunk-W4MTTNEZ.js.map +1 -0
- package/dist/{chunk-OUQDC5Z6.js → chunk-YT7MRFCP.js} +55 -35
- package/dist/chunk-YT7MRFCP.js.map +1 -0
- package/dist/content-addressed-blob-store-Dcj3SB9r.d.cts +60 -0
- package/dist/content-addressed-blob-store-Dcj3SB9r.d.ts +60 -0
- package/dist/{export-audit-VPGQWNR6.js → export-audit-ODGQTQIK.js} +3 -3
- package/dist/index.cjs +923 -131
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +443 -6
- package/dist/index.d.ts +443 -6
- package/dist/index.js +737 -49
- package/dist/index.js.map +1 -1
- package/dist/internal.cjs +69 -43
- package/dist/internal.cjs.map +1 -1
- package/dist/internal.d.cts +3 -3
- package/dist/internal.d.ts +3 -3
- package/dist/internal.js +3 -3
- package/dist/sqlite-wasm-channel-GGHA2BXU.js +12 -0
- package/dist/{sqlite-wasm-channel-DUDPclTE.d.cts → sqlite-wasm-channel-L6o3bWQK.d.cts} +1 -1
- package/dist/{sqlite-wasm-channel-DUDPclTE.d.ts → sqlite-wasm-channel-L6o3bWQK.d.ts} +1 -1
- package/dist/{types-CyjXwrjW.d.cts → types-BOO9EPt1.d.ts} +10 -3
- package/dist/{types-CJOVKxLw.d.ts → types-BslDOTk0.d.cts} +32 -2
- package/dist/{types-CJOVKxLw.d.cts → types-BslDOTk0.d.ts} +32 -2
- package/dist/{types-DAxCladl.d.ts → types-C_GDavG-.d.cts} +10 -3
- package/package.json +13 -12
- package/dist/adapters/sqlite-wasm-shared-host.cjs +0 -68
- package/dist/adapters/sqlite-wasm-shared-host.cjs.map +0 -1
- package/dist/adapters/sqlite-wasm-shared-host.d.cts +0 -2
- package/dist/adapters/sqlite-wasm-shared-host.d.ts +0 -2
- package/dist/adapters/sqlite-wasm-shared-host.js +0 -66
- package/dist/adapters/sqlite-wasm-shared-host.js.map +0 -1
- package/dist/chunk-5R4QMY3Z.js.map +0 -1
- package/dist/chunk-EIVXWF4B.js.map +0 -1
- package/dist/chunk-ETDJEH2I.js.map +0 -1
- package/dist/chunk-IOEFNHHP.js.map +0 -1
- package/dist/chunk-OUQDC5Z6.js.map +0 -1
- package/dist/chunk-RBPVEAVM.js +0 -425
- package/dist/chunk-RBPVEAVM.js.map +0 -1
- package/dist/sqlite-wasm-channel-H43KEZLM.js +0 -12
- /package/dist/{backup-5XZ7THNJ.js.map → backup-5S7K2Z35.js.map} +0 -0
- /package/dist/{export-audit-VPGQWNR6.js.map → export-audit-ODGQTQIK.js.map} +0 -0
- /package/dist/{sqlite-wasm-channel-H43KEZLM.js.map → sqlite-wasm-channel-GGHA2BXU.js.map} +0 -0
|
@@ -21,7 +21,7 @@ var __copyProps = (to, from, except, desc) => {
|
|
|
21
21
|
var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
|
|
22
22
|
|
|
23
23
|
// src/errors.ts
|
|
24
|
-
var import_core, AdapterError, StoreNotOpenError, WorkerTimeoutError;
|
|
24
|
+
var import_core, AdapterError, StoreNotOpenError, WorkerTimeoutError, NoLeaderError;
|
|
25
25
|
var init_errors = __esm({
|
|
26
26
|
"src/errors.ts"() {
|
|
27
27
|
"use strict";
|
|
@@ -48,6 +48,16 @@ var init_errors = __esm({
|
|
|
48
48
|
this.name = "WorkerTimeoutError";
|
|
49
49
|
}
|
|
50
50
|
};
|
|
51
|
+
NoLeaderError = class extends import_core.KoraError {
|
|
52
|
+
constructor(operation) {
|
|
53
|
+
super(
|
|
54
|
+
`No live leader tab is answering multi-tab storage RPC for operation "${operation}"`,
|
|
55
|
+
"NO_LEADER",
|
|
56
|
+
{ operation }
|
|
57
|
+
);
|
|
58
|
+
this.name = "NoLeaderError";
|
|
59
|
+
}
|
|
60
|
+
};
|
|
51
61
|
}
|
|
52
62
|
});
|
|
53
63
|
|
|
@@ -189,130 +199,86 @@ module.exports = __toCommonJS(sqlite_wasm_exports);
|
|
|
189
199
|
var import_core2 = require("@korajs/core");
|
|
190
200
|
init_errors();
|
|
191
201
|
|
|
192
|
-
// src/multi-tab/shared-worker-bridge.ts
|
|
193
|
-
init_errors();
|
|
194
|
-
var SW_REQUEST = "kora-sw-request";
|
|
195
|
-
var SW_RESPONSE = "kora-sw-response";
|
|
196
|
-
var SharedWorkerClientBridge = class {
|
|
197
|
-
constructor(sharedWorker, dbName, workerUrl, timeoutMs = 3e4) {
|
|
198
|
-
this.dbName = dbName;
|
|
199
|
-
this.workerUrl = workerUrl;
|
|
200
|
-
this.timeoutMs = timeoutMs;
|
|
201
|
-
this.port = sharedWorker.port;
|
|
202
|
-
this.port.start();
|
|
203
|
-
this.port.addEventListener("message", (event) => {
|
|
204
|
-
const data = event.data;
|
|
205
|
-
if (data?.type !== SW_RESPONSE) {
|
|
206
|
-
return;
|
|
207
|
-
}
|
|
208
|
-
const entry = this.pending.get(data.requestId);
|
|
209
|
-
if (entry) {
|
|
210
|
-
this.pending.delete(data.requestId);
|
|
211
|
-
entry.resolve(data.response);
|
|
212
|
-
}
|
|
213
|
-
});
|
|
214
|
-
}
|
|
215
|
-
dbName;
|
|
216
|
-
workerUrl;
|
|
217
|
-
port;
|
|
218
|
-
pending = /* @__PURE__ */ new Map();
|
|
219
|
-
timeoutMs;
|
|
220
|
-
terminated = false;
|
|
221
|
-
async send(request) {
|
|
222
|
-
if (this.terminated) {
|
|
223
|
-
return {
|
|
224
|
-
id: request.id,
|
|
225
|
-
type: "error",
|
|
226
|
-
message: "SharedWorker bridge terminated",
|
|
227
|
-
code: "BRIDGE_TERMINATED"
|
|
228
|
-
};
|
|
229
|
-
}
|
|
230
|
-
const requestId = crypto.randomUUID();
|
|
231
|
-
const msg = {
|
|
232
|
-
type: SW_REQUEST,
|
|
233
|
-
requestId,
|
|
234
|
-
dbName: this.dbName,
|
|
235
|
-
workerUrl: this.workerUrl,
|
|
236
|
-
request
|
|
237
|
-
};
|
|
238
|
-
return new Promise((resolve, reject) => {
|
|
239
|
-
const timer = setTimeout(() => {
|
|
240
|
-
this.pending.delete(requestId);
|
|
241
|
-
reject(new WorkerTimeoutError(`shared-worker:${request.type}`, this.timeoutMs));
|
|
242
|
-
}, this.timeoutMs);
|
|
243
|
-
this.pending.set(requestId, {
|
|
244
|
-
resolve: (response) => {
|
|
245
|
-
clearTimeout(timer);
|
|
246
|
-
resolve(response);
|
|
247
|
-
},
|
|
248
|
-
reject: (error) => {
|
|
249
|
-
clearTimeout(timer);
|
|
250
|
-
reject(error);
|
|
251
|
-
}
|
|
252
|
-
});
|
|
253
|
-
this.port.postMessage(msg);
|
|
254
|
-
});
|
|
255
|
-
}
|
|
256
|
-
terminate() {
|
|
257
|
-
if (this.terminated) {
|
|
258
|
-
return;
|
|
259
|
-
}
|
|
260
|
-
this.terminated = true;
|
|
261
|
-
this.port.close();
|
|
262
|
-
for (const [, entry] of this.pending) {
|
|
263
|
-
entry.reject(new Error("SharedWorker bridge terminated"));
|
|
264
|
-
}
|
|
265
|
-
this.pending.clear();
|
|
266
|
-
}
|
|
267
|
-
};
|
|
268
|
-
|
|
269
202
|
// src/multi-tab/tab-storage.ts
|
|
270
203
|
init_errors();
|
|
271
204
|
var RPC_REQUEST = "kora-worker-request";
|
|
272
205
|
var RPC_RESPONSE = "kora-worker-response";
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
206
|
+
var CLIENT_LEAVE = "kora-client-leave";
|
|
207
|
+
var LEADER_PING = "kora-leader-ping";
|
|
208
|
+
var LEADER_PONG = "kora-leader-pong";
|
|
209
|
+
var DEFAULT_LIVENESS_PROBE_MS = 2e3;
|
|
210
|
+
var DEFAULT_TRANSACTION_IDLE_TIMEOUT_MS = 1e4;
|
|
276
211
|
function isMultiTabStorageSupported() {
|
|
277
212
|
return typeof globalThis !== "undefined" && typeof BroadcastChannel !== "undefined" && typeof navigator !== "undefined" && typeof navigator.locks?.request === "function";
|
|
278
213
|
}
|
|
279
|
-
async function acquireTabStorageSession(dbName) {
|
|
214
|
+
async function acquireTabStorageSession(dbName, options) {
|
|
280
215
|
const channelName = `kora-storage-${dbName}`;
|
|
216
|
+
const lockName = `kora-leader-${dbName}`;
|
|
281
217
|
if (!isMultiTabStorageSupported()) {
|
|
282
218
|
return { role: "leader", channelName };
|
|
283
219
|
}
|
|
284
220
|
return new Promise((resolve) => {
|
|
285
221
|
let releaseHeld;
|
|
286
|
-
void navigator.locks.request(
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
if (lock === null) {
|
|
291
|
-
resolve({ role: "follower", channelName });
|
|
292
|
-
return;
|
|
293
|
-
}
|
|
294
|
-
resolve({
|
|
295
|
-
role: "leader",
|
|
296
|
-
channelName,
|
|
297
|
-
releaseLock: async () => {
|
|
298
|
-
releaseHeld?.();
|
|
299
|
-
}
|
|
300
|
-
});
|
|
301
|
-
return new Promise((release) => {
|
|
302
|
-
releaseHeld = release;
|
|
303
|
-
});
|
|
222
|
+
void navigator.locks.request(lockName, { mode: "exclusive", ifAvailable: true }, (lock) => {
|
|
223
|
+
if (lock === null) {
|
|
224
|
+
resolve(startFollowerSession(channelName, lockName, options));
|
|
225
|
+
return;
|
|
304
226
|
}
|
|
305
|
-
|
|
227
|
+
resolve({
|
|
228
|
+
role: "leader",
|
|
229
|
+
channelName,
|
|
230
|
+
releaseLock: async () => {
|
|
231
|
+
releaseHeld?.();
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
return new Promise((release) => {
|
|
235
|
+
releaseHeld = release;
|
|
236
|
+
});
|
|
237
|
+
});
|
|
306
238
|
});
|
|
307
239
|
}
|
|
240
|
+
function startFollowerSession(channelName, lockName, options) {
|
|
241
|
+
let promoted = false;
|
|
242
|
+
let releasePromotedLock;
|
|
243
|
+
const abort = new AbortController();
|
|
244
|
+
void navigator.locks.request(lockName, { mode: "exclusive", signal: abort.signal }, () => {
|
|
245
|
+
promoted = true;
|
|
246
|
+
options?.onPromote?.();
|
|
247
|
+
return new Promise((release) => {
|
|
248
|
+
releasePromotedLock = release;
|
|
249
|
+
});
|
|
250
|
+
}).catch(() => {
|
|
251
|
+
});
|
|
252
|
+
return {
|
|
253
|
+
role: "follower",
|
|
254
|
+
channelName,
|
|
255
|
+
cancelPromotionWatch: () => {
|
|
256
|
+
if (promoted) {
|
|
257
|
+
releasePromotedLock?.();
|
|
258
|
+
} else {
|
|
259
|
+
abort.abort();
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
};
|
|
263
|
+
}
|
|
308
264
|
function startLeaderRpcRelay(channelName, bridge) {
|
|
309
265
|
const channel = new BroadcastChannel(channelName);
|
|
310
266
|
const onMessage = (event) => {
|
|
311
267
|
const data = event.data;
|
|
268
|
+
if (data?.type === LEADER_PING) {
|
|
269
|
+
channel.postMessage({ type: LEADER_PONG });
|
|
270
|
+
return;
|
|
271
|
+
}
|
|
272
|
+
if (data?.type === CLIENT_LEAVE) {
|
|
273
|
+
if (hasReclaimClient(bridge)) {
|
|
274
|
+
bridge.reclaimClient(data.clientId, "client-left");
|
|
275
|
+
}
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
312
278
|
if (data?.type !== RPC_REQUEST) {
|
|
313
279
|
return;
|
|
314
280
|
}
|
|
315
|
-
void bridge.send(data.request).then((response) => {
|
|
281
|
+
void bridge.send(data.request, data.clientId).then((response) => {
|
|
316
282
|
const msg = {
|
|
317
283
|
type: RPC_RESPONSE,
|
|
318
284
|
requestId: data.requestId,
|
|
@@ -342,12 +308,21 @@ function startLeaderRpcRelay(channelName, bridge) {
|
|
|
342
308
|
}
|
|
343
309
|
var FollowerBroadcastBridge = class {
|
|
344
310
|
channel;
|
|
311
|
+
clientId = createClientId();
|
|
312
|
+
onPageHide = () => {
|
|
313
|
+
this.leaveLeader();
|
|
314
|
+
};
|
|
345
315
|
pending = /* @__PURE__ */ new Map();
|
|
346
316
|
timeoutMs;
|
|
317
|
+
livenessProbeMs;
|
|
347
318
|
terminated = false;
|
|
348
|
-
constructor(channelName, timeoutMs = 3e4) {
|
|
319
|
+
constructor(channelName, timeoutMs = 3e4, livenessProbeMs = DEFAULT_LIVENESS_PROBE_MS) {
|
|
349
320
|
this.timeoutMs = timeoutMs;
|
|
321
|
+
this.livenessProbeMs = Math.min(livenessProbeMs, timeoutMs);
|
|
350
322
|
this.channel = new BroadcastChannel(channelName);
|
|
323
|
+
if (typeof addEventListener === "function") {
|
|
324
|
+
addEventListener("pagehide", this.onPageHide);
|
|
325
|
+
}
|
|
351
326
|
this.channel.addEventListener("message", (event) => {
|
|
352
327
|
const data = event.data;
|
|
353
328
|
if (data?.type !== RPC_RESPONSE) {
|
|
@@ -360,6 +335,48 @@ var FollowerBroadcastBridge = class {
|
|
|
360
335
|
}
|
|
361
336
|
});
|
|
362
337
|
}
|
|
338
|
+
/**
|
|
339
|
+
* Readiness handshake: resolves `true` as soon as a live leader answers a ping,
|
|
340
|
+
* or `false` if none answers within the budget. The adapter calls this before
|
|
341
|
+
* its first RPC so a follower created before a leader relay is live retries the
|
|
342
|
+
* handshake instead of firing into the void and waiting out the full timeout.
|
|
343
|
+
*/
|
|
344
|
+
async waitForLeader(timeoutMs = 3e3, attempts = 3) {
|
|
345
|
+
const perAttempt = Math.max(50, Math.floor(timeoutMs / Math.max(1, attempts)));
|
|
346
|
+
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
|
347
|
+
if (this.terminated) {
|
|
348
|
+
return false;
|
|
349
|
+
}
|
|
350
|
+
if (await this.pingLeader(perAttempt)) {
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
return false;
|
|
355
|
+
}
|
|
356
|
+
/** Sends one liveness ping and resolves whether a leader answered in time. */
|
|
357
|
+
pingLeader(timeoutMs) {
|
|
358
|
+
if (this.terminated) {
|
|
359
|
+
return Promise.resolve(false);
|
|
360
|
+
}
|
|
361
|
+
return new Promise((resolve) => {
|
|
362
|
+
const onPong = (event) => {
|
|
363
|
+
if (event.data?.type === LEADER_PONG) {
|
|
364
|
+
cleanup();
|
|
365
|
+
resolve(true);
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
const cleanup = () => {
|
|
369
|
+
clearTimeout(timer);
|
|
370
|
+
this.channel.removeEventListener("message", onPong);
|
|
371
|
+
};
|
|
372
|
+
const timer = setTimeout(() => {
|
|
373
|
+
cleanup();
|
|
374
|
+
resolve(false);
|
|
375
|
+
}, timeoutMs);
|
|
376
|
+
this.channel.addEventListener("message", onPong);
|
|
377
|
+
this.channel.postMessage({ type: LEADER_PING });
|
|
378
|
+
});
|
|
379
|
+
}
|
|
363
380
|
async send(request) {
|
|
364
381
|
if (this.terminated) {
|
|
365
382
|
return {
|
|
@@ -369,22 +386,33 @@ var FollowerBroadcastBridge = class {
|
|
|
369
386
|
code: "BRIDGE_TERMINATED"
|
|
370
387
|
};
|
|
371
388
|
}
|
|
372
|
-
const requestId =
|
|
373
|
-
const msg = {
|
|
389
|
+
const requestId = createClientId();
|
|
390
|
+
const msg = {
|
|
391
|
+
type: RPC_REQUEST,
|
|
392
|
+
requestId,
|
|
393
|
+
clientId: this.clientId,
|
|
394
|
+
request
|
|
395
|
+
};
|
|
374
396
|
return new Promise((resolve, reject) => {
|
|
375
|
-
const
|
|
397
|
+
const settle = (fn) => {
|
|
398
|
+
clearTimeout(timer);
|
|
399
|
+
clearTimeout(probe);
|
|
376
400
|
this.pending.delete(requestId);
|
|
377
|
-
|
|
401
|
+
fn();
|
|
402
|
+
};
|
|
403
|
+
const timer = setTimeout(() => {
|
|
404
|
+
settle(() => reject(new WorkerTimeoutError(`follower-rpc:${request.type}`, this.timeoutMs)));
|
|
378
405
|
}, this.timeoutMs);
|
|
406
|
+
const probe = setTimeout(() => {
|
|
407
|
+
void this.pingLeader(this.livenessProbeMs).then((alive) => {
|
|
408
|
+
if (!alive && this.pending.has(requestId)) {
|
|
409
|
+
settle(() => reject(new NoLeaderError(`follower-rpc:${request.type}`)));
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
}, this.livenessProbeMs);
|
|
379
413
|
this.pending.set(requestId, {
|
|
380
|
-
resolve: (response) =>
|
|
381
|
-
|
|
382
|
-
resolve(response);
|
|
383
|
-
},
|
|
384
|
-
reject: (error) => {
|
|
385
|
-
clearTimeout(timer);
|
|
386
|
-
reject(error);
|
|
387
|
-
}
|
|
414
|
+
resolve: (response) => settle(() => resolve(response)),
|
|
415
|
+
reject: (error) => settle(() => reject(error))
|
|
388
416
|
});
|
|
389
417
|
this.channel.postMessage(msg);
|
|
390
418
|
});
|
|
@@ -394,16 +422,213 @@ var FollowerBroadcastBridge = class {
|
|
|
394
422
|
return;
|
|
395
423
|
}
|
|
396
424
|
this.terminated = true;
|
|
425
|
+
this.leaveLeader();
|
|
426
|
+
if (typeof removeEventListener === "function") {
|
|
427
|
+
removeEventListener("pagehide", this.onPageHide);
|
|
428
|
+
}
|
|
397
429
|
this.channel.close();
|
|
398
430
|
for (const [, entry] of this.pending) {
|
|
399
431
|
entry.reject(new Error("Follower bridge terminated"));
|
|
400
432
|
}
|
|
401
433
|
this.pending.clear();
|
|
402
434
|
}
|
|
435
|
+
leaveLeader() {
|
|
436
|
+
try {
|
|
437
|
+
const message = {
|
|
438
|
+
type: CLIENT_LEAVE,
|
|
439
|
+
clientId: this.clientId
|
|
440
|
+
};
|
|
441
|
+
this.channel.postMessage(message);
|
|
442
|
+
} catch {
|
|
443
|
+
}
|
|
444
|
+
}
|
|
403
445
|
};
|
|
446
|
+
var TransactionSerializingWorkerBridge = class {
|
|
447
|
+
inner;
|
|
448
|
+
leaderClientId = createClientId();
|
|
449
|
+
transactionIdleTimeoutMs;
|
|
450
|
+
queue = [];
|
|
451
|
+
activeTransactionClient = null;
|
|
452
|
+
abortedClients = /* @__PURE__ */ new Set();
|
|
453
|
+
transactionIdleTimer = null;
|
|
454
|
+
reclaiming = false;
|
|
455
|
+
processing = false;
|
|
456
|
+
terminated = false;
|
|
457
|
+
constructor(inner, transactionIdleTimeoutMs = DEFAULT_TRANSACTION_IDLE_TIMEOUT_MS) {
|
|
458
|
+
this.inner = inner;
|
|
459
|
+
this.transactionIdleTimeoutMs = transactionIdleTimeoutMs;
|
|
460
|
+
}
|
|
461
|
+
send(request, clientId = this.leaderClientId) {
|
|
462
|
+
if (this.terminated) {
|
|
463
|
+
return Promise.resolve({
|
|
464
|
+
id: request.id,
|
|
465
|
+
type: "error",
|
|
466
|
+
message: "Worker has been terminated",
|
|
467
|
+
code: "WORKER_TERMINATED"
|
|
468
|
+
});
|
|
469
|
+
}
|
|
470
|
+
if (this.abortedClients.has(clientId)) {
|
|
471
|
+
if (request.type === "begin") {
|
|
472
|
+
this.abortedClients.delete(clientId);
|
|
473
|
+
} else {
|
|
474
|
+
return Promise.resolve({
|
|
475
|
+
id: request.id,
|
|
476
|
+
type: "error",
|
|
477
|
+
message: "Previous transaction was aborted because the client stopped sending requests.",
|
|
478
|
+
code: "TRANSACTION_ABORTED"
|
|
479
|
+
});
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return new Promise((resolve, reject) => {
|
|
483
|
+
this.queue.push({ clientId, request, resolve, reject });
|
|
484
|
+
void this.processQueue();
|
|
485
|
+
});
|
|
486
|
+
}
|
|
487
|
+
terminate() {
|
|
488
|
+
if (this.terminated) {
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
this.terminated = true;
|
|
492
|
+
this.clearTransactionIdleTimer();
|
|
493
|
+
this.inner.terminate();
|
|
494
|
+
const pending = this.queue.splice(0);
|
|
495
|
+
for (const entry of pending) {
|
|
496
|
+
entry.reject(new Error("Worker terminated"));
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
reclaimClient(clientId, reason) {
|
|
500
|
+
if (this.terminated) {
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
void this.reclaimClientNow(clientId, reason);
|
|
504
|
+
}
|
|
505
|
+
async processQueue() {
|
|
506
|
+
if (this.processing || this.reclaiming) {
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
this.processing = true;
|
|
510
|
+
try {
|
|
511
|
+
while (!this.terminated) {
|
|
512
|
+
const index = this.nextRunnableIndex();
|
|
513
|
+
if (index === -1) {
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
const [entry] = this.queue.splice(index, 1);
|
|
517
|
+
if (!entry) {
|
|
518
|
+
return;
|
|
519
|
+
}
|
|
520
|
+
try {
|
|
521
|
+
const response = await this.inner.send(entry.request);
|
|
522
|
+
this.recordTransactionState(entry, response);
|
|
523
|
+
entry.resolve(response);
|
|
524
|
+
} catch (error) {
|
|
525
|
+
this.resetFailedTransaction(entry);
|
|
526
|
+
entry.reject(error instanceof Error ? error : new Error(String(error)));
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
} finally {
|
|
530
|
+
this.processing = false;
|
|
531
|
+
if (!this.terminated && !this.reclaiming && this.nextRunnableIndex() !== -1) {
|
|
532
|
+
void this.processQueue();
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
nextRunnableIndex() {
|
|
537
|
+
if (this.queue.length === 0) {
|
|
538
|
+
return -1;
|
|
539
|
+
}
|
|
540
|
+
if (this.activeTransactionClient === null) {
|
|
541
|
+
return 0;
|
|
542
|
+
}
|
|
543
|
+
return this.queue.findIndex((entry) => entry.clientId === this.activeTransactionClient);
|
|
544
|
+
}
|
|
545
|
+
recordTransactionState(entry, response) {
|
|
546
|
+
if (response.type === "error") {
|
|
547
|
+
this.resetFailedTransaction(entry);
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
if (entry.request.type === "begin") {
|
|
551
|
+
this.activeTransactionClient = entry.clientId;
|
|
552
|
+
this.armTransactionIdleTimer(entry.clientId);
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
if (this.activeTransactionClient === entry.clientId) {
|
|
556
|
+
this.armTransactionIdleTimer(entry.clientId);
|
|
557
|
+
}
|
|
558
|
+
if (this.activeTransactionClient === entry.clientId && (entry.request.type === "commit" || entry.request.type === "rollback")) {
|
|
559
|
+
this.activeTransactionClient = null;
|
|
560
|
+
this.clearTransactionIdleTimer();
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
resetFailedTransaction(entry) {
|
|
564
|
+
if (this.activeTransactionClient === entry.clientId) {
|
|
565
|
+
this.activeTransactionClient = null;
|
|
566
|
+
this.clearTransactionIdleTimer();
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
armTransactionIdleTimer(clientId) {
|
|
570
|
+
this.clearTransactionIdleTimer();
|
|
571
|
+
this.transactionIdleTimer = setTimeout(() => {
|
|
572
|
+
void this.reclaimClientNow(clientId, "transaction-idle-timeout");
|
|
573
|
+
}, this.transactionIdleTimeoutMs);
|
|
574
|
+
}
|
|
575
|
+
clearTransactionIdleTimer() {
|
|
576
|
+
if (this.transactionIdleTimer) {
|
|
577
|
+
clearTimeout(this.transactionIdleTimer);
|
|
578
|
+
this.transactionIdleTimer = null;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
async reclaimClientNow(clientId, reason) {
|
|
582
|
+
if (this.reclaiming || this.activeTransactionClient !== clientId) {
|
|
583
|
+
return;
|
|
584
|
+
}
|
|
585
|
+
this.reclaiming = true;
|
|
586
|
+
this.clearTransactionIdleTimer();
|
|
587
|
+
this.activeTransactionClient = null;
|
|
588
|
+
this.abortedClients.add(clientId);
|
|
589
|
+
this.rejectQueuedClientRequests(clientId, reason);
|
|
590
|
+
try {
|
|
591
|
+
const response = await this.inner.send({ id: 0, type: "rollback" });
|
|
592
|
+
if (response.type === "error") {
|
|
593
|
+
}
|
|
594
|
+
} catch {
|
|
595
|
+
} finally {
|
|
596
|
+
this.reclaiming = false;
|
|
597
|
+
if (!this.terminated && this.nextRunnableIndex() !== -1) {
|
|
598
|
+
void this.processQueue();
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
rejectQueuedClientRequests(clientId, reason) {
|
|
603
|
+
const keep = [];
|
|
604
|
+
for (const entry of this.queue) {
|
|
605
|
+
if (entry.clientId === clientId) {
|
|
606
|
+
entry.resolve({
|
|
607
|
+
id: entry.request.id,
|
|
608
|
+
type: "error",
|
|
609
|
+
message: `Transaction client was reclaimed: ${reason}`,
|
|
610
|
+
code: "TRANSACTION_ABORTED"
|
|
611
|
+
});
|
|
612
|
+
} else {
|
|
613
|
+
keep.push(entry);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
this.queue = keep;
|
|
617
|
+
}
|
|
618
|
+
};
|
|
619
|
+
function hasReclaimClient(bridge) {
|
|
620
|
+
return typeof bridge.reclaimClient === "function";
|
|
621
|
+
}
|
|
622
|
+
function createClientId() {
|
|
623
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") {
|
|
624
|
+
return crypto.randomUUID();
|
|
625
|
+
}
|
|
626
|
+
return `${Date.now()}-${Math.random().toString(36).slice(2)}`;
|
|
627
|
+
}
|
|
404
628
|
|
|
405
629
|
// src/adapters/sqlite-wasm-adapter.ts
|
|
406
630
|
init_sqlite_wasm_channel();
|
|
631
|
+
var warnedSharedWorkerDeprecated = false;
|
|
407
632
|
var SqliteWasmAdapter = class {
|
|
408
633
|
bridge = null;
|
|
409
634
|
opened = false;
|
|
@@ -413,43 +638,76 @@ var SqliteWasmAdapter = class {
|
|
|
413
638
|
sharedWorkerUrl;
|
|
414
639
|
workerResponseTimeoutMs;
|
|
415
640
|
dbName;
|
|
641
|
+
emitter;
|
|
416
642
|
tabSession = null;
|
|
417
|
-
|
|
643
|
+
emitNonPersistentDiagnostic;
|
|
644
|
+
storageOpenState = null;
|
|
645
|
+
/** Retained so a follower promoted to leader can re-open its own worker. */
|
|
646
|
+
schema = null;
|
|
647
|
+
promoting = false;
|
|
418
648
|
constructor(options = {}) {
|
|
419
649
|
this.injectedBridge = options.bridge;
|
|
420
650
|
this.workerUrl = options.workerUrl;
|
|
421
651
|
this.sharedWorkerUrl = options.sharedWorkerUrl;
|
|
422
652
|
this.workerResponseTimeoutMs = options.workerResponseTimeoutMs ?? 3e4;
|
|
423
653
|
this.dbName = options.dbName ?? "kora-db";
|
|
654
|
+
this.emitter = options.emitter;
|
|
655
|
+
this.emitNonPersistentDiagnostic = options.emitNonPersistentDiagnostic ?? true;
|
|
424
656
|
}
|
|
425
657
|
async open(schema) {
|
|
426
658
|
if (this.opened) return;
|
|
659
|
+
const ddlStatements = (0, import_core2.generateFullDDL)(schema);
|
|
427
660
|
if (this.injectedBridge) {
|
|
428
661
|
this.bridge = this.injectedBridge;
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
this.
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
662
|
+
const response = await this.openCurrentBridge(ddlStatements);
|
|
663
|
+
this.reportStorageMode(response.data);
|
|
664
|
+
this.opened = true;
|
|
665
|
+
return;
|
|
666
|
+
}
|
|
667
|
+
if (this.workerUrl) {
|
|
668
|
+
this.warnSharedWorkerDeprecated();
|
|
669
|
+
await this.openDurableWorkerBridge(schema);
|
|
670
|
+
const response = await this.openCurrentBridge(ddlStatements);
|
|
671
|
+
this.reportStorageMode(response.data);
|
|
672
|
+
this.opened = true;
|
|
673
|
+
return;
|
|
674
|
+
}
|
|
675
|
+
throw new AdapterError(
|
|
676
|
+
'SqliteWasmAdapter requires either a bridge (for testing) or a workerUrl (for browsers). Pass { bridge: new MockWorkerBridge() } for tests, or { workerUrl: "/worker.js" } for browsers.'
|
|
677
|
+
);
|
|
678
|
+
}
|
|
679
|
+
async openDurableWorkerBridge(schema) {
|
|
680
|
+
if (!this.workerUrl) {
|
|
681
|
+
throw new AdapterError("Durable SQLite WASM storage requires workerUrl.");
|
|
682
|
+
}
|
|
683
|
+
this.schema = schema;
|
|
684
|
+
this.tabSession = await acquireTabStorageSession(this.dbName, {
|
|
685
|
+
onPromote: () => {
|
|
686
|
+
void this.promoteToLeader();
|
|
446
687
|
}
|
|
447
|
-
}
|
|
448
|
-
|
|
449
|
-
|
|
688
|
+
});
|
|
689
|
+
const { WebWorkerBridge: WebWorkerBridge2 } = await Promise.resolve().then(() => (init_sqlite_wasm_channel(), sqlite_wasm_channel_exports));
|
|
690
|
+
if (this.tabSession.role === "leader") {
|
|
691
|
+
const workerBridge = new TransactionSerializingWorkerBridge(
|
|
692
|
+
new WebWorkerBridge2(this.workerUrl, this.workerResponseTimeoutMs)
|
|
450
693
|
);
|
|
694
|
+
this.tabSession.stopRelay = startLeaderRpcRelay(this.tabSession.channelName, workerBridge);
|
|
695
|
+
this.bridge = workerBridge;
|
|
696
|
+
return;
|
|
451
697
|
}
|
|
452
|
-
const
|
|
698
|
+
const followerBridge = new FollowerBroadcastBridge(
|
|
699
|
+
this.tabSession.channelName,
|
|
700
|
+
this.workerResponseTimeoutMs
|
|
701
|
+
);
|
|
702
|
+
this.bridge = followerBridge;
|
|
703
|
+
this.emitter?.emit({
|
|
704
|
+
type: "store:db-name-collision",
|
|
705
|
+
dbName: this.dbName,
|
|
706
|
+
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.`
|
|
707
|
+
});
|
|
708
|
+
await followerBridge.waitForLeader();
|
|
709
|
+
}
|
|
710
|
+
async openCurrentBridge(ddlStatements) {
|
|
453
711
|
const response = await this.sendRequest({
|
|
454
712
|
id: 0,
|
|
455
713
|
type: "open",
|
|
@@ -462,7 +720,40 @@ var SqliteWasmAdapter = class {
|
|
|
462
720
|
dbName: this.dbName
|
|
463
721
|
});
|
|
464
722
|
}
|
|
465
|
-
|
|
723
|
+
return response;
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Surface a `store:opfs-unavailable` diagnostic when the worker reported that
|
|
727
|
+
* persistence silently degraded to an in-memory database, so a data-loss
|
|
728
|
+
* condition is observable instead of failing silently. A persistent open (or a
|
|
729
|
+
* bridge that does not report a mode, e.g. the Node mock) emits nothing.
|
|
730
|
+
*/
|
|
731
|
+
reportStorageMode(data) {
|
|
732
|
+
this.storageOpenState = parseStorageOpenState(data);
|
|
733
|
+
if (!this.emitNonPersistentDiagnostic || !this.emitter || !this.storageOpenState) {
|
|
734
|
+
return;
|
|
735
|
+
}
|
|
736
|
+
if (this.storageOpenState.persistent === false) {
|
|
737
|
+
const reason = this.storageOpenState.fallbackReason ?? "unsupported";
|
|
738
|
+
this.emitter.emit({
|
|
739
|
+
type: "store:opfs-unavailable",
|
|
740
|
+
dbName: this.dbName,
|
|
741
|
+
reason,
|
|
742
|
+
message: `OPFS persistence is unavailable (${reason}) for database "${this.dbName}"; the store is running in memory and data will not survive a reload.`
|
|
743
|
+
});
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
getStorageOpenState() {
|
|
747
|
+
return this.storageOpenState;
|
|
748
|
+
}
|
|
749
|
+
warnSharedWorkerDeprecated() {
|
|
750
|
+
if (!this.sharedWorkerUrl || warnedSharedWorkerDeprecated) {
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
warnedSharedWorkerDeprecated = true;
|
|
754
|
+
console.warn(
|
|
755
|
+
"[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."
|
|
756
|
+
);
|
|
466
757
|
}
|
|
467
758
|
async close() {
|
|
468
759
|
if (!this.bridge) return;
|
|
@@ -470,16 +761,42 @@ var SqliteWasmAdapter = class {
|
|
|
470
761
|
await this.sendRequest({ id: 0, type: "close" });
|
|
471
762
|
} finally {
|
|
472
763
|
this.tabSession?.stopRelay?.();
|
|
764
|
+
this.tabSession?.cancelPromotionWatch?.();
|
|
473
765
|
if (this.tabSession?.releaseLock) {
|
|
474
766
|
await this.tabSession.releaseLock();
|
|
475
767
|
}
|
|
476
768
|
this.tabSession = null;
|
|
477
|
-
this.sharedWorker = null;
|
|
478
769
|
this.bridge.terminate();
|
|
479
770
|
this.bridge = null;
|
|
480
771
|
this.opened = false;
|
|
481
772
|
}
|
|
482
773
|
}
|
|
774
|
+
/**
|
|
775
|
+
* Promotes a follower to leader after the previous leader released the storage
|
|
776
|
+
* lock (its tab closed or crashed). The lock is only granted once the old leader
|
|
777
|
+
* is gone, so opening our own worker here is safe from the OPFS single-writer
|
|
778
|
+
* rule. In-flight follower RPCs are rejected and left to the caller / reactive
|
|
779
|
+
* layer to retry against the new leader.
|
|
780
|
+
*/
|
|
781
|
+
async promoteToLeader() {
|
|
782
|
+
if (this.promoting || !this.opened || this.workerUrl === void 0 || this.schema === null) {
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
this.promoting = true;
|
|
786
|
+
const { WebWorkerBridge: WebWorkerBridge2 } = await Promise.resolve().then(() => (init_sqlite_wasm_channel(), sqlite_wasm_channel_exports));
|
|
787
|
+
const workerBridge = new TransactionSerializingWorkerBridge(
|
|
788
|
+
new WebWorkerBridge2(this.workerUrl, this.workerResponseTimeoutMs)
|
|
789
|
+
);
|
|
790
|
+
const previousBridge = this.bridge;
|
|
791
|
+
this.bridge = workerBridge;
|
|
792
|
+
if (this.tabSession) {
|
|
793
|
+
this.tabSession.role = "leader";
|
|
794
|
+
this.tabSession.stopRelay = startLeaderRpcRelay(this.tabSession.channelName, workerBridge);
|
|
795
|
+
}
|
|
796
|
+
previousBridge?.terminate();
|
|
797
|
+
const ddlStatements = (0, import_core2.generateFullDDL)(this.schema);
|
|
798
|
+
await this.sendRequest({ id: 0, type: "open", ddlStatements, dbName: this.dbName });
|
|
799
|
+
}
|
|
483
800
|
async execute(sql, params) {
|
|
484
801
|
this.guardOpen();
|
|
485
802
|
const response = await this.sendRequest({ id: 0, type: "execute", sql, params });
|
|
@@ -567,7 +884,10 @@ var SqliteWasmAdapter = class {
|
|
|
567
884
|
this.guardOpen();
|
|
568
885
|
const response = await this.sendRequest({ id: 0, type: "export" });
|
|
569
886
|
if (response.type === "error") {
|
|
570
|
-
throw new AdapterError(`Export failed: ${response.message}
|
|
887
|
+
throw new AdapterError(`Export failed: ${response.message}`, {
|
|
888
|
+
code: response.code,
|
|
889
|
+
context: response.context
|
|
890
|
+
});
|
|
571
891
|
}
|
|
572
892
|
return response.data;
|
|
573
893
|
}
|
|
@@ -600,6 +920,20 @@ var SqliteWasmAdapter = class {
|
|
|
600
920
|
}
|
|
601
921
|
}
|
|
602
922
|
};
|
|
923
|
+
function parseStorageOpenState(data) {
|
|
924
|
+
if (typeof data !== "object" || data === null) {
|
|
925
|
+
return null;
|
|
926
|
+
}
|
|
927
|
+
const mode = data;
|
|
928
|
+
if (typeof mode.persistent !== "boolean") {
|
|
929
|
+
return null;
|
|
930
|
+
}
|
|
931
|
+
return {
|
|
932
|
+
persistent: mode.persistent,
|
|
933
|
+
mode: mode.persistent ? "opfs" : "memory",
|
|
934
|
+
...mode.fallbackReason ? { fallbackReason: mode.fallbackReason } : {}
|
|
935
|
+
};
|
|
936
|
+
}
|
|
603
937
|
|
|
604
938
|
// src/adapters/sqlite-wasm.ts
|
|
605
939
|
init_sqlite_wasm_channel();
|