@carlonicora/nextjs-jsonapi 6.12.2 → 6.13.0
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-75ED2CN2.mjs → BlockNoteEditor-22Q4DJBU.mjs} +20 -8
- package/dist/BlockNoteEditor-22Q4DJBU.mjs.map +1 -0
- package/dist/{BlockNoteEditor-7M5QW2YQ.js → BlockNoteEditor-2VLPSEKS.js} +34 -22
- package/dist/BlockNoteEditor-2VLPSEKS.js.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-V3YDVJ63.js → chunk-2H342HNN.js} +948 -921
- package/dist/chunk-2H342HNN.js.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-RXQYEEKZ.mjs → chunk-H4NLDX5G.mjs} +51 -24
- package/dist/chunk-H4NLDX5G.mjs.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-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-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/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,706 @@
|
|
|
1
|
+
import { CURRENT_USER_STORAGE_KEY } from "../features/user/data/user.storage";
|
|
2
|
+
import { getOfflineConfig } from "./config";
|
|
3
|
+
import { getOfflineState } from "./state";
|
|
4
|
+
import type { DeviceStore, StoredLink, StoredRecord } from "./store/DeviceStore";
|
|
5
|
+
import type {
|
|
6
|
+
LocalAnswerContext,
|
|
7
|
+
OfflineAnswer,
|
|
8
|
+
OfflineConfig,
|
|
9
|
+
OfflineMethod,
|
|
10
|
+
OfflineRoute,
|
|
11
|
+
OfflineTypeConfig,
|
|
12
|
+
} from "./types";
|
|
13
|
+
|
|
14
|
+
const DEFAULT_PAGE_SIZE = 25;
|
|
15
|
+
|
|
16
|
+
// ---------------------------------------------------------------------------------------------
|
|
17
|
+
// Answers shared by every route
|
|
18
|
+
// ---------------------------------------------------------------------------------------------
|
|
19
|
+
|
|
20
|
+
/** A JSON:API errors document with one error, answered with `status`. */
|
|
21
|
+
export function errorAnswer(status: number, title: string, detail: string, code?: string): OfflineAnswer {
|
|
22
|
+
return {
|
|
23
|
+
status,
|
|
24
|
+
body: { errors: [{ status: String(status), ...(code ? { code } : {}), title, detail }] },
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function needsConnectionAnswer(detail: string = "Needs a connection"): OfflineAnswer {
|
|
29
|
+
return errorAnswer(503, "Needs a connection", detail, "needs-connection");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function notFoundAnswer(detail: string): OfflineAnswer {
|
|
33
|
+
return errorAnswer(404, "Not found", detail);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A single document of one stored record, with `included` (`includedFor`). */
|
|
37
|
+
export async function singleAnswer(
|
|
38
|
+
store: DeviceStore,
|
|
39
|
+
record: StoredRecord,
|
|
40
|
+
status: number = 200,
|
|
41
|
+
): Promise<OfflineAnswer> {
|
|
42
|
+
return { status, body: { data: record.resource, included: await includedFor(store, [record]) } };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function isRootWriteLocked(rootId: string | undefined): boolean {
|
|
46
|
+
return !!rootId && getOfflineState().lockedRootIds.includes(rootId);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------------------------
|
|
50
|
+
// Resource helpers
|
|
51
|
+
// ---------------------------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
type ResourceIdentifier = { type: string; id: string };
|
|
54
|
+
|
|
55
|
+
function identifiersOf(relationship: any): ResourceIdentifier[] {
|
|
56
|
+
const data = relationship?.data;
|
|
57
|
+
if (!data) return [];
|
|
58
|
+
return (Array.isArray(data) ? data : [data]).filter((item: any) => item && item.type && item.id);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function linkedIds(links: StoredLink[], id: string): ResourceIdentifier[] {
|
|
62
|
+
return links.map((link) =>
|
|
63
|
+
link.fromId === id ? { type: link.toType, id: link.toId } : { type: link.fromType, id: link.fromId },
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Every stored resource linked to one of `ids`, in either direction, without duplicates or the records themselves. */
|
|
68
|
+
/** The `{ type, id }` every relationship of a JSON:API resource points to. */
|
|
69
|
+
function relationshipTargets(resource: any): { type: string; id: string }[] {
|
|
70
|
+
const targets: { type: string; id: string }[] = [];
|
|
71
|
+
for (const relationship of Object.values<any>(resource?.relationships ?? {})) {
|
|
72
|
+
const data = relationship?.data;
|
|
73
|
+
for (const ref of Array.isArray(data) ? data : data ? [data] : []) {
|
|
74
|
+
if (ref?.type && ref?.id) targets.push({ type: ref.type, id: ref.id });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return targets;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The `included` of a local answer: the stored records linked to `records` (the synced links),
|
|
82
|
+
* then the stored records their relationships point to, and one level further the stored records
|
|
83
|
+
* the relationships of those included records point to (an NPC's root record, then the root record's
|
|
84
|
+
* owner), so a model reads the same nested relationships it reads from the server.
|
|
85
|
+
*
|
|
86
|
+
* Stored resources point at each other (a root record at its owner, the owner at the root record's
|
|
87
|
+
* company, the company back at the owner); `RehydrationFactory` skips a resource met again inside
|
|
88
|
+
* its own chain, so those circles never recurse.
|
|
89
|
+
*/
|
|
90
|
+
export async function includedFor(store: DeviceStore, records: StoredRecord[]): Promise<any[]> {
|
|
91
|
+
// 0: the answer's own records; 1: what they link to; 2: what those link to.
|
|
92
|
+
const level = new Map<string, number>(records.map((record) => [`${record.type}:${record.id}`, 0]));
|
|
93
|
+
const included: { resource: any; level: number }[] = [];
|
|
94
|
+
|
|
95
|
+
const add = async (target: { type: string; id: string }, atLevel: number): Promise<void> => {
|
|
96
|
+
const key = `${target.type}:${target.id}`;
|
|
97
|
+
if (level.has(key)) return;
|
|
98
|
+
level.set(key, atLevel);
|
|
99
|
+
const stored = await store.getRecord(target.type, target.id);
|
|
100
|
+
const resource = stored?.resource ?? signedInUserResource(target);
|
|
101
|
+
if (resource) included.push({ resource, level: atLevel });
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
for (const record of records) {
|
|
105
|
+
const links = [...(await store.linksFrom(record.id)), ...(await store.linksTo(record.id))];
|
|
106
|
+
for (const target of linkedIds(links, record.id)) await add(target, 1);
|
|
107
|
+
}
|
|
108
|
+
for (const record of records) {
|
|
109
|
+
for (const target of relationshipTargets(record.resource)) await add(target, 1);
|
|
110
|
+
}
|
|
111
|
+
for (const entry of included.filter((candidate) => candidate.level === 1)) {
|
|
112
|
+
for (const target of relationshipTargets(entry.resource)) await add(target, 2);
|
|
113
|
+
}
|
|
114
|
+
return included.map((entry) => entry.resource);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* The signed-in user as a resource, when `target` is that user. Users are not kept on the device,
|
|
119
|
+
* but a relationship to the signed-in user (every downloaded root record's owner) must still resolve:
|
|
120
|
+
* the app tells a GM from a player by comparing the root record's owner with the signed-in user. The
|
|
121
|
+
* resource comes from the copy CurrentUserProvider keeps in localStorage, without relationships.
|
|
122
|
+
*/
|
|
123
|
+
function signedInUserResource(target: { type: string; id: string }): any | undefined {
|
|
124
|
+
try {
|
|
125
|
+
if (target.id !== getOfflineConfig().getUserId()) return undefined;
|
|
126
|
+
const stored = JSON.parse(window.localStorage.getItem(CURRENT_USER_STORAGE_KEY) ?? "null");
|
|
127
|
+
const user = stored?.jsonApi;
|
|
128
|
+
if (user?.type !== target.type || user?.id !== target.id) return undefined;
|
|
129
|
+
return { type: user.type, id: user.id, attributes: user.attributes ?? {}, meta: user.meta };
|
|
130
|
+
} catch {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function attributeOf(record: StoredRecord, name: string): unknown {
|
|
136
|
+
const value = record.resource?.attributes?.[name];
|
|
137
|
+
if (value !== undefined && value !== null) return value;
|
|
138
|
+
// The server value the sync keeps beside the resource.
|
|
139
|
+
if (name === "updatedAt") return record.updatedAt ?? null;
|
|
140
|
+
return value;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** The server's default list order (`defineEntity` sets `defaultOrderBy: "updatedAt DESC"`). */
|
|
144
|
+
export const SERVER_DEFAULT_ORDER_BY = "updatedAt DESC";
|
|
145
|
+
|
|
146
|
+
/** The only `orderBy` the server accepts (`buildLegacyOrderByClause`): one property, optional ASC/DESC. */
|
|
147
|
+
const ORDER_BY = /^([A-Za-z_][A-Za-z0-9_]*)(?:\s+(ASC|DESC))?$/i;
|
|
148
|
+
|
|
149
|
+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}(T|$)/;
|
|
150
|
+
|
|
151
|
+
function comparable(value: unknown): unknown {
|
|
152
|
+
if (value instanceof Date) return value.getTime();
|
|
153
|
+
if (typeof value === "string" && ISO_DATE.test(value)) {
|
|
154
|
+
const time = Date.parse(value);
|
|
155
|
+
if (!Number.isNaN(time)) return time;
|
|
156
|
+
}
|
|
157
|
+
return value;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Cypher ORDER BY on one property, ascending: null sorts after every value. */
|
|
161
|
+
function compareAscending(left: unknown, right: unknown): number {
|
|
162
|
+
const a = comparable(left);
|
|
163
|
+
const b = comparable(right);
|
|
164
|
+
const aNull = a === undefined || a === null;
|
|
165
|
+
const bNull = b === undefined || b === null;
|
|
166
|
+
if (aNull || bNull) return aNull === bNull ? 0 : aNull ? 1 : -1;
|
|
167
|
+
if (typeof a === "number" && typeof b === "number") return a - b;
|
|
168
|
+
if (typeof a === "boolean" && typeof b === "boolean") return Number(a) - Number(b);
|
|
169
|
+
const x = String(a);
|
|
170
|
+
const y = String(b);
|
|
171
|
+
return x < y ? -1 : x > y ? 1 : 0;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export type ListOptions = {
|
|
175
|
+
/** The string attributes the server's fulltext index covers (default: every string attribute). */
|
|
176
|
+
searchFields?: string[];
|
|
177
|
+
/** The order with no `orderBy` (default `updatedAt DESC`, the server's). */
|
|
178
|
+
defaultOrderBy?: string;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* `search` as the server runs it: the term is split into letter/digit tokens and a record
|
|
183
|
+
* matches when every token appears, case-insensitive, inside one of its indexed string
|
|
184
|
+
* attributes (the fulltext query `*token* AND ...`).
|
|
185
|
+
*/
|
|
186
|
+
export function searchRecords(
|
|
187
|
+
records: StoredRecord[],
|
|
188
|
+
query: URLSearchParams,
|
|
189
|
+
options: ListOptions = {},
|
|
190
|
+
): StoredRecord[] {
|
|
191
|
+
const term = query.get("search");
|
|
192
|
+
if (term === null || term === undefined || term.trim() === "") return records;
|
|
193
|
+
const tokens = term
|
|
194
|
+
.toLowerCase()
|
|
195
|
+
.split(/[^\p{L}\p{N}]+/u)
|
|
196
|
+
.filter(Boolean);
|
|
197
|
+
if (tokens.length === 0) return records;
|
|
198
|
+
|
|
199
|
+
return records.filter((record) => {
|
|
200
|
+
const attributes = record.resource?.attributes ?? {};
|
|
201
|
+
const fields = options.searchFields ?? Object.keys(attributes);
|
|
202
|
+
const texts = fields
|
|
203
|
+
.map((field) => attributes[field])
|
|
204
|
+
.filter((value): value is string => typeof value === "string")
|
|
205
|
+
.map((value) => value.toLowerCase());
|
|
206
|
+
return tokens.every((token) => texts.some((text) => text.includes(token)));
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Orders records as the server's `ORDER BY` does for `orderBy` (`field` or `field ASC|DESC`),
|
|
212
|
+
* or for the default order when there is none. `null` when the server would refuse the
|
|
213
|
+
* `orderBy` (400).
|
|
214
|
+
*/
|
|
215
|
+
export function orderRecords(
|
|
216
|
+
records: StoredRecord[],
|
|
217
|
+
query: URLSearchParams,
|
|
218
|
+
options: ListOptions = {},
|
|
219
|
+
): StoredRecord[] | null {
|
|
220
|
+
const requested = query.get("orderBy")?.trim();
|
|
221
|
+
const value = requested || options.defaultOrderBy?.trim() || SERVER_DEFAULT_ORDER_BY;
|
|
222
|
+
const match = ORDER_BY.exec(value);
|
|
223
|
+
if (!match) return null;
|
|
224
|
+
const field = match[1];
|
|
225
|
+
const desc = match[2]?.toUpperCase() === "DESC";
|
|
226
|
+
return [...records].sort((left, right) => {
|
|
227
|
+
const order = compareAscending(attributeOf(left, field), attributeOf(right, field));
|
|
228
|
+
return desc ? -order : order;
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function linkUrl(base: string, query: URLSearchParams, changes: Record<string, string | undefined>): string {
|
|
233
|
+
const params = new URLSearchParams(query);
|
|
234
|
+
for (const [key, value] of Object.entries(changes)) {
|
|
235
|
+
if (value === undefined) params.delete(key);
|
|
236
|
+
else params.set(key, value);
|
|
237
|
+
}
|
|
238
|
+
const search = params.toString().replace(/%5B/g, "[").replace(/%5D/g, "]").replace(/%2C/g, ",");
|
|
239
|
+
return search ? `${base}?${search}` : base;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* One list answer for a set of stored records, as the server answers a list: `search`, then
|
|
244
|
+
* `orderBy` (or the default order), then paged the way `JsonApiPaginator` pages (`fetchAll`,
|
|
245
|
+
* `page[size]`, `page[offset]`), with `included`. App answers pass the records they selected.
|
|
246
|
+
*/
|
|
247
|
+
export async function listAnswer(
|
|
248
|
+
ctx: LocalAnswerContext,
|
|
249
|
+
records: StoredRecord[],
|
|
250
|
+
options: ListOptions = {},
|
|
251
|
+
): Promise<OfflineAnswer> {
|
|
252
|
+
const { request, store } = ctx;
|
|
253
|
+
const base = request.url ? request.url.split("?")[0] : request.path;
|
|
254
|
+
const sorted = orderRecords(searchRecords(records, request.query, options), request.query, options);
|
|
255
|
+
if (!sorted) {
|
|
256
|
+
return errorAnswer(400, "Bad Request", "Invalid orderBy");
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (request.query.get("fetchAll") === "true") {
|
|
260
|
+
return {
|
|
261
|
+
status: 200,
|
|
262
|
+
body: {
|
|
263
|
+
data: sorted.map((record) => record.resource),
|
|
264
|
+
included: await includedFor(store, sorted),
|
|
265
|
+
links: { self: linkUrl(base, request.query, {}) },
|
|
266
|
+
meta: { total: sorted.length },
|
|
267
|
+
},
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const size = Number(request.query.get("page[size]")) || DEFAULT_PAGE_SIZE;
|
|
272
|
+
const offset = Number(request.query.get("page[offset]")) || 0;
|
|
273
|
+
const page = sorted.slice(offset, offset + size);
|
|
274
|
+
|
|
275
|
+
const links: Record<string, string> = {
|
|
276
|
+
self: linkUrl(base, request.query, { "page[size]": String(size), "page[offset]": undefined }),
|
|
277
|
+
};
|
|
278
|
+
if (offset + size < sorted.length) {
|
|
279
|
+
links.next = linkUrl(base, request.query, { "page[size]": String(size), "page[offset]": String(offset + size) });
|
|
280
|
+
}
|
|
281
|
+
if (offset > 0) {
|
|
282
|
+
links.prev = linkUrl(base, request.query, {
|
|
283
|
+
"page[size]": String(size),
|
|
284
|
+
"page[offset]": String(Math.max(0, offset - size)),
|
|
285
|
+
});
|
|
286
|
+
// The server names it `previous`; the client reads `prev`.
|
|
287
|
+
links.previous = links.prev;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
status: 200,
|
|
292
|
+
body: {
|
|
293
|
+
data: page.map((record) => record.resource),
|
|
294
|
+
included: await includedFor(store, page),
|
|
295
|
+
links,
|
|
296
|
+
meta: { total: sorted.length },
|
|
297
|
+
},
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* A list document of stored records in the order the caller settled, every record in one page,
|
|
303
|
+
* with `included`: for an app's custom list query that has its own `ORDER BY` and no paging.
|
|
304
|
+
*/
|
|
305
|
+
export async function orderedListAnswer(store: DeviceStore, records: StoredRecord[]): Promise<OfflineAnswer> {
|
|
306
|
+
return {
|
|
307
|
+
status: 200,
|
|
308
|
+
body: {
|
|
309
|
+
data: records.map((record) => record.resource),
|
|
310
|
+
included: await includedFor(store, records),
|
|
311
|
+
meta: { total: records.length },
|
|
312
|
+
},
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* A list document of resources the device computed rather than stored (a query-result
|
|
318
|
+
* projection such as a search hit, built by its model), answered as they are, nothing included.
|
|
319
|
+
*/
|
|
320
|
+
export function resourceListAnswer(resources: any[]): OfflineAnswer {
|
|
321
|
+
return { status: 200, body: { data: resources, included: [] } };
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
function listOptionsOf(typeConfig: OfflineTypeConfig): ListOptions {
|
|
325
|
+
return { searchFields: typeConfig.searchFields, defaultOrderBy: typeConfig.defaultOrderBy };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// ---------------------------------------------------------------------------------------------
|
|
329
|
+
// Link kinds
|
|
330
|
+
// ---------------------------------------------------------------------------------------------
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* The kind a link written on the device carries for relationship `name`: the Neo4j type the
|
|
334
|
+
* type config maps it to (the kind synced links carry), otherwise the name itself.
|
|
335
|
+
*/
|
|
336
|
+
export function linkKindFor(typeConfig: OfflineTypeConfig | undefined, name: string): string {
|
|
337
|
+
return typeConfig?.relationshipKinds?.[name] ?? name;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Every kind a link of relationship `name` may carry on the device: the name (written on the
|
|
342
|
+
* device before a mapping existed) and the mapped Neo4j type (synced). `undefined` when the
|
|
343
|
+
* Neo4j type is not known: then a link of any kind may be the one.
|
|
344
|
+
*/
|
|
345
|
+
export function linkKindsOf(typeConfig: OfflineTypeConfig | undefined, name: string): string[] | undefined {
|
|
346
|
+
const mapped = typeConfig?.relationshipKinds?.[name];
|
|
347
|
+
return mapped ? [name, mapped] : undefined;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* The stored links of `id` whose kind is one of `kinds` (Neo4j types and/or relationship
|
|
352
|
+
* names). `out`: links from `id`; `in`: links to `id`; `both`: either.
|
|
353
|
+
*/
|
|
354
|
+
export async function linksOfKind(
|
|
355
|
+
store: DeviceStore,
|
|
356
|
+
id: string,
|
|
357
|
+
kinds: string[],
|
|
358
|
+
direction: "out" | "in" | "both" = "both",
|
|
359
|
+
): Promise<StoredLink[]> {
|
|
360
|
+
const result: StoredLink[] = [];
|
|
361
|
+
const seen = new Set<string>();
|
|
362
|
+
for (const kind of new Set(kinds)) {
|
|
363
|
+
const links = [
|
|
364
|
+
...(direction !== "in" ? await store.linksFrom(id, kind) : []),
|
|
365
|
+
...(direction !== "out" ? await store.linksTo(id, kind) : []),
|
|
366
|
+
];
|
|
367
|
+
for (const link of links) {
|
|
368
|
+
const key = `${link.fromId}|${link.toId}|${link.kind}`;
|
|
369
|
+
if (seen.has(key)) continue;
|
|
370
|
+
seen.add(key);
|
|
371
|
+
result.push(link);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
return result;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function typeConfigOf(config: OfflineConfig, type: string): OfflineTypeConfig | undefined {
|
|
378
|
+
return config.types.find((typeConfig) => typeConfig.module.name === type);
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** The relationship names of `resource` that point to a record of `type`. */
|
|
382
|
+
function relationshipNamesToward(resource: any, type: string): string[] {
|
|
383
|
+
return Object.entries<any>(resource?.relationships ?? {})
|
|
384
|
+
.filter(([, relationship]) => identifiersOf(relationship).some((identifier) => identifier.type === type))
|
|
385
|
+
.map(([name]) => name);
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
// ---------------------------------------------------------------------------------------------
|
|
389
|
+
// Reads
|
|
390
|
+
// ---------------------------------------------------------------------------------------------
|
|
391
|
+
|
|
392
|
+
/** A record that is not on the device: only the server can answer for it. */
|
|
393
|
+
function notOnDeviceAnswer(type: string, id: string): OfflineAnswer {
|
|
394
|
+
return needsConnectionAnswer(`${type} ${id} is not on this device`);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async function getOne(ctx: LocalAnswerContext, type: string): Promise<OfflineAnswer> {
|
|
398
|
+
const record = await ctx.store.getRecord(type, ctx.params.id);
|
|
399
|
+
if (!record) return notOnDeviceAnswer(type, ctx.params.id);
|
|
400
|
+
return singleAnswer(ctx.store, record);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
async function rootList(
|
|
404
|
+
ctx: LocalAnswerContext,
|
|
405
|
+
config: OfflineConfig,
|
|
406
|
+
typeConfig: OfflineTypeConfig,
|
|
407
|
+
): Promise<OfflineAnswer> {
|
|
408
|
+
const rootType = config.rootModule.name;
|
|
409
|
+
const rootId = ctx.params.rootId;
|
|
410
|
+
if (!(await ctx.store.getRecord(rootType, rootId))) return notOnDeviceAnswer(rootType, rootId);
|
|
411
|
+
const records = await ctx.store.listRecords({ type: typeConfig.module.name, rootId });
|
|
412
|
+
return listAnswer(ctx, records, listOptionsOf(typeConfig));
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* `GET <parent>/:parentId/<type>`: the stored records of `type` linked to the parent by the
|
|
417
|
+
* relationship between the two types. That relationship is the one whose name, on either
|
|
418
|
+
* record, points to the other type; its links are matched by name and by mapped Neo4j type.
|
|
419
|
+
* When its Neo4j type is not known, links of any kind count.
|
|
420
|
+
*/
|
|
421
|
+
async function relatedList(
|
|
422
|
+
ctx: LocalAnswerContext,
|
|
423
|
+
config: OfflineConfig,
|
|
424
|
+
typeConfig: OfflineTypeConfig,
|
|
425
|
+
): Promise<OfflineAnswer> {
|
|
426
|
+
const type = typeConfig.module.name;
|
|
427
|
+
const { parentType, parentId } = ctx.params;
|
|
428
|
+
const parent = await ctx.store.getRecord(parentType, parentId);
|
|
429
|
+
if (!parent) return notOnDeviceAnswer(parentType, parentId);
|
|
430
|
+
|
|
431
|
+
const links = [...(await ctx.store.linksFrom(parentId)), ...(await ctx.store.linksTo(parentId))].filter((link) =>
|
|
432
|
+
link.fromId === parentId ? link.toType === type : link.fromType === type,
|
|
433
|
+
);
|
|
434
|
+
const candidates = await ctx.store.listRecords({
|
|
435
|
+
type,
|
|
436
|
+
ids: [...new Set(links.map((link) => (link.fromId === parentId ? link.toId : link.fromId)))],
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
const kinds = new Set<string>();
|
|
440
|
+
let anyKind = false;
|
|
441
|
+
const collect = (owner: OfflineTypeConfig | undefined, names: string[]) => {
|
|
442
|
+
for (const name of names) {
|
|
443
|
+
const known = linkKindsOf(owner, name);
|
|
444
|
+
if (known) known.forEach((kind) => kinds.add(kind));
|
|
445
|
+
else anyKind = true;
|
|
446
|
+
}
|
|
447
|
+
};
|
|
448
|
+
collect(typeConfig, [
|
|
449
|
+
...new Set(candidates.flatMap((record) => relationshipNamesToward(record.resource, parentType))),
|
|
450
|
+
]);
|
|
451
|
+
collect(typeConfigOf(config, parentType), relationshipNamesToward(parent.resource, type));
|
|
452
|
+
|
|
453
|
+
const accepted = anyKind || kinds.size === 0 ? links : links.filter((link) => kinds.has(link.kind));
|
|
454
|
+
const ids = new Set(accepted.map((link) => (link.fromId === parentId ? link.toId : link.fromId)));
|
|
455
|
+
return listAnswer(
|
|
456
|
+
ctx,
|
|
457
|
+
candidates.filter((record) => ids.has(record.id)),
|
|
458
|
+
listOptionsOf(typeConfig),
|
|
459
|
+
);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// ---------------------------------------------------------------------------------------------
|
|
463
|
+
// Writes
|
|
464
|
+
// ---------------------------------------------------------------------------------------------
|
|
465
|
+
|
|
466
|
+
/** The root record a record belongs to: itself (root type), a relationship to the root, or a related stored record. */
|
|
467
|
+
export async function rootIdForResource(
|
|
468
|
+
store: DeviceStore,
|
|
469
|
+
config: OfflineConfig,
|
|
470
|
+
resource: any,
|
|
471
|
+
): Promise<string | undefined> {
|
|
472
|
+
const rootType = config.rootModule.name;
|
|
473
|
+
if (resource?.type === rootType && resource.id) return resource.id;
|
|
474
|
+
|
|
475
|
+
const identifiers = Object.values(resource?.relationships ?? {}).flatMap(identifiersOf);
|
|
476
|
+
const root = identifiers.find((identifier) => identifier.type === rootType);
|
|
477
|
+
if (root) return root.id;
|
|
478
|
+
|
|
479
|
+
for (const identifier of identifiers) {
|
|
480
|
+
const stored = await store.getRecord(identifier.type, identifier.id);
|
|
481
|
+
if (stored?.rootId) return stored.rootId;
|
|
482
|
+
}
|
|
483
|
+
return undefined;
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
async function linkRelationships(
|
|
487
|
+
store: DeviceStore,
|
|
488
|
+
typeConfig: OfflineTypeConfig | undefined,
|
|
489
|
+
record: { type: string; id: string },
|
|
490
|
+
before: Record<string, any>,
|
|
491
|
+
after: Record<string, any>,
|
|
492
|
+
): Promise<void> {
|
|
493
|
+
for (const [name, relationship] of Object.entries(after)) {
|
|
494
|
+
const previous = new Map(identifiersOf(before[name]).map((identifier) => [identifier.id, identifier]));
|
|
495
|
+
const next = new Map(identifiersOf(relationship).map((identifier) => [identifier.id, identifier]));
|
|
496
|
+
const kinds = linkKindsOf(typeConfig, name);
|
|
497
|
+
|
|
498
|
+
for (const [id] of previous) {
|
|
499
|
+
if (next.has(id)) continue;
|
|
500
|
+
// Only the links of this relationship (by name or Neo4j type); any kind when it is not known.
|
|
501
|
+
const links = kinds
|
|
502
|
+
? await linksOfKind(store, record.id, kinds, "both")
|
|
503
|
+
: [...(await store.linksFrom(record.id)), ...(await store.linksTo(record.id))];
|
|
504
|
+
for (const link of links) {
|
|
505
|
+
if (link.fromId === id || link.toId === id) {
|
|
506
|
+
await store.deleteLink({ fromId: link.fromId, toId: link.toId, kind: link.kind });
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
for (const [id, identifier] of next) {
|
|
511
|
+
if (previous.has(id)) continue;
|
|
512
|
+
await store.putLink({
|
|
513
|
+
fromType: record.type,
|
|
514
|
+
fromId: record.id,
|
|
515
|
+
toType: identifier.type,
|
|
516
|
+
toId: id,
|
|
517
|
+
kind: linkKindFor(typeConfig, name),
|
|
518
|
+
props: {},
|
|
519
|
+
});
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/** The `data` of a JSON:API document a model built (`createJsonApi()` or a dedicated method). */
|
|
525
|
+
export type ResourceDocumentData = {
|
|
526
|
+
attributes?: Record<string, unknown>;
|
|
527
|
+
relationships?: Record<string, unknown>;
|
|
528
|
+
meta?: Record<string, unknown>;
|
|
529
|
+
};
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* The resource a create document stands for, under `identity`: the document's attributes,
|
|
533
|
+
* relationships and meta, as the device stores a record it creates.
|
|
534
|
+
*/
|
|
535
|
+
export function resourceFromDocument(document: any, identity: { type: string; id: string }): any {
|
|
536
|
+
const data: ResourceDocumentData = document?.data ?? {};
|
|
537
|
+
const resource: any = { type: identity.type, id: identity.id };
|
|
538
|
+
if (data.attributes) resource.attributes = { ...data.attributes };
|
|
539
|
+
if (data.relationships) resource.relationships = { ...data.relationships };
|
|
540
|
+
if (data.meta) resource.meta = { ...data.meta };
|
|
541
|
+
return resource;
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/**
|
|
545
|
+
* A stored resource with a document's `data` written over it: attributes and relationships merged
|
|
546
|
+
* by name, and meta merged when the document carries one, as the device applies an edit.
|
|
547
|
+
*/
|
|
548
|
+
export function mergeResource(before: any, identity: { type: string; id: string }, data: ResourceDocumentData): any {
|
|
549
|
+
const previous = before ?? identity;
|
|
550
|
+
const resource: any = {
|
|
551
|
+
...previous,
|
|
552
|
+
type: identity.type,
|
|
553
|
+
id: identity.id,
|
|
554
|
+
attributes: { ...(previous.attributes ?? {}), ...(data?.attributes ?? {}) },
|
|
555
|
+
relationships: { ...(previous.relationships ?? {}), ...(data?.relationships ?? {}) },
|
|
556
|
+
};
|
|
557
|
+
if (data?.meta) resource.meta = { ...(previous.meta ?? {}), ...data.meta };
|
|
558
|
+
return resource;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function newId(): string {
|
|
562
|
+
if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") return crypto.randomUUID();
|
|
563
|
+
return `local-${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
async function create(ctx: LocalAnswerContext, config: OfflineConfig, type: string): Promise<OfflineAnswer> {
|
|
567
|
+
const typeConfig = typeConfigOf(config, type);
|
|
568
|
+
const id: string = ctx.request.body?.data?.id ?? newId();
|
|
569
|
+
const resource = resourceFromDocument(ctx.request.body, { type, id });
|
|
570
|
+
const rootId = await rootIdForResource(ctx.store, config, resource);
|
|
571
|
+
if (!rootId) return needsConnectionAnswer(`A new ${type} that belongs to no root record needs a connection`);
|
|
572
|
+
if (isRootWriteLocked(rootId)) return needsConnectionAnswer("The root record is locked");
|
|
573
|
+
|
|
574
|
+
const record: StoredRecord = { type, id, rootId, resource, updatedAt: null, base: null, localOnly: true };
|
|
575
|
+
await ctx.store.transaction(async (store) => {
|
|
576
|
+
await store.putRecord(record);
|
|
577
|
+
await linkRelationships(store, typeConfig, { type, id }, {}, resource.relationships ?? {});
|
|
578
|
+
await store.enqueue({
|
|
579
|
+
rootId,
|
|
580
|
+
method: ctx.request.method,
|
|
581
|
+
path: queuePath(ctx),
|
|
582
|
+
body: ctx.request.body,
|
|
583
|
+
base: null,
|
|
584
|
+
});
|
|
585
|
+
});
|
|
586
|
+
|
|
587
|
+
return singleAnswer(ctx.store, record, 201);
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
async function update(ctx: LocalAnswerContext, config: OfflineConfig, type: string): Promise<OfflineAnswer> {
|
|
591
|
+
const id = ctx.params.id;
|
|
592
|
+
const existing = await ctx.store.getRecord(type, id);
|
|
593
|
+
if (!existing) return notOnDeviceAnswer(type, id);
|
|
594
|
+
if (isRootWriteLocked(existing.rootId)) return needsConnectionAnswer("The root record is locked");
|
|
595
|
+
|
|
596
|
+
const data = ctx.request.body?.data ?? {};
|
|
597
|
+
const before = existing.resource ?? { type, id };
|
|
598
|
+
const resource = mergeResource(before, { type, id }, data);
|
|
599
|
+
// A record created on this device has no server version to merge against.
|
|
600
|
+
const base = existing.localOnly ? null : (existing.base ?? before);
|
|
601
|
+
|
|
602
|
+
const record: StoredRecord = { ...existing, resource, base, localOnly: existing.localOnly };
|
|
603
|
+
await ctx.store.transaction(async (store) => {
|
|
604
|
+
await store.putRecord(record);
|
|
605
|
+
await linkRelationships(
|
|
606
|
+
store,
|
|
607
|
+
typeConfigOf(config, type),
|
|
608
|
+
{ type, id },
|
|
609
|
+
before.relationships ?? {},
|
|
610
|
+
data.relationships ?? {},
|
|
611
|
+
);
|
|
612
|
+
await store.enqueue({
|
|
613
|
+
rootId: existing.rootId,
|
|
614
|
+
method: ctx.request.method,
|
|
615
|
+
path: queuePath(ctx),
|
|
616
|
+
body: ctx.request.body,
|
|
617
|
+
recordType: type,
|
|
618
|
+
recordId: id,
|
|
619
|
+
base,
|
|
620
|
+
});
|
|
621
|
+
});
|
|
622
|
+
|
|
623
|
+
return singleAnswer(ctx.store, record);
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
async function remove(ctx: LocalAnswerContext, type: string): Promise<OfflineAnswer> {
|
|
627
|
+
const id = ctx.params.id;
|
|
628
|
+
const existing = await ctx.store.getRecord(type, id);
|
|
629
|
+
if (!existing) return notOnDeviceAnswer(type, id);
|
|
630
|
+
if (isRootWriteLocked(existing.rootId)) return needsConnectionAnswer("The root record is locked");
|
|
631
|
+
|
|
632
|
+
const base = existing.localOnly ? null : (existing.base ?? existing.resource);
|
|
633
|
+
|
|
634
|
+
await ctx.store.transaction(async (store) => {
|
|
635
|
+
const links = [...(await store.linksFrom(id)), ...(await store.linksTo(id))];
|
|
636
|
+
for (const link of links) await store.deleteLink({ fromId: link.fromId, toId: link.toId, kind: link.kind });
|
|
637
|
+
await store.deleteRecord(type, id);
|
|
638
|
+
await store.enqueue({
|
|
639
|
+
rootId: existing.rootId,
|
|
640
|
+
method: "DELETE",
|
|
641
|
+
path: queuePath(ctx),
|
|
642
|
+
recordType: type,
|
|
643
|
+
recordId: id,
|
|
644
|
+
base,
|
|
645
|
+
});
|
|
646
|
+
});
|
|
647
|
+
|
|
648
|
+
return { status: 204, body: undefined };
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** The request path as `EndpointCreator` built it, query string included. */
|
|
652
|
+
export function queuePath(ctx: Pick<LocalAnswerContext, "request">): string {
|
|
653
|
+
const query = ctx.request.query.toString();
|
|
654
|
+
return query ? `${ctx.request.path}?${query}` : ctx.request.path;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
// ---------------------------------------------------------------------------------------------
|
|
658
|
+
// The generic route list
|
|
659
|
+
// ---------------------------------------------------------------------------------------------
|
|
660
|
+
|
|
661
|
+
function readOnlyGuard(typeConfig: OfflineTypeConfig, answer: (ctx: LocalAnswerContext) => Promise<OfflineAnswer>) {
|
|
662
|
+
return async (ctx: LocalAnswerContext): Promise<OfflineAnswer> =>
|
|
663
|
+
typeConfig.readOnly ? needsConnectionAnswer(`${typeConfig.module.name} is read-only offline`) : answer(ctx);
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
/**
|
|
667
|
+
* One set of routes per configured type, in matching order: the root record list before the
|
|
668
|
+
* related list (both are `x/:id/<type>`), the related list last. These generic routes are not
|
|
669
|
+
* queued by the layer: each write answer queues itself, with its base.
|
|
670
|
+
*/
|
|
671
|
+
export function buildGenericRoutes(config: OfflineConfig): OfflineRoute[] {
|
|
672
|
+
const routes: OfflineRoute[] = [];
|
|
673
|
+
const route = (method: OfflineMethod, pattern: OfflineRoute["pattern"], answer: OfflineRoute["answer"]) =>
|
|
674
|
+
routes.push({ method, pattern, answer, queue: false });
|
|
675
|
+
|
|
676
|
+
for (const typeConfig of config.types) {
|
|
677
|
+
const type = typeConfig.module.name;
|
|
678
|
+
route("GET", [typeConfig.module, ":id"], (ctx) => getOne(ctx, type));
|
|
679
|
+
route("GET", [config.rootModule, ":rootId", typeConfig.module], (ctx) => rootList(ctx, config, typeConfig));
|
|
680
|
+
route(
|
|
681
|
+
"POST",
|
|
682
|
+
[typeConfig.module],
|
|
683
|
+
readOnlyGuard(typeConfig, (ctx) => create(ctx, config, type)),
|
|
684
|
+
);
|
|
685
|
+
route(
|
|
686
|
+
"PUT",
|
|
687
|
+
[typeConfig.module, ":id"],
|
|
688
|
+
readOnlyGuard(typeConfig, (ctx) => update(ctx, config, type)),
|
|
689
|
+
);
|
|
690
|
+
route(
|
|
691
|
+
"PATCH",
|
|
692
|
+
[typeConfig.module, ":id"],
|
|
693
|
+
readOnlyGuard(typeConfig, (ctx) => update(ctx, config, type)),
|
|
694
|
+
);
|
|
695
|
+
route(
|
|
696
|
+
"DELETE",
|
|
697
|
+
[typeConfig.module, ":id"],
|
|
698
|
+
readOnlyGuard(typeConfig, (ctx) => remove(ctx, type)),
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
for (const typeConfig of config.types) {
|
|
702
|
+
route("GET", [":parentType", ":parentId", typeConfig.module], (ctx) => relatedList(ctx, config, typeConfig));
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
return routes;
|
|
706
|
+
}
|