@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,469 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Conflict,
|
|
3
|
+
DeviceStore,
|
|
4
|
+
QueueEntry,
|
|
5
|
+
QueueStatus,
|
|
6
|
+
SkippedRecord,
|
|
7
|
+
StoredLink,
|
|
8
|
+
StoredRecord,
|
|
9
|
+
SyncState,
|
|
10
|
+
} from "./DeviceStore";
|
|
11
|
+
import { DEVICE_STORE_RESET, DEVICE_STORE_SCHEMA, DEVICE_STORE_SCHEMA_VERSION } from "./schema";
|
|
12
|
+
|
|
13
|
+
/** Runs one SQL statement with positional `?` binds and answers its rows as objects. */
|
|
14
|
+
export type ExecFn = (sql: string, bind?: unknown[]) => Promise<Record<string, unknown>[]>;
|
|
15
|
+
|
|
16
|
+
export type SqlDeviceStoreHooks = {
|
|
17
|
+
/** Runs before a queue entry is written (the lifecycle marks the file as holding unsent changes). */
|
|
18
|
+
beforeEnqueue?: () => Promise<void>;
|
|
19
|
+
/** Runs after a committed change that can shrink the queue, with the number of entries left. */
|
|
20
|
+
afterQueueChange?: (entries: number) => Promise<void>;
|
|
21
|
+
/**
|
|
22
|
+
* Runs each operation with exclusive use of the database across pages (the shared device copy:
|
|
23
|
+
* several pages send statements to one database, and a transaction must not interleave).
|
|
24
|
+
*/
|
|
25
|
+
exclusive?: <T>(fn: () => Promise<T>) => Promise<T>;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const toJson = (value: unknown): string | null =>
|
|
29
|
+
value === undefined || value === null ? null : JSON.stringify(value);
|
|
30
|
+
const fromJson = (value: unknown): any => (value === null || value === undefined ? null : JSON.parse(String(value)));
|
|
31
|
+
const toIso = (value: Date | null | undefined): string | null => (value ? value.toISOString() : null);
|
|
32
|
+
const fromIso = (value: unknown): Date | null =>
|
|
33
|
+
value === null || value === undefined ? null : new Date(String(value));
|
|
34
|
+
|
|
35
|
+
const newId = (): string =>
|
|
36
|
+
typeof globalThis.crypto?.randomUUID === "function"
|
|
37
|
+
? globalThis.crypto.randomUUID()
|
|
38
|
+
: `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
39
|
+
|
|
40
|
+
const rowToRecord = (row: Record<string, unknown>): StoredRecord => ({
|
|
41
|
+
type: String(row.type),
|
|
42
|
+
id: String(row.id),
|
|
43
|
+
rootId: String(row.root_id),
|
|
44
|
+
resource: fromJson(row.resource),
|
|
45
|
+
updatedAt: fromIso(row.updated_at),
|
|
46
|
+
base: fromJson(row.base),
|
|
47
|
+
localOnly: Number(row.local_only) === 1,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
const rowToLink = (row: Record<string, unknown>): StoredLink => ({
|
|
51
|
+
fromType: String(row.from_type),
|
|
52
|
+
fromId: String(row.from_id),
|
|
53
|
+
toType: String(row.to_type),
|
|
54
|
+
toId: String(row.to_id),
|
|
55
|
+
kind: String(row.kind),
|
|
56
|
+
props: fromJson(row.props) ?? {},
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const rowToQueueEntry = (row: Record<string, unknown>): QueueEntry => {
|
|
60
|
+
const request = fromJson(row.request) ?? {};
|
|
61
|
+
const entry: QueueEntry = {
|
|
62
|
+
seq: Number(row.seq),
|
|
63
|
+
rootId: String(row.root_id),
|
|
64
|
+
method: request.method,
|
|
65
|
+
path: request.path,
|
|
66
|
+
base: fromJson(row.base),
|
|
67
|
+
createdAt: new Date(String(row.created_at)),
|
|
68
|
+
status: String(row.status) as QueueStatus,
|
|
69
|
+
};
|
|
70
|
+
if (request.body !== undefined) entry.body = request.body;
|
|
71
|
+
if (request.recordType !== undefined) entry.recordType = request.recordType;
|
|
72
|
+
if (request.recordId !== undefined) entry.recordId = request.recordId;
|
|
73
|
+
if (request.resolution !== undefined) entry.resolution = request.resolution;
|
|
74
|
+
if (row.package_id !== null && row.package_id !== undefined) entry.packageId = String(row.package_id);
|
|
75
|
+
return entry;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
const rowToConflict = (row: Record<string, unknown>): Conflict => {
|
|
79
|
+
const conflict: Conflict = {
|
|
80
|
+
id: String(row.id),
|
|
81
|
+
queueSeq: Number(row.queue_seq),
|
|
82
|
+
kind: String(row.kind) as Conflict["kind"],
|
|
83
|
+
mine: fromJson(row.mine),
|
|
84
|
+
theirs: fromJson(row.theirs),
|
|
85
|
+
base: fromJson(row.base),
|
|
86
|
+
fields: fromJson(row.fields) ?? [],
|
|
87
|
+
};
|
|
88
|
+
if (row.record_type !== null && row.record_type !== undefined) conflict.recordType = String(row.record_type);
|
|
89
|
+
if (row.record_id !== null && row.record_id !== undefined) conflict.recordId = String(row.record_id);
|
|
90
|
+
const merged = fromJson(row.merged);
|
|
91
|
+
if (merged !== null) conflict.merged = merged;
|
|
92
|
+
const blocks = fromJson(row.blocks);
|
|
93
|
+
if (blocks !== null) conflict.blocks = blocks;
|
|
94
|
+
if (row.reason !== null && row.reason !== undefined) conflict.reason = String(row.reason);
|
|
95
|
+
return conflict;
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
/** The statements of every store operation, run over an exec that is already serialised. */
|
|
99
|
+
const createOperations = (exec: ExecFn, hooks: SqlDeviceStoreHooks) => {
|
|
100
|
+
const insertLink = async (link: StoredLink, rootId: string | null) => {
|
|
101
|
+
await exec(
|
|
102
|
+
`INSERT OR REPLACE INTO links (from_id, from_type, to_id, to_type, kind, props, root_id) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
|
103
|
+
[link.fromId, link.fromType, link.toId, link.toType, link.kind, JSON.stringify(link.props ?? {}), rootId],
|
|
104
|
+
);
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
const deleteRecordEverywhere = async (type: string, id: string) => {
|
|
108
|
+
await exec(`DELETE FROM records WHERE type = ? AND id = ?`, [type, id]);
|
|
109
|
+
await exec(`DELETE FROM record_roots WHERE type = ? AND id = ?`, [type, id]);
|
|
110
|
+
await exec(`DELETE FROM links WHERE from_id = ? OR to_id = ?`, [id, id]);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
async getRecord(type: string, id: string): Promise<StoredRecord | undefined> {
|
|
115
|
+
const rows = await exec(`SELECT * FROM records WHERE type = ? AND id = ?`, [type, id]);
|
|
116
|
+
return rows[0] ? rowToRecord(rows[0]) : undefined;
|
|
117
|
+
},
|
|
118
|
+
|
|
119
|
+
async listRecords(filter: { type: string; rootId?: string; ids?: string[] }): Promise<StoredRecord[]> {
|
|
120
|
+
if (filter.ids && filter.ids.length === 0) return [];
|
|
121
|
+
const where = ["type = ?"];
|
|
122
|
+
const bind: unknown[] = [filter.type];
|
|
123
|
+
if (filter.rootId !== undefined) {
|
|
124
|
+
where.push(
|
|
125
|
+
"EXISTS (SELECT 1 FROM record_roots rc WHERE rc.type = records.type AND rc.id = records.id AND rc.root_id = ?)",
|
|
126
|
+
);
|
|
127
|
+
bind.push(filter.rootId);
|
|
128
|
+
}
|
|
129
|
+
if (filter.ids) {
|
|
130
|
+
where.push(`id IN (${filter.ids.map(() => "?").join(", ")})`);
|
|
131
|
+
bind.push(...filter.ids);
|
|
132
|
+
}
|
|
133
|
+
const rows = await exec(`SELECT * FROM records WHERE ${where.join(" AND ")} ORDER BY rowid`, bind);
|
|
134
|
+
return rows.map(rowToRecord);
|
|
135
|
+
},
|
|
136
|
+
|
|
137
|
+
async putRecord(
|
|
138
|
+
record: Omit<StoredRecord, "base" | "localOnly"> & { base?: any | null; localOnly?: boolean },
|
|
139
|
+
): Promise<void> {
|
|
140
|
+
// A record already stored keeps its first root record; the new one is added to its memberships.
|
|
141
|
+
await exec(
|
|
142
|
+
`INSERT INTO records (type, id, root_id, resource, updated_at, base, local_only) VALUES (?, ?, ?, ?, ?, ?, ?)
|
|
143
|
+
ON CONFLICT (type, id) DO UPDATE SET resource = excluded.resource, updated_at = excluded.updated_at,
|
|
144
|
+
base = excluded.base, local_only = excluded.local_only`,
|
|
145
|
+
[
|
|
146
|
+
record.type,
|
|
147
|
+
record.id,
|
|
148
|
+
record.rootId,
|
|
149
|
+
JSON.stringify(record.resource),
|
|
150
|
+
toIso(record.updatedAt),
|
|
151
|
+
toJson(record.base),
|
|
152
|
+
record.localOnly ? 1 : 0,
|
|
153
|
+
],
|
|
154
|
+
);
|
|
155
|
+
await exec(`INSERT OR IGNORE INTO record_roots (type, id, root_id) VALUES (?, ?, ?)`, [
|
|
156
|
+
record.type,
|
|
157
|
+
record.id,
|
|
158
|
+
record.rootId,
|
|
159
|
+
]);
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
async deleteRecord(type: string, id: string): Promise<void> {
|
|
163
|
+
await deleteRecordEverywhere(type, id);
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
async removeRecordFromRoot(type: string, id: string, rootId: string): Promise<void> {
|
|
167
|
+
await exec(`DELETE FROM record_roots WHERE type = ? AND id = ? AND root_id = ?`, [type, id, rootId]);
|
|
168
|
+
const left = await exec(`SELECT root_id FROM record_roots WHERE type = ? AND id = ? ORDER BY root_id`, [
|
|
169
|
+
type,
|
|
170
|
+
id,
|
|
171
|
+
]);
|
|
172
|
+
if (left.length === 0) {
|
|
173
|
+
await deleteRecordEverywhere(type, id);
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
await exec(`UPDATE records SET root_id = ? WHERE type = ? AND id = ? AND root_id = ?`, [
|
|
177
|
+
String(left[0].root_id),
|
|
178
|
+
type,
|
|
179
|
+
id,
|
|
180
|
+
rootId,
|
|
181
|
+
]);
|
|
182
|
+
},
|
|
183
|
+
|
|
184
|
+
async linksFrom(fromId: string, kind?: string): Promise<StoredLink[]> {
|
|
185
|
+
const rows =
|
|
186
|
+
kind === undefined
|
|
187
|
+
? await exec(`SELECT * FROM links WHERE from_id = ? ORDER BY rowid`, [fromId])
|
|
188
|
+
: await exec(`SELECT * FROM links WHERE from_id = ? AND kind = ? ORDER BY rowid`, [fromId, kind]);
|
|
189
|
+
return rows.map(rowToLink);
|
|
190
|
+
},
|
|
191
|
+
|
|
192
|
+
async linksTo(toId: string, kind?: string): Promise<StoredLink[]> {
|
|
193
|
+
const rows =
|
|
194
|
+
kind === undefined
|
|
195
|
+
? await exec(`SELECT * FROM links WHERE to_id = ? ORDER BY rowid`, [toId])
|
|
196
|
+
: await exec(`SELECT * FROM links WHERE to_id = ? AND kind = ? ORDER BY rowid`, [toId, kind]);
|
|
197
|
+
return rows.map(rowToLink);
|
|
198
|
+
},
|
|
199
|
+
|
|
200
|
+
async replaceRootLinks(rootId: string, links: StoredLink[]): Promise<void> {
|
|
201
|
+
await exec(`DELETE FROM links WHERE root_id = ?`, [rootId]);
|
|
202
|
+
for (const link of links) await insertLink(link, rootId);
|
|
203
|
+
},
|
|
204
|
+
|
|
205
|
+
async putLink(link: StoredLink): Promise<void> {
|
|
206
|
+
// A link belongs to the root record of the record at either end, so a later
|
|
207
|
+
// replaceRootLinks / removeRoot of that root record reaches it.
|
|
208
|
+
const owner = await exec(`SELECT root_id FROM records WHERE id IN (?, ?) ORDER BY id = ? DESC LIMIT 1`, [
|
|
209
|
+
link.fromId,
|
|
210
|
+
link.toId,
|
|
211
|
+
link.fromId,
|
|
212
|
+
]);
|
|
213
|
+
await insertLink(link, owner[0] ? String(owner[0].root_id) : null);
|
|
214
|
+
},
|
|
215
|
+
|
|
216
|
+
async deleteLink(link: Pick<StoredLink, "fromId" | "toId" | "kind">): Promise<void> {
|
|
217
|
+
await exec(`DELETE FROM links WHERE from_id = ? AND to_id = ? AND kind = ?`, [link.fromId, link.toId, link.kind]);
|
|
218
|
+
},
|
|
219
|
+
|
|
220
|
+
async enqueue(entry: Omit<QueueEntry, "seq" | "createdAt" | "status">): Promise<number> {
|
|
221
|
+
if (hooks.beforeEnqueue) await hooks.beforeEnqueue();
|
|
222
|
+
const request = {
|
|
223
|
+
method: entry.method,
|
|
224
|
+
path: entry.path,
|
|
225
|
+
body: entry.body,
|
|
226
|
+
recordType: entry.recordType,
|
|
227
|
+
recordId: entry.recordId,
|
|
228
|
+
resolution: entry.resolution,
|
|
229
|
+
};
|
|
230
|
+
const rows = await exec(
|
|
231
|
+
`INSERT INTO queue (root_id, request, base, created_at, status) VALUES (?, ?, ?, ?, 'pending') RETURNING seq`,
|
|
232
|
+
[entry.rootId, JSON.stringify(request), toJson(entry.base), new Date().toISOString()],
|
|
233
|
+
);
|
|
234
|
+
return Number(rows[0].seq);
|
|
235
|
+
},
|
|
236
|
+
|
|
237
|
+
async queue(filter?: { status?: QueueStatus }): Promise<QueueEntry[]> {
|
|
238
|
+
const rows = filter?.status
|
|
239
|
+
? await exec(`SELECT * FROM queue WHERE status = ? ORDER BY seq`, [filter.status])
|
|
240
|
+
: await exec(`SELECT * FROM queue ORDER BY seq`);
|
|
241
|
+
return rows.map(rowToQueueEntry);
|
|
242
|
+
},
|
|
243
|
+
|
|
244
|
+
async setQueueStatus(seq: number, status: QueueStatus): Promise<void> {
|
|
245
|
+
await exec(`UPDATE queue SET status = ? WHERE seq = ?`, [status, seq]);
|
|
246
|
+
},
|
|
247
|
+
|
|
248
|
+
async setQueuePackage(seqs: number[], packageId: string | null): Promise<void> {
|
|
249
|
+
if (seqs.length === 0) return;
|
|
250
|
+
await exec(`UPDATE queue SET package_id = ? WHERE seq IN (${seqs.map(() => "?").join(", ")})`, [
|
|
251
|
+
packageId,
|
|
252
|
+
...seqs,
|
|
253
|
+
]);
|
|
254
|
+
},
|
|
255
|
+
|
|
256
|
+
async removeQueueEntry(seq: number): Promise<void> {
|
|
257
|
+
await exec(`DELETE FROM queue WHERE seq = ?`, [seq]);
|
|
258
|
+
},
|
|
259
|
+
|
|
260
|
+
async addConflict(c: Omit<Conflict, "id">): Promise<string> {
|
|
261
|
+
const id = newId();
|
|
262
|
+
const owner = await exec(`SELECT root_id FROM queue WHERE seq = ?`, [c.queueSeq]);
|
|
263
|
+
await exec(
|
|
264
|
+
`INSERT INTO conflicts (id, queue_seq, root_id, kind, record_type, record_id, mine, theirs, base, merged, fields, blocks, reason)
|
|
265
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
266
|
+
[
|
|
267
|
+
id,
|
|
268
|
+
c.queueSeq,
|
|
269
|
+
owner[0] ? String(owner[0].root_id) : null,
|
|
270
|
+
c.kind,
|
|
271
|
+
c.recordType ?? null,
|
|
272
|
+
c.recordId ?? null,
|
|
273
|
+
toJson(c.mine),
|
|
274
|
+
toJson(c.theirs),
|
|
275
|
+
toJson(c.base),
|
|
276
|
+
toJson(c.merged),
|
|
277
|
+
JSON.stringify(c.fields ?? []),
|
|
278
|
+
toJson(c.blocks),
|
|
279
|
+
c.reason ?? null,
|
|
280
|
+
],
|
|
281
|
+
);
|
|
282
|
+
return id;
|
|
283
|
+
},
|
|
284
|
+
|
|
285
|
+
async conflicts(): Promise<Conflict[]> {
|
|
286
|
+
const rows = await exec(`SELECT * FROM conflicts ORDER BY queue_seq, rowid`);
|
|
287
|
+
return rows.map(rowToConflict);
|
|
288
|
+
},
|
|
289
|
+
|
|
290
|
+
async removeConflict(id: string): Promise<void> {
|
|
291
|
+
await exec(`DELETE FROM conflicts WHERE id = ?`, [id]);
|
|
292
|
+
},
|
|
293
|
+
|
|
294
|
+
async getSyncState(rootId: string): Promise<SyncState> {
|
|
295
|
+
const rows = await exec(`SELECT * FROM sync_state WHERE root_id = ?`, [rootId]);
|
|
296
|
+
if (!rows[0]) return { serverTime: null, locked: false, lockedFrom: null };
|
|
297
|
+
return {
|
|
298
|
+
serverTime: fromIso(rows[0].server_time),
|
|
299
|
+
locked: Number(rows[0].locked) === 1,
|
|
300
|
+
lockedFrom: fromIso(rows[0].locked_from),
|
|
301
|
+
};
|
|
302
|
+
},
|
|
303
|
+
|
|
304
|
+
async setSyncState(rootId: string, s: SyncState): Promise<void> {
|
|
305
|
+
await exec(`INSERT OR REPLACE INTO sync_state (root_id, server_time, locked, locked_from) VALUES (?, ?, ?, ?)`, [
|
|
306
|
+
rootId,
|
|
307
|
+
toIso(s.serverTime),
|
|
308
|
+
s.locked ? 1 : 0,
|
|
309
|
+
toIso(s.lockedFrom),
|
|
310
|
+
]);
|
|
311
|
+
},
|
|
312
|
+
|
|
313
|
+
async skippedRecords(rootId: string): Promise<SkippedRecord[]> {
|
|
314
|
+
const rows = await exec(`SELECT * FROM skipped_records WHERE root_id = ? ORDER BY type, id`, [rootId]);
|
|
315
|
+
return rows.map((row) => ({
|
|
316
|
+
type: String(row.type),
|
|
317
|
+
id: String(row.id),
|
|
318
|
+
updatedAt: new Date(String(row.updated_at)),
|
|
319
|
+
}));
|
|
320
|
+
},
|
|
321
|
+
|
|
322
|
+
async setSkippedRecords(rootId: string, records: SkippedRecord[]): Promise<void> {
|
|
323
|
+
await exec(`DELETE FROM skipped_records WHERE root_id = ?`, [rootId]);
|
|
324
|
+
for (const record of records) {
|
|
325
|
+
await exec(`INSERT OR REPLACE INTO skipped_records (root_id, type, id, updated_at) VALUES (?, ?, ?, ?)`, [
|
|
326
|
+
rootId,
|
|
327
|
+
record.type,
|
|
328
|
+
record.id,
|
|
329
|
+
record.updatedAt.toISOString(),
|
|
330
|
+
]);
|
|
331
|
+
}
|
|
332
|
+
},
|
|
333
|
+
|
|
334
|
+
async removeRoot(rootId: string): Promise<void> {
|
|
335
|
+
// Waiting changes are never dropped: a root record with queue entries is kept whole.
|
|
336
|
+
const waiting = await exec(`SELECT COUNT(*) AS n FROM queue WHERE root_id = ?`, [rootId]);
|
|
337
|
+
if (Number(waiting[0]?.n ?? 0) > 0) return;
|
|
338
|
+
// A shared record another root record still uses stays for that root record; every other
|
|
339
|
+
// record of this root record goes, with its links.
|
|
340
|
+
await exec(`DELETE FROM links WHERE root_id = ?`, [rootId]);
|
|
341
|
+
await exec(`DELETE FROM record_roots WHERE root_id = ?`, [rootId]);
|
|
342
|
+
const orphans = `SELECT id FROM records r WHERE NOT EXISTS (SELECT 1 FROM record_roots rc WHERE rc.type = r.type AND rc.id = r.id)`;
|
|
343
|
+
await exec(`DELETE FROM links WHERE from_id IN (${orphans}) OR to_id IN (${orphans})`);
|
|
344
|
+
await exec(
|
|
345
|
+
`DELETE FROM records WHERE NOT EXISTS (SELECT 1 FROM record_roots rc WHERE rc.type = records.type AND rc.id = records.id)`,
|
|
346
|
+
);
|
|
347
|
+
await exec(
|
|
348
|
+
`UPDATE records SET root_id = (SELECT MIN(rc.root_id) FROM record_roots rc WHERE rc.type = records.type AND rc.id = records.id) WHERE root_id = ?`,
|
|
349
|
+
[rootId],
|
|
350
|
+
);
|
|
351
|
+
await exec(`DELETE FROM conflicts WHERE root_id = ?`, [rootId]);
|
|
352
|
+
await exec(`DELETE FROM sync_state WHERE root_id = ?`, [rootId]);
|
|
353
|
+
await exec(`DELETE FROM skipped_records WHERE root_id = ?`, [rootId]);
|
|
354
|
+
},
|
|
355
|
+
|
|
356
|
+
async queueSize(): Promise<number> {
|
|
357
|
+
const rows = await exec(`SELECT COUNT(*) AS n FROM queue`);
|
|
358
|
+
return Number(rows[0]?.n ?? 0);
|
|
359
|
+
},
|
|
360
|
+
};
|
|
361
|
+
};
|
|
362
|
+
|
|
363
|
+
type Operations = ReturnType<typeof createOperations>;
|
|
364
|
+
|
|
365
|
+
/** A store whose `transaction` runs `fn` directly: used inside an open transaction. */
|
|
366
|
+
const nestedStore = (ops: Operations): DeviceStore => {
|
|
367
|
+
const { queueSize: _queueSize, ...rest } = ops;
|
|
368
|
+
const store: DeviceStore = { ...rest, transaction: (fn) => fn(store) };
|
|
369
|
+
return store;
|
|
370
|
+
};
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* The device store over any SQLite `exec` (the worker's OPFS file, or an
|
|
374
|
+
* in-memory database in tests). Every operation is serialised, so a
|
|
375
|
+
* transaction never interleaves with another caller's statements.
|
|
376
|
+
*/
|
|
377
|
+
export async function createSqlDeviceStore(exec: ExecFn, hooks: SqlDeviceStoreHooks = {}): Promise<DeviceStore> {
|
|
378
|
+
const exclusive = hooks.exclusive ?? (<T>(fn: () => Promise<T>) => fn());
|
|
379
|
+
|
|
380
|
+
// A file of an older layout is recreated (pre-release: no migration of device files).
|
|
381
|
+
await exclusive(async () => {
|
|
382
|
+
const version = Number((await exec(`PRAGMA user_version`))[0]?.user_version ?? 0);
|
|
383
|
+
if (version > 0 && version < DEVICE_STORE_SCHEMA_VERSION) {
|
|
384
|
+
for (const statement of DEVICE_STORE_RESET) await exec(statement);
|
|
385
|
+
}
|
|
386
|
+
for (const statement of DEVICE_STORE_SCHEMA) await exec(statement);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
const ops = createOperations(exec, hooks);
|
|
390
|
+
let tail: Promise<unknown> = Promise.resolve();
|
|
391
|
+
const serialised = <T>(fn: () => Promise<T>): Promise<T> => {
|
|
392
|
+
const run = tail.then(
|
|
393
|
+
() => exclusive(fn),
|
|
394
|
+
() => exclusive(fn),
|
|
395
|
+
);
|
|
396
|
+
tail = run.catch(() => undefined);
|
|
397
|
+
return run;
|
|
398
|
+
};
|
|
399
|
+
const settleQueue = async () => {
|
|
400
|
+
if (hooks.afterQueueChange) await hooks.afterQueueChange(await ops.queueSize());
|
|
401
|
+
};
|
|
402
|
+
|
|
403
|
+
const store: DeviceStore = {
|
|
404
|
+
getRecord: (type, id) => serialised(() => ops.getRecord(type, id)),
|
|
405
|
+
listRecords: (filter) => serialised(() => ops.listRecords(filter)),
|
|
406
|
+
putRecord: (record) => serialised(() => ops.putRecord(record)),
|
|
407
|
+
deleteRecord: (type, id) => serialised(() => ops.deleteRecord(type, id)),
|
|
408
|
+
removeRecordFromRoot: (type, id, rootId) => serialised(() => ops.removeRecordFromRoot(type, id, rootId)),
|
|
409
|
+
linksFrom: (fromId, kind) => serialised(() => ops.linksFrom(fromId, kind)),
|
|
410
|
+
linksTo: (toId, kind) => serialised(() => ops.linksTo(toId, kind)),
|
|
411
|
+
replaceRootLinks: (rootId, links) =>
|
|
412
|
+
serialised(async () => {
|
|
413
|
+
await exec("BEGIN");
|
|
414
|
+
try {
|
|
415
|
+
await ops.replaceRootLinks(rootId, links);
|
|
416
|
+
await exec("COMMIT");
|
|
417
|
+
} catch (error) {
|
|
418
|
+
await exec("ROLLBACK");
|
|
419
|
+
throw error;
|
|
420
|
+
}
|
|
421
|
+
}),
|
|
422
|
+
putLink: (link) => serialised(() => ops.putLink(link)),
|
|
423
|
+
deleteLink: (link) => serialised(() => ops.deleteLink(link)),
|
|
424
|
+
enqueue: (entry) => serialised(() => ops.enqueue(entry)),
|
|
425
|
+
queue: (filter) => serialised(() => ops.queue(filter)),
|
|
426
|
+
setQueueStatus: (seq, status) => serialised(() => ops.setQueueStatus(seq, status)),
|
|
427
|
+
setQueuePackage: (seqs, packageId) => serialised(() => ops.setQueuePackage(seqs, packageId)),
|
|
428
|
+
removeQueueEntry: (seq) =>
|
|
429
|
+
serialised(async () => {
|
|
430
|
+
await ops.removeQueueEntry(seq);
|
|
431
|
+
await settleQueue();
|
|
432
|
+
}),
|
|
433
|
+
addConflict: (c) => serialised(() => ops.addConflict(c)),
|
|
434
|
+
conflicts: () => serialised(() => ops.conflicts()),
|
|
435
|
+
removeConflict: (id) => serialised(() => ops.removeConflict(id)),
|
|
436
|
+
getSyncState: (rootId) => serialised(() => ops.getSyncState(rootId)),
|
|
437
|
+
setSyncState: (rootId, s) => serialised(() => ops.setSyncState(rootId, s)),
|
|
438
|
+
skippedRecords: (rootId) => serialised(() => ops.skippedRecords(rootId)),
|
|
439
|
+
setSkippedRecords: (rootId, records) => serialised(() => ops.setSkippedRecords(rootId, records)),
|
|
440
|
+
removeRoot: (rootId) =>
|
|
441
|
+
serialised(async () => {
|
|
442
|
+
await exec("BEGIN");
|
|
443
|
+
try {
|
|
444
|
+
await ops.removeRoot(rootId);
|
|
445
|
+
await exec("COMMIT");
|
|
446
|
+
} catch (error) {
|
|
447
|
+
await exec("ROLLBACK");
|
|
448
|
+
throw error;
|
|
449
|
+
}
|
|
450
|
+
await settleQueue();
|
|
451
|
+
}),
|
|
452
|
+
transaction: <T>(fn: (tx: DeviceStore) => Promise<T>) =>
|
|
453
|
+
serialised(async () => {
|
|
454
|
+
await exec("BEGIN");
|
|
455
|
+
let result: T;
|
|
456
|
+
try {
|
|
457
|
+
result = await fn(nestedStore(ops));
|
|
458
|
+
await exec("COMMIT");
|
|
459
|
+
} catch (error) {
|
|
460
|
+
await exec("ROLLBACK");
|
|
461
|
+
await settleQueue();
|
|
462
|
+
throw error;
|
|
463
|
+
}
|
|
464
|
+
await settleQueue();
|
|
465
|
+
return result;
|
|
466
|
+
}),
|
|
467
|
+
};
|
|
468
|
+
return store;
|
|
469
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import type { ExecFn } from "./sql-device-store";
|
|
2
|
+
import type { WorkerRequest, WorkerResponse } from "./worker";
|
|
3
|
+
|
|
4
|
+
export type OpenedDatabase = {
|
|
5
|
+
exec: ExecFn;
|
|
6
|
+
close: () => Promise<void>;
|
|
7
|
+
/** True when the file did not exist before this open. */
|
|
8
|
+
created: boolean;
|
|
9
|
+
};
|
|
10
|
+
|
|
11
|
+
type Pending = { resolve: (value: unknown) => void; reject: (error: Error) => void };
|
|
12
|
+
|
|
13
|
+
/** Starts the device-copy worker and opens `fileName` in it for `userId`. */
|
|
14
|
+
export async function openWorkerDatabase(
|
|
15
|
+
userId: string,
|
|
16
|
+
fileName: string,
|
|
17
|
+
urls: { workerUrl: string; sqliteModuleUrl: string },
|
|
18
|
+
): Promise<OpenedDatabase> {
|
|
19
|
+
// A plain URL, so the app's bundler leaves it alone: the worker is a standalone file the app
|
|
20
|
+
// serves (OfflineConfig.deviceWorkerUrl), precached by the service worker for offline use.
|
|
21
|
+
const worker = new Worker(urls.workerUrl, { type: "module" });
|
|
22
|
+
const { sqliteModuleUrl } = urls;
|
|
23
|
+
const pending = new Map<number, Pending>();
|
|
24
|
+
let nextId = 1;
|
|
25
|
+
let closed = false;
|
|
26
|
+
|
|
27
|
+
worker.onmessage = (event: MessageEvent<WorkerResponse>) => {
|
|
28
|
+
const response = event.data;
|
|
29
|
+
const waiting = pending.get(response.id);
|
|
30
|
+
if (!waiting) return;
|
|
31
|
+
pending.delete(response.id);
|
|
32
|
+
if (response.ok) waiting.resolve(response.result);
|
|
33
|
+
else waiting.reject(new Error(response.error));
|
|
34
|
+
};
|
|
35
|
+
worker.onerror = (event: ErrorEvent) => {
|
|
36
|
+
const error = new Error(event.message || "The device database worker failed.");
|
|
37
|
+
for (const waiting of pending.values()) waiting.reject(error);
|
|
38
|
+
pending.clear();
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const send = <T>(request: Omit<WorkerRequest, "id"> & { op: WorkerRequest["op"] }): Promise<T> => {
|
|
42
|
+
if (closed) return Promise.reject(new Error("The device database is closed."));
|
|
43
|
+
const id = nextId++;
|
|
44
|
+
return new Promise<T>((resolve, reject) => {
|
|
45
|
+
pending.set(id, { resolve: resolve as (value: unknown) => void, reject });
|
|
46
|
+
worker.postMessage({ ...request, id } as WorkerRequest);
|
|
47
|
+
});
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
let opened: { created: boolean };
|
|
51
|
+
try {
|
|
52
|
+
opened = await send<{ created: boolean }>({ op: "open", userId, fileName, sqliteModuleUrl } as WorkerRequest);
|
|
53
|
+
} catch (error) {
|
|
54
|
+
worker.terminate();
|
|
55
|
+
throw error;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
created: opened.created,
|
|
60
|
+
exec: (sql, bind) => send<Record<string, unknown>[]>({ op: "exec", sql, bind } as WorkerRequest),
|
|
61
|
+
close: async () => {
|
|
62
|
+
if (closed) return;
|
|
63
|
+
try {
|
|
64
|
+
await send({ op: "close" } as WorkerRequest);
|
|
65
|
+
} finally {
|
|
66
|
+
closed = true;
|
|
67
|
+
worker.terminate();
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The device-copy worker: loads the official SQLite WebAssembly build, opens
|
|
3
|
+
* the signed-in user's file `nja-offline-<userId>.sqlite3` in the Origin
|
|
4
|
+
* Private File System and answers `exec` messages from `worker-client.ts`.
|
|
5
|
+
*
|
|
6
|
+
* VFS: the "opfs" VFS when the page is cross-origin isolated (it needs
|
|
7
|
+
* SharedArrayBuffer); otherwise the "opfs-sahpool" VFS, which needs no
|
|
8
|
+
* COOP/COEP headers, registered per user in the OPFS directory
|
|
9
|
+
* `.nja-offline-<userId>` so the user id is still in the stored name.
|
|
10
|
+
*/
|
|
11
|
+
import type { Database, default as Sqlite3InitModule } from "@sqlite.org/sqlite-wasm";
|
|
12
|
+
|
|
13
|
+
// SQLite is loaded at run time from the URL the page sends with "open" (OfflineConfig.sqliteModuleUrl),
|
|
14
|
+
// never bundled: its own helper worker uses a computed URL that app bundlers refuse.
|
|
15
|
+
const loadSqlite = async (moduleUrl: string): Promise<typeof Sqlite3InitModule> =>
|
|
16
|
+
(await import(/* webpackIgnore: true */ /* turbopackIgnore: true */ moduleUrl)).default;
|
|
17
|
+
|
|
18
|
+
export type WorkerRequest =
|
|
19
|
+
| { id: number; op: "open"; userId: string; fileName: string; sqliteModuleUrl: string }
|
|
20
|
+
| { id: number; op: "exec"; sql: string; bind?: unknown[] }
|
|
21
|
+
| { id: number; op: "close" };
|
|
22
|
+
|
|
23
|
+
export type WorkerResponse = { id: number; ok: true; result: unknown } | { id: number; ok: false; error: string };
|
|
24
|
+
|
|
25
|
+
let db: Database | undefined;
|
|
26
|
+
|
|
27
|
+
const reply = (response: WorkerResponse) =>
|
|
28
|
+
(self as unknown as { postMessage: (m: WorkerResponse) => void }).postMessage(response);
|
|
29
|
+
|
|
30
|
+
const fileExists = async (fileName: string): Promise<boolean> => {
|
|
31
|
+
const root = await navigator.storage.getDirectory();
|
|
32
|
+
try {
|
|
33
|
+
await root.getFileHandle(fileName);
|
|
34
|
+
return true;
|
|
35
|
+
} catch {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
const open = async (
|
|
41
|
+
userId: string,
|
|
42
|
+
fileName: string,
|
|
43
|
+
sqliteModuleUrl: string,
|
|
44
|
+
): Promise<{ created: boolean; vfs: string }> => {
|
|
45
|
+
if (db) throw new Error("The device database is already open in this worker.");
|
|
46
|
+
const sqlite3InitModule = await loadSqlite(new URL(sqliteModuleUrl, self.location.href).href);
|
|
47
|
+
const sqlite3 = await sqlite3InitModule();
|
|
48
|
+
if (sqlite3.oo1.OpfsDb) {
|
|
49
|
+
const created = !(await fileExists(fileName));
|
|
50
|
+
db = new sqlite3.oo1.OpfsDb(`/${fileName}`, "c");
|
|
51
|
+
return { created, vfs: "opfs" };
|
|
52
|
+
}
|
|
53
|
+
const pool = await sqlite3.installOpfsSAHPoolVfs({
|
|
54
|
+
name: `nja-offline-${userId}`,
|
|
55
|
+
directory: `.nja-offline-${userId}`,
|
|
56
|
+
});
|
|
57
|
+
const created = !pool.getFileNames().includes(`/${fileName}`);
|
|
58
|
+
db = new pool.OpfsSAHPoolDb(`/${fileName}`);
|
|
59
|
+
return { created, vfs: "opfs-sahpool" };
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const exec = (sql: string, bind?: unknown[]): Record<string, unknown>[] => {
|
|
63
|
+
if (!db) throw new Error("The device database is not open.");
|
|
64
|
+
return db.exec({
|
|
65
|
+
sql,
|
|
66
|
+
bind: bind && bind.length ? (bind as any) : undefined,
|
|
67
|
+
rowMode: "object",
|
|
68
|
+
returnValue: "resultRows",
|
|
69
|
+
}) as Record<string, unknown>[];
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
self.onmessage = async (event: MessageEvent<WorkerRequest>) => {
|
|
73
|
+
const request = event.data;
|
|
74
|
+
try {
|
|
75
|
+
let result: unknown;
|
|
76
|
+
if (request.op === "open") result = await open(request.userId, request.fileName, request.sqliteModuleUrl);
|
|
77
|
+
else if (request.op === "exec") result = exec(request.sql, request.bind);
|
|
78
|
+
else {
|
|
79
|
+
db?.close();
|
|
80
|
+
db = undefined;
|
|
81
|
+
result = null;
|
|
82
|
+
}
|
|
83
|
+
reply({ id: request.id, ok: true, result });
|
|
84
|
+
} catch (error) {
|
|
85
|
+
reply({ id: request.id, ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
86
|
+
}
|
|
87
|
+
};
|