@carlonicora/nextjs-jsonapi 6.12.2 → 6.13.1
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/{ApiData-DPKNfY-9.d.mts → ApiData-Be8yxCOX.d.mts} +2 -0
- package/dist/{ApiData-DPKNfY-9.d.ts → ApiData-Be8yxCOX.d.ts} +2 -0
- package/dist/{BlockNoteEditor-7M5QW2YQ.js → BlockNoteEditor-UBVEUFNR.js} +34 -22
- package/dist/BlockNoteEditor-UBVEUFNR.js.map +1 -0
- package/dist/{BlockNoteEditor-75ED2CN2.mjs → BlockNoteEditor-VXMDSQAA.mjs} +20 -8
- package/dist/BlockNoteEditor-VXMDSQAA.mjs.map +1 -0
- package/dist/{BlockNoteViewer-WGCDB7IJ.js → BlockNoteViewer-2XYASA5Y.js} +8 -5
- package/dist/BlockNoteViewer-2XYASA5Y.js.map +1 -0
- package/dist/{BlockNoteViewer-MTGS6YJV.mjs → BlockNoteViewer-NBW2H6LC.mjs} +7 -4
- package/dist/{BlockNoteViewer-MTGS6YJV.mjs.map → BlockNoteViewer-NBW2H6LC.mjs.map} +1 -1
- package/dist/{JsonApiRequest-A66XK5WT.mjs → JsonApiRequest-F6TMJ42G.mjs} +2 -2
- package/dist/JsonApiRequest-WISJZSDE.js +26 -0
- package/dist/{JsonApiRequest-FZGPLNUF.js.map → JsonApiRequest-WISJZSDE.js.map} +1 -1
- package/dist/billing/index.js +499 -494
- package/dist/billing/index.js.map +1 -1
- package/dist/billing/index.mjs +13 -8
- package/dist/billing/index.mjs.map +1 -1
- package/dist/chunk-27LQDGNP.mjs +317 -0
- package/dist/chunk-27LQDGNP.mjs.map +1 -0
- package/dist/{chunk-KSYQEKJX.js → chunk-2SV4SS6N.js} +14 -14
- package/dist/{chunk-KSYQEKJX.js.map → chunk-2SV4SS6N.js.map} +1 -1
- package/dist/{chunk-4FNGWALA.mjs → chunk-47BBY5KA.mjs} +6 -4
- package/dist/{chunk-4FNGWALA.mjs.map → chunk-47BBY5KA.mjs.map} +1 -1
- package/dist/chunk-6JUCIWDC.mjs +570 -0
- package/dist/chunk-6JUCIWDC.mjs.map +1 -0
- package/dist/chunk-7DBJTNSU.mjs +1049 -0
- package/dist/chunk-7DBJTNSU.mjs.map +1 -0
- package/dist/chunk-7JZHPMGW.mjs +704 -0
- package/dist/chunk-7JZHPMGW.mjs.map +1 -0
- package/dist/{chunk-L6PYFQTO.js → chunk-7ZHTPFFT.js} +16 -14
- package/dist/chunk-7ZHTPFFT.js.map +1 -0
- package/dist/chunk-B6JGKDP2.js +214 -0
- package/dist/chunk-B6JGKDP2.js.map +1 -0
- package/dist/{chunk-3YW35YHQ.mjs → chunk-BH6JWIKI.mjs} +5 -5
- package/dist/chunk-BZ5J2FIV.js +570 -0
- package/dist/chunk-BZ5J2FIV.js.map +1 -0
- package/dist/{chunk-V5MI7RNB.mjs → chunk-DGWOVYEC.mjs} +2 -2
- package/dist/chunk-DOCIFE6A.mjs +212 -0
- package/dist/chunk-DOCIFE6A.mjs.map +1 -0
- package/dist/chunk-GB6DBFYY.js +25 -0
- package/dist/chunk-GB6DBFYY.js.map +1 -0
- package/dist/{chunk-EWCY6QJA.mjs → chunk-JJMMT24D.mjs} +37 -3
- package/dist/chunk-JJMMT24D.mjs.map +1 -0
- package/dist/{chunk-JTPRJNT2.js → chunk-KMW7UNL2.js} +38 -3
- package/dist/chunk-KMW7UNL2.js.map +1 -0
- package/dist/chunk-MHB36MYE.js +317 -0
- package/dist/chunk-MHB36MYE.js.map +1 -0
- package/dist/{chunk-FF4EIQ6H.js → chunk-MOGEGWXW.js} +1039 -1680
- package/dist/chunk-MOGEGWXW.js.map +1 -0
- package/dist/{chunk-RXQYEEKZ.mjs → chunk-N4RZZKL5.mjs} +58 -25
- package/dist/chunk-N4RZZKL5.mjs.map +1 -0
- package/dist/{chunk-AA52ORW2.mjs → chunk-P445QGOC.mjs} +61 -702
- package/dist/chunk-P445QGOC.mjs.map +1 -0
- package/dist/chunk-PV4Z5W6V.js +212 -0
- package/dist/chunk-PV4Z5W6V.js.map +1 -0
- package/dist/chunk-PXDUJ47N.js +1049 -0
- package/dist/chunk-PXDUJ47N.js.map +1 -0
- package/dist/{chunk-V3YDVJ63.js → chunk-PYRZCKC2.js} +1209 -1176
- package/dist/chunk-PYRZCKC2.js.map +1 -0
- package/dist/{chunk-GHE4VT2R.js → chunk-QKNGZDSQ.js} +2 -2
- package/dist/{chunk-GHE4VT2R.js.map → chunk-QKNGZDSQ.js.map} +1 -1
- package/dist/chunk-W62MHDWM.js +704 -0
- package/dist/chunk-W62MHDWM.js.map +1 -0
- package/dist/chunk-XXP3TQHF.mjs +25 -0
- package/dist/chunk-XXP3TQHF.mjs.map +1 -0
- package/dist/chunk-YRKIFYOE.mjs +214 -0
- package/dist/chunk-YRKIFYOE.mjs.map +1 -0
- package/dist/{chunk-O3PDL2CV.js → chunk-Z4POQWFG.js} +40 -6
- package/dist/chunk-Z4POQWFG.js.map +1 -0
- package/dist/{chunk-PI56A4DG.mjs → chunk-ZLTOUZN2.mjs} +37 -2
- package/dist/chunk-ZLTOUZN2.mjs.map +1 -0
- package/dist/client/index.d.mts +2 -2
- package/dist/client/index.d.ts +2 -2
- package/dist/client/index.js +14 -9
- package/dist/client/index.js.map +1 -1
- package/dist/client/index.mjs +24 -19
- package/dist/components/index.d.mts +7 -1
- package/dist/components/index.d.ts +7 -1
- package/dist/components/index.js +11 -7
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +10 -6
- package/dist/contexts/index.d.mts +1 -1
- package/dist/contexts/index.d.ts +1 -1
- package/dist/contexts/index.js +11 -7
- package/dist/contexts/index.js.map +1 -1
- package/dist/contexts/index.mjs +10 -6
- package/dist/core/index.d.mts +128 -2
- package/dist/core/index.d.ts +128 -2
- package/dist/core/index.js +17 -4
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +22 -9
- package/dist/email-automations/index.js +101 -96
- package/dist/email-automations/index.js.map +1 -1
- package/dist/email-automations/index.mjs +13 -8
- package/dist/email-automations/index.mjs.map +1 -1
- package/dist/features/analytics/index.js +128 -123
- package/dist/features/analytics/index.js.map +1 -1
- package/dist/features/analytics/index.mjs +13 -8
- package/dist/features/analytics/index.mjs.map +1 -1
- package/dist/features/analytics/server.js +11 -8
- package/dist/features/analytics/server.js.map +1 -1
- package/dist/features/analytics/server.mjs +9 -6
- package/dist/features/analytics/server.mjs.map +1 -1
- package/dist/features/blog/index.js +158 -153
- package/dist/features/blog/index.js.map +1 -1
- package/dist/features/blog/index.mjs +14 -9
- package/dist/features/blog/index.mjs.map +1 -1
- package/dist/features/blog/server.js +30 -26
- package/dist/features/blog/server.js.map +1 -1
- package/dist/features/blog/server.mjs +10 -6
- package/dist/features/blog/server.mjs.map +1 -1
- package/dist/features/help/index.js +44 -40
- package/dist/features/help/index.js.map +1 -1
- package/dist/features/help/index.mjs +10 -6
- package/dist/features/help/index.mjs.map +1 -1
- package/dist/features/tokenusage/index.js +134 -129
- package/dist/features/tokenusage/index.js.map +1 -1
- package/dist/features/tokenusage/index.mjs +13 -8
- package/dist/features/tokenusage/index.mjs.map +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +18 -5
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +24 -11
- package/dist/lifecycle-4GUOTB46.js +23 -0
- package/dist/lifecycle-4GUOTB46.js.map +1 -0
- package/dist/lifecycle-LKGPRBS7.mjs +23 -0
- package/dist/local-transport-7H3SPZSA.js +15 -0
- package/dist/local-transport-7H3SPZSA.js.map +1 -0
- package/dist/local-transport-QYFKA6AN.mjs +15 -0
- package/dist/local-transport-QYFKA6AN.mjs.map +1 -0
- package/dist/offline/index.d.mts +607 -0
- package/dist/offline/index.d.ts +607 -0
- package/dist/offline/index.js +1052 -0
- package/dist/offline/index.js.map +1 -0
- package/dist/offline/index.mjs +1052 -0
- package/dist/offline/index.mjs.map +1 -0
- package/dist/offline/sqlite/device-worker.mjs +62 -0
- package/dist/offline/sqlite/index.mjs +17392 -0
- package/dist/offline/sqlite/sqlite3-opfs-async-proxy.js +881 -0
- package/dist/offline/sqlite/sqlite3.wasm +0 -0
- package/dist/request-KTBV6HHH.js +10 -0
- package/dist/request-KTBV6HHH.js.map +1 -0
- package/dist/request-SQOUNWZ4.mjs +10 -0
- package/dist/request-SQOUNWZ4.mjs.map +1 -0
- package/dist/server/cachedGet.d.mts +1 -1
- package/dist/server/cachedGet.d.ts +1 -1
- package/dist/server/cachedGet.stub.d.mts +1 -1
- package/dist/server/cachedGet.stub.d.ts +1 -1
- package/dist/server/index.d.mts +28 -2
- package/dist/server/index.d.ts +28 -2
- package/dist/server/index.js +18 -8
- package/dist/server/index.js.map +1 -1
- package/dist/server/index.mjs +18 -8
- package/dist/{useSocket-IRHtk2xc.d.ts → useSocket-Bo9M0t0J.d.ts} +3 -1
- package/dist/{useSocket-C8fqU4od.d.mts → useSocket-J5qanQPN.d.mts} +3 -1
- package/dist/write-through-FDQ7N4YI.js +15 -0
- package/dist/write-through-FDQ7N4YI.js.map +1 -0
- package/dist/write-through-R25VQMH6.mjs +15 -0
- package/dist/write-through-R25VQMH6.mjs.map +1 -0
- package/package.json +7 -1
- package/pwa/sw.js +277 -26
- package/src/client/request.ts +37 -0
- package/src/components/editors/BlockNoteEditor.tsx +14 -0
- package/src/components/editors/__tests__/BlockNoteEditor.empty-change.spec.tsx +205 -0
- package/src/contexts/SocketContext.tsx +7 -1
- package/src/contexts/__tests__/SocketContext.session.spec.tsx +52 -5
- package/src/core/factories/RehydrationFactory.ts +28 -8
- package/src/core/factories/__tests__/RehydrationFactory.spec.ts +33 -0
- package/src/core/index.ts +3 -0
- package/src/core/interfaces/ApiData.ts +2 -0
- package/src/core/registry/ModuleRegistry.ts +3 -0
- package/src/features/auth/components/forms/Logout.tsx +8 -1
- package/src/features/auth/components/forms/__tests__/Logout.spec.tsx +62 -0
- package/src/features/onboarding/contexts/OnboardingContext.spec.tsx +42 -1
- package/src/features/onboarding/contexts/OnboardingContext.tsx +11 -1
- package/src/hooks/__tests__/useSocket.pause.spec.ts +39 -0
- package/src/hooks/useSocket.ts +20 -1
- package/src/offline/__tests__/answer-builders.spec.ts +176 -0
- package/src/offline/__tests__/device-switch.spec.ts +179 -0
- package/src/offline/__tests__/generic-answers.spec.ts +380 -0
- package/src/offline/__tests__/local-transport.spec.ts +150 -0
- package/src/offline/__tests__/logout.spec.ts +122 -0
- package/src/offline/__tests__/offline-fixtures.ts +89 -0
- package/src/offline/__tests__/router.spec.ts +56 -0
- package/src/offline/__tests__/state.spec.ts +62 -0
- package/src/offline/__tests__/write-through.spec.ts +137 -0
- package/src/offline/config.ts +229 -0
- package/src/offline/configure.ts +14 -0
- package/src/offline/device-switch.ts +75 -0
- package/src/offline/generic-answers.ts +706 -0
- package/src/offline/index.ts +67 -0
- package/src/offline/local-transport.ts +189 -0
- package/src/offline/logout.ts +41 -0
- package/src/offline/router.ts +41 -0
- package/src/offline/server/__tests__/build-offline-manifest.spec.ts +76 -0
- package/src/offline/server/build-offline-manifest.ts +49 -0
- package/src/offline/state-hooks.ts +10 -0
- package/src/offline/state.ts +143 -0
- package/src/offline/store/DeviceStore.ts +112 -0
- package/src/offline/store/__tests__/device-store-lifecycle.spec.ts +343 -0
- package/src/offline/store/__tests__/shared-database.spec.ts +151 -0
- package/src/offline/store/__tests__/sql-device-store.spec.ts +399 -0
- package/src/offline/store/lifecycle.ts +315 -0
- package/src/offline/store/schema.ts +101 -0
- package/src/offline/store/shared-database.ts +321 -0
- package/src/offline/store/sql-device-store.ts +469 -0
- package/src/offline/store/worker-client.ts +71 -0
- package/src/offline/store/worker.ts +87 -0
- package/src/offline/sync/__tests__/conflicts.spec.ts +185 -0
- package/src/offline/sync/__tests__/queue-drain.spec.ts +364 -0
- package/src/offline/sync/__tests__/runtime.spec.ts +337 -0
- package/src/offline/sync/__tests__/sync-fixtures.ts +103 -0
- package/src/offline/sync/__tests__/sync-root.spec.ts +727 -0
- package/src/offline/sync/artwork-cache.ts +67 -0
- package/src/offline/sync/conflicts.ts +211 -0
- package/src/offline/sync/counts.ts +39 -0
- package/src/offline/sync/drain-queue.ts +246 -0
- package/src/offline/sync/known-roots.ts +37 -0
- package/src/offline/sync/models/OfflineChangeSet.ts +34 -0
- package/src/offline/sync/models/OfflineChangeSetInterface.ts +54 -0
- package/src/offline/sync/models/RootSync.ts +68 -0
- package/src/offline/sync/models/RootSyncInterface.ts +20 -0
- package/src/offline/sync/models/__tests__/OfflineChangeSet.spec.ts +47 -0
- package/src/offline/sync/models/__tests__/RootSync.spec.ts +54 -0
- package/src/offline/sync/models/index.ts +5 -0
- package/src/offline/sync/models/offline.modules.ts +14 -0
- package/src/offline/sync/runtime.ts +226 -0
- package/src/offline/sync/session-refresh.ts +32 -0
- package/src/offline/sync/sync-root.ts +336 -0
- package/src/offline/sync/waiting.ts +40 -0
- package/src/offline/types.ts +115 -0
- package/src/offline/write-through.ts +189 -0
- package/src/pwa/__tests__/sw.spec.ts +358 -1
- package/src/server/index.ts +5 -0
- package/src/unified/__tests__/JsonApiRequest.offline.spec.ts +243 -0
- package/dist/BlockNoteEditor-75ED2CN2.mjs.map +0 -1
- package/dist/BlockNoteEditor-7M5QW2YQ.js.map +0 -1
- package/dist/BlockNoteViewer-WGCDB7IJ.js.map +0 -1
- package/dist/JsonApiRequest-FZGPLNUF.js +0 -26
- package/dist/chunk-AA52ORW2.mjs.map +0 -1
- package/dist/chunk-EWCY6QJA.mjs.map +0 -1
- package/dist/chunk-FF4EIQ6H.js.map +0 -1
- package/dist/chunk-JTPRJNT2.js.map +0 -1
- package/dist/chunk-L6PYFQTO.js.map +0 -1
- package/dist/chunk-O3PDL2CV.js.map +0 -1
- package/dist/chunk-PI56A4DG.mjs.map +0 -1
- package/dist/chunk-RXQYEEKZ.mjs.map +0 -1
- package/dist/chunk-V3YDVJ63.js.map +0 -1
- package/dist/request-5RLK2AM3.mjs +0 -9
- package/dist/request-EUGTI3BE.js +0 -9
- package/dist/request-EUGTI3BE.js.map +0 -1
- /package/dist/{JsonApiRequest-A66XK5WT.mjs.map → JsonApiRequest-F6TMJ42G.mjs.map} +0 -0
- /package/dist/{chunk-3YW35YHQ.mjs.map → chunk-BH6JWIKI.mjs.map} +0 -0
- /package/dist/{chunk-V5MI7RNB.mjs.map → chunk-DGWOVYEC.mjs.map} +0 -0
- /package/dist/{request-5RLK2AM3.mjs.map → lifecycle-LKGPRBS7.mjs.map} +0 -0
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import type { ExecFn } from "./sql-device-store";
|
|
2
|
+
import type { OpenedDatabase } from "./worker-client";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* One device copy shared by every page of the origin (tabs, the installed app, and the page that
|
|
6
|
+
* is still unloading while the next one loads in the same tab).
|
|
7
|
+
*
|
|
8
|
+
* The browser lets only one page at a time hold the copy's files (opfs-sahpool: one set of access
|
|
9
|
+
* handles per origin). So one page, the holder, opens the database; the others send their
|
|
10
|
+
* statements to it over a BroadcastChannel. The holder is whoever holds the Web Lock
|
|
11
|
+
* `nja-offline-db:<userId>`; every other page waits in that lock's queue and takes over when the
|
|
12
|
+
* holder closes or goes away, retrying the open while the previous holder's files are released.
|
|
13
|
+
*
|
|
14
|
+
* Each store operation runs with exclusive use of the database across all pages (`exclusive`), so
|
|
15
|
+
* a transaction of one page never interleaves with another page's statements.
|
|
16
|
+
*/
|
|
17
|
+
export type SharedDatabase = OpenedDatabase & {
|
|
18
|
+
/** Runs `fn` with exclusive use of the database across every page. */
|
|
19
|
+
exclusive: <T>(fn: () => Promise<T>) => Promise<T>;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
type Message =
|
|
23
|
+
| { kind: "holder" }
|
|
24
|
+
| { kind: "lease"; id: string }
|
|
25
|
+
| { kind: "granted"; id: string }
|
|
26
|
+
| { kind: "release"; id: string }
|
|
27
|
+
| { kind: "exec"; id: string; lease: string; sql: string; bind?: unknown[] }
|
|
28
|
+
| { kind: "result"; id: string; ok: true; rows: Record<string, unknown>[] }
|
|
29
|
+
| { kind: "result"; id: string; ok: false; error: string }
|
|
30
|
+
| { kind: "shutdown" };
|
|
31
|
+
|
|
32
|
+
/** How long the holder keeps a lease for a page that never releases it (the page closed). */
|
|
33
|
+
const LEASE_TIMEOUT_MS = 30_000;
|
|
34
|
+
/** How long a page waits for an answer before giving up on a statement. */
|
|
35
|
+
const EXEC_TIMEOUT_MS = 30_000;
|
|
36
|
+
/** How long the next holder keeps retrying the open while the previous holder's files are released. */
|
|
37
|
+
const OPEN_RETRY_MS = 10_000;
|
|
38
|
+
|
|
39
|
+
const lockName = (userId: string) => `nja-offline-db:${userId}`;
|
|
40
|
+
const channelName = (userId: string) => `nja-offline-db:${userId}`;
|
|
41
|
+
|
|
42
|
+
const newId = (): string => globalThis.crypto.randomUUID();
|
|
43
|
+
|
|
44
|
+
/** Whether this browser offers what sharing needs; without it the page opens the copy directly. */
|
|
45
|
+
export function canShareDatabase(): boolean {
|
|
46
|
+
return (
|
|
47
|
+
typeof BroadcastChannel !== "undefined" &&
|
|
48
|
+
typeof navigator !== "undefined" &&
|
|
49
|
+
typeof navigator.locks?.request === "function"
|
|
50
|
+
);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The files are still held by the page that had them (it is closing): worth retrying. */
|
|
54
|
+
const isHeldElsewhere = (error: unknown): boolean =>
|
|
55
|
+
/NoModificationAllowedError|Access Handles cannot be created|createSyncAccessHandle/i.test(
|
|
56
|
+
String((error as Error)?.message ?? error),
|
|
57
|
+
);
|
|
58
|
+
|
|
59
|
+
async function openWithRetry(openLocal: () => Promise<OpenedDatabase>): Promise<OpenedDatabase> {
|
|
60
|
+
const deadline = Date.now() + OPEN_RETRY_MS;
|
|
61
|
+
for (let wait = 100; ; wait = Math.min(wait * 2, 1000)) {
|
|
62
|
+
try {
|
|
63
|
+
return await openLocal();
|
|
64
|
+
} catch (error) {
|
|
65
|
+
if (!isHeldElsewhere(error) || Date.now() >= deadline) throw error;
|
|
66
|
+
await new Promise((resolve) => setTimeout(resolve, wait));
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** A promise chain: one `fn` at a time. */
|
|
72
|
+
function createMutex() {
|
|
73
|
+
let tail: Promise<unknown> = Promise.resolve();
|
|
74
|
+
return <T>(fn: () => Promise<T>): Promise<T> => {
|
|
75
|
+
const run = tail.then(fn, fn);
|
|
76
|
+
tail = run.catch(() => undefined);
|
|
77
|
+
return run;
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Opens the shared device copy of `userId`. `openLocal` opens the database files in this page
|
|
83
|
+
* (the worker); it runs only in the page that holds the lock.
|
|
84
|
+
*/
|
|
85
|
+
export async function openSharedDatabase(
|
|
86
|
+
userId: string,
|
|
87
|
+
openLocal: () => Promise<OpenedDatabase>,
|
|
88
|
+
/** Called when another page asks every page to close the copy (logout, offline mode off). */
|
|
89
|
+
onShutdown?: () => void,
|
|
90
|
+
): Promise<SharedDatabase> {
|
|
91
|
+
const channel = new BroadcastChannel(channelName(userId));
|
|
92
|
+
const post = (message: Message) => channel.postMessage(message);
|
|
93
|
+
const mutex = createMutex();
|
|
94
|
+
|
|
95
|
+
let local: OpenedDatabase | undefined;
|
|
96
|
+
let created = false;
|
|
97
|
+
let closed = false;
|
|
98
|
+
let releaseLock: (() => void) | undefined;
|
|
99
|
+
const lockAbort = new AbortController();
|
|
100
|
+
|
|
101
|
+
// Holder side: leases granted to other pages, released by them (or after a timeout).
|
|
102
|
+
const leases = new Map<string, () => void>();
|
|
103
|
+
// Page side: leases asked for and not yet granted, and statements waiting for an answer.
|
|
104
|
+
const waitingLeases = new Map<
|
|
105
|
+
string,
|
|
106
|
+
{ run: () => Promise<unknown>; resolve: (v: unknown) => void; reject: (e: Error) => void }
|
|
107
|
+
>();
|
|
108
|
+
const waitingResults = new Map<
|
|
109
|
+
string,
|
|
110
|
+
{ resolve: (rows: Record<string, unknown>[]) => void; reject: (e: Error) => void }
|
|
111
|
+
>();
|
|
112
|
+
let currentLease: string | undefined;
|
|
113
|
+
|
|
114
|
+
const isHolder = () => local !== undefined;
|
|
115
|
+
|
|
116
|
+
/** Runs a lease granted by the holder: `run` sends its statements under that lease. */
|
|
117
|
+
const runGranted = async (id: string) => {
|
|
118
|
+
const waiting = waitingLeases.get(id);
|
|
119
|
+
if (!waiting) {
|
|
120
|
+
post({ kind: "release", id });
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
waitingLeases.delete(id);
|
|
124
|
+
currentLease = id;
|
|
125
|
+
try {
|
|
126
|
+
waiting.resolve(await waiting.run());
|
|
127
|
+
} catch (error) {
|
|
128
|
+
waiting.reject(error as Error);
|
|
129
|
+
} finally {
|
|
130
|
+
currentLease = undefined;
|
|
131
|
+
post({ kind: "release", id });
|
|
132
|
+
}
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
channel.onmessage = (event: MessageEvent<Message>) => {
|
|
136
|
+
const message = event.data;
|
|
137
|
+
if (closed) return;
|
|
138
|
+
switch (message.kind) {
|
|
139
|
+
case "holder":
|
|
140
|
+
// A new holder: the lease requests the old one never answered are asked again.
|
|
141
|
+
if (!isHolder()) for (const id of waitingLeases.keys()) post({ kind: "lease", id });
|
|
142
|
+
return;
|
|
143
|
+
case "lease":
|
|
144
|
+
if (!isHolder() || leases.has(message.id)) return;
|
|
145
|
+
void mutex(
|
|
146
|
+
() =>
|
|
147
|
+
new Promise<void>((done) => {
|
|
148
|
+
const timer = setTimeout(() => finish(), LEASE_TIMEOUT_MS);
|
|
149
|
+
const finish = () => {
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
leases.delete(message.id);
|
|
152
|
+
done();
|
|
153
|
+
};
|
|
154
|
+
leases.set(message.id, finish);
|
|
155
|
+
post({ kind: "granted", id: message.id });
|
|
156
|
+
}),
|
|
157
|
+
);
|
|
158
|
+
return;
|
|
159
|
+
case "granted":
|
|
160
|
+
if (!isHolder()) void runGranted(message.id);
|
|
161
|
+
return;
|
|
162
|
+
case "release":
|
|
163
|
+
leases.get(message.id)?.();
|
|
164
|
+
return;
|
|
165
|
+
case "exec":
|
|
166
|
+
if (!isHolder() || !leases.has(message.lease)) return;
|
|
167
|
+
local!
|
|
168
|
+
.exec(message.sql, message.bind)
|
|
169
|
+
.then((rows) => post({ kind: "result", id: message.id, ok: true, rows }))
|
|
170
|
+
.catch((error) =>
|
|
171
|
+
post({ kind: "result", id: message.id, ok: false, error: String(error?.message ?? error) }),
|
|
172
|
+
);
|
|
173
|
+
return;
|
|
174
|
+
case "result": {
|
|
175
|
+
const waiting = waitingResults.get(message.id);
|
|
176
|
+
if (!waiting) return;
|
|
177
|
+
waitingResults.delete(message.id);
|
|
178
|
+
if (message.ok) waiting.resolve(message.rows);
|
|
179
|
+
else waiting.reject(new Error(message.error));
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
182
|
+
case "shutdown":
|
|
183
|
+
void close().finally(() => onShutdown?.());
|
|
184
|
+
return;
|
|
185
|
+
}
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
const becomeHolder = async () => {
|
|
189
|
+
local = await openWithRetry(openLocal);
|
|
190
|
+
created = local.created;
|
|
191
|
+
// Leases this page asked for and nobody granted: they run here now.
|
|
192
|
+
for (const [id, waiting] of waitingLeases) {
|
|
193
|
+
waitingLeases.delete(id);
|
|
194
|
+
mutex(waiting.run).then(waiting.resolve, waiting.reject);
|
|
195
|
+
}
|
|
196
|
+
post({ kind: "holder" });
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
/** Holds the lock as the holder until `close`; the lock passes to the next page when this ends. */
|
|
200
|
+
const holdLock = async (): Promise<void> => {
|
|
201
|
+
if (closed) return;
|
|
202
|
+
await becomeHolder();
|
|
203
|
+
await new Promise<void>((resolve) => (releaseLock = resolve));
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
// Free right now: this page is the holder. Held by another page: this page starts as its client
|
|
207
|
+
// and queues for the lock, to take over when the holder leaves.
|
|
208
|
+
let opened: Promise<void> | undefined;
|
|
209
|
+
const isFree = await new Promise<boolean>((answer) => {
|
|
210
|
+
navigator.locks
|
|
211
|
+
.request(lockName(userId), { ifAvailable: true }, async (lock) => {
|
|
212
|
+
if (!lock) {
|
|
213
|
+
answer(false);
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
answer(true);
|
|
217
|
+
opened = becomeHolder();
|
|
218
|
+
try {
|
|
219
|
+
await opened;
|
|
220
|
+
} catch {
|
|
221
|
+
return; // Could not open: the lock is released for the next page.
|
|
222
|
+
}
|
|
223
|
+
await new Promise<void>((resolve) => (releaseLock = resolve));
|
|
224
|
+
})
|
|
225
|
+
.catch(() => answer(false));
|
|
226
|
+
});
|
|
227
|
+
if (isFree) {
|
|
228
|
+
try {
|
|
229
|
+
await opened;
|
|
230
|
+
} catch (error) {
|
|
231
|
+
closed = true;
|
|
232
|
+
channel.close();
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
} else {
|
|
236
|
+
navigator.locks.request(lockName(userId), { signal: lockAbort.signal }, holdLock).catch((error) => {
|
|
237
|
+
if ((error as { name?: string })?.name !== "AbortError")
|
|
238
|
+
console.warn("[offline] taking over the device copy failed", error);
|
|
239
|
+
});
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
const remoteExec: ExecFn = (sql, bind) => {
|
|
243
|
+
if (closed) return Promise.reject(new Error("The device database is closed."));
|
|
244
|
+
const lease = currentLease;
|
|
245
|
+
if (!lease) return exclusive(() => remoteExec(sql, bind));
|
|
246
|
+
const id = newId();
|
|
247
|
+
return new Promise<Record<string, unknown>[]>((resolve, reject) => {
|
|
248
|
+
const timer = setTimeout(() => {
|
|
249
|
+
waitingResults.delete(id);
|
|
250
|
+
reject(new Error("The page holding the device copy did not answer."));
|
|
251
|
+
}, EXEC_TIMEOUT_MS);
|
|
252
|
+
waitingResults.set(id, {
|
|
253
|
+
resolve: (rows) => {
|
|
254
|
+
clearTimeout(timer);
|
|
255
|
+
resolve(rows);
|
|
256
|
+
},
|
|
257
|
+
reject: (error) => {
|
|
258
|
+
clearTimeout(timer);
|
|
259
|
+
reject(error);
|
|
260
|
+
},
|
|
261
|
+
});
|
|
262
|
+
post({ kind: "exec", id, lease, sql, bind });
|
|
263
|
+
});
|
|
264
|
+
};
|
|
265
|
+
|
|
266
|
+
const exclusive = <T>(fn: () => Promise<T>): Promise<T> => {
|
|
267
|
+
if (closed) return Promise.reject(new Error("The device database is closed."));
|
|
268
|
+
if (isHolder()) return mutex(fn);
|
|
269
|
+
const id = newId();
|
|
270
|
+
return new Promise<T>((resolve, reject) => {
|
|
271
|
+
waitingLeases.set(id, {
|
|
272
|
+
run: fn as () => Promise<unknown>,
|
|
273
|
+
resolve: resolve as (v: unknown) => void,
|
|
274
|
+
reject,
|
|
275
|
+
});
|
|
276
|
+
post({ kind: "lease", id });
|
|
277
|
+
});
|
|
278
|
+
};
|
|
279
|
+
|
|
280
|
+
const exec: ExecFn = (sql, bind) => (isHolder() ? local!.exec(sql, bind) : remoteExec(sql, bind));
|
|
281
|
+
|
|
282
|
+
async function close(): Promise<void> {
|
|
283
|
+
if (closed) return;
|
|
284
|
+
closed = true;
|
|
285
|
+
lockAbort.abort();
|
|
286
|
+
const error = new Error("The device database is closed.");
|
|
287
|
+
for (const waiting of waitingLeases.values()) waiting.reject(error);
|
|
288
|
+
for (const waiting of waitingResults.values()) waiting.reject(error);
|
|
289
|
+
waitingLeases.clear();
|
|
290
|
+
waitingResults.clear();
|
|
291
|
+
for (const finish of [...leases.values()]) finish();
|
|
292
|
+
try {
|
|
293
|
+
if (local) await local.close();
|
|
294
|
+
} finally {
|
|
295
|
+
local = undefined;
|
|
296
|
+
releaseLock?.();
|
|
297
|
+
channel.close();
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return {
|
|
302
|
+
get created() {
|
|
303
|
+
return created;
|
|
304
|
+
},
|
|
305
|
+
exec,
|
|
306
|
+
exclusive,
|
|
307
|
+
close,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* Asks every page to close the shared copy of `userId`, then runs `fn` once no page holds it
|
|
313
|
+
* (logout, offline mode turned off: the files can only be deleted when nobody has them open).
|
|
314
|
+
*/
|
|
315
|
+
export async function withDatabaseReleased<T>(userId: string, fn: () => Promise<T>): Promise<T> {
|
|
316
|
+
if (!canShareDatabase()) return fn();
|
|
317
|
+
const channel = new BroadcastChannel(channelName(userId));
|
|
318
|
+
channel.postMessage({ kind: "shutdown" } satisfies Message);
|
|
319
|
+
channel.close();
|
|
320
|
+
return navigator.locks.request(lockName(userId), () => fn()) as Promise<T>;
|
|
321
|
+
}
|