@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,67 @@
|
|
|
1
|
+
// Artwork images for the device copy, kept in Cache Storage under the exact URL the stored
|
|
2
|
+
// artwork record holds (`attributes.imageUrl`). The service worker answers those URLs from the
|
|
3
|
+
// same cache when the network fails. The image downloads are plain GETs of the URLs the API
|
|
4
|
+
// already returned (decision recorded in the plan).
|
|
5
|
+
|
|
6
|
+
// The service worker names its caches `${appId}-images` and `${appId}-offline-${revision}`
|
|
7
|
+
// (pwa/sw.js). The page does not load sw-config.js, so it finds the app id from those names;
|
|
8
|
+
// Cache Storage is per origin, so only this app's caches are listed.
|
|
9
|
+
const IMAGES_CACHE_SUFFIX = "-images";
|
|
10
|
+
const OFFLINE_CACHE_MARKER = "-offline-";
|
|
11
|
+
|
|
12
|
+
async function imagesCacheName(): Promise<string | undefined> {
|
|
13
|
+
if (typeof caches === "undefined") return undefined;
|
|
14
|
+
const keys = await caches.keys();
|
|
15
|
+
const existing = keys.find((key) => key.endsWith(IMAGES_CACHE_SUFFIX));
|
|
16
|
+
if (existing) return existing;
|
|
17
|
+
|
|
18
|
+
const offline = keys.find((key) => key.includes(OFFLINE_CACHE_MARKER));
|
|
19
|
+
if (!offline) return undefined;
|
|
20
|
+
return `${offline.slice(0, offline.indexOf(OFFLINE_CACHE_MARKER))}${IMAGES_CACHE_SUFFIX}`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The image URL a stored resource carries, if any. */
|
|
24
|
+
export function imageUrlOf(resource: any): string | undefined {
|
|
25
|
+
const url = resource?.attributes?.imageUrl;
|
|
26
|
+
return typeof url === "string" && url.length > 0 ? url : undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Makes sure `url` is in the images cache: downloaded when missing. When `previousUrl` is a
|
|
31
|
+
* different URL of the same artwork, its entry is removed. A failed download is logged and
|
|
32
|
+
* retried at the next sync.
|
|
33
|
+
*/
|
|
34
|
+
export async function cacheArtworkImage(url: string, previousUrl?: string): Promise<void> {
|
|
35
|
+
const name = await imagesCacheName();
|
|
36
|
+
if (!name) return;
|
|
37
|
+
const cache = await caches.open(name);
|
|
38
|
+
|
|
39
|
+
if (previousUrl && previousUrl !== url) await cache.delete(previousUrl);
|
|
40
|
+
if (await cache.match(url)) return;
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const response = await fetch(url);
|
|
44
|
+
if (!response.ok) {
|
|
45
|
+
console.warn("[offline] could not download an artwork image", url, response.status);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
await cache.put(url, response);
|
|
49
|
+
} catch (error) {
|
|
50
|
+
console.warn("[offline] could not download an artwork image", url, error);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Removes the cached copy of `url`. */
|
|
55
|
+
export async function removeArtworkImage(url: string): Promise<void> {
|
|
56
|
+
const name = await imagesCacheName();
|
|
57
|
+
if (!name) return;
|
|
58
|
+
const cache = await caches.open(name);
|
|
59
|
+
await cache.delete(url);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Deletes this app's whole images cache (logout, or another user signing in). */
|
|
63
|
+
export async function deleteImagesCaches(): Promise<void> {
|
|
64
|
+
if (typeof caches === "undefined") return;
|
|
65
|
+
const keys = await caches.keys();
|
|
66
|
+
await Promise.all(keys.filter((key) => key.endsWith(IMAGES_CACHE_SUFFIX)).map((key) => caches.delete(key)));
|
|
67
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { EndpointCreator } from "../../core/endpoint/EndpointCreator";
|
|
2
|
+
import { getOfflineConfig, getOfflineStore } from "../config";
|
|
3
|
+
import { getOfflineState } from "../state";
|
|
4
|
+
import type { Conflict, DeviceStore, QueueEntry } from "../store/DeviceStore";
|
|
5
|
+
import type { OfflineChangeResolution } from "./models/OfflineChangeSetInterface";
|
|
6
|
+
import { refreshOfflineCounts } from "./counts";
|
|
7
|
+
import { drainQueue } from "./drain-queue";
|
|
8
|
+
|
|
9
|
+
export { useConflicts } from "./counts";
|
|
10
|
+
|
|
11
|
+
export type ConflictChoice =
|
|
12
|
+
| { kind: "mine" }
|
|
13
|
+
| { kind: "theirs" }
|
|
14
|
+
/**
|
|
15
|
+
* Per conflicting field: whose value wins. `mergedBody`: the JSON:API document the record
|
|
16
|
+
* type's model built with createJsonApi() holding only the fields chosen "merged".
|
|
17
|
+
*/
|
|
18
|
+
| { kind: "fields"; choices: Record<string, "mine" | "theirs" | "merged">; mergedBody?: any }
|
|
19
|
+
| { kind: "restore" }
|
|
20
|
+
| { kind: "accept-deletion" }
|
|
21
|
+
| { kind: "delete-anyway" }
|
|
22
|
+
| { kind: "retry" }
|
|
23
|
+
| { kind: "discard" };
|
|
24
|
+
|
|
25
|
+
function updatedAtOf(resource: any): Date | null {
|
|
26
|
+
const value = resource?.attributes?.updatedAt;
|
|
27
|
+
if (!value) return null;
|
|
28
|
+
const date = new Date(value);
|
|
29
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Writes the chosen "theirs" and "merged" values of the conflicting fields into the stored record. */
|
|
33
|
+
async function applyChoicesToRecord(
|
|
34
|
+
store: DeviceStore,
|
|
35
|
+
conflict: Conflict,
|
|
36
|
+
choices: Record<string, "mine" | "theirs" | "merged">,
|
|
37
|
+
mergedBody: any,
|
|
38
|
+
): Promise<void> {
|
|
39
|
+
if (!conflict.recordType || !conflict.recordId) return;
|
|
40
|
+
const record = await store.getRecord(conflict.recordType, conflict.recordId);
|
|
41
|
+
if (!record) return;
|
|
42
|
+
const attributes = { ...(record.resource?.attributes ?? {}) };
|
|
43
|
+
const relationships = { ...(record.resource?.relationships ?? {}) };
|
|
44
|
+
for (const [field, choice] of Object.entries(choices)) {
|
|
45
|
+
const source = choice === "theirs" ? conflict.theirs : choice === "merged" ? mergedBody?.data : undefined;
|
|
46
|
+
if (!source) continue;
|
|
47
|
+
if (source.attributes && field in source.attributes) attributes[field] = source.attributes[field];
|
|
48
|
+
else if (source.relationships && field in source.relationships) relationships[field] = source.relationships[field];
|
|
49
|
+
}
|
|
50
|
+
await store.putRecord({ ...record, resource: { ...record.resource, attributes, relationships } });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function rootIdOf(store: DeviceStore, conflict: Conflict, entry: QueueEntry | undefined): Promise<string> {
|
|
54
|
+
if (entry) return entry.rootId;
|
|
55
|
+
if (conflict.recordType && conflict.recordId) {
|
|
56
|
+
const record = await store.getRecord(conflict.recordType, conflict.recordId);
|
|
57
|
+
if (record) return record.rootId;
|
|
58
|
+
}
|
|
59
|
+
throw new Error(`[offline] conflict ${conflict.id} belongs to no root record on this device`);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The collection path of a record type, from the configured module (EndpointCreator). */
|
|
63
|
+
function collectionPath(recordType: string): string {
|
|
64
|
+
const module = getOfflineConfig().types.find((typeConfig) => typeConfig.module.name === recordType)?.module;
|
|
65
|
+
return new EndpointCreator({ endpoint: module ?? recordType }).generate();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Queues the conflicting change again, unchanged apart from `resolution`, and drops the old entry. */
|
|
69
|
+
async function requeueOriginal(
|
|
70
|
+
store: DeviceStore,
|
|
71
|
+
conflict: Conflict,
|
|
72
|
+
entry: QueueEntry | undefined,
|
|
73
|
+
fallbackMethod: "PATCH" | "DELETE",
|
|
74
|
+
resolution: OfflineChangeResolution,
|
|
75
|
+
): Promise<void> {
|
|
76
|
+
const method = entry?.method ?? fallbackMethod;
|
|
77
|
+
const body = method === "DELETE" ? undefined : (entry?.body ?? conflict.mine ?? undefined);
|
|
78
|
+
await store.enqueue({
|
|
79
|
+
rootId: await rootIdOf(store, conflict, entry),
|
|
80
|
+
method,
|
|
81
|
+
path:
|
|
82
|
+
entry?.path ??
|
|
83
|
+
new EndpointCreator({ endpoint: collectionPath(conflict.recordType!), id: conflict.recordId }).generate(),
|
|
84
|
+
...(body !== undefined ? { body } : {}),
|
|
85
|
+
recordType: entry?.recordType ?? conflict.recordType,
|
|
86
|
+
recordId: entry?.recordId ?? conflict.recordId,
|
|
87
|
+
base: entry ? entry.base : (conflict.base ?? null),
|
|
88
|
+
// QueueEntry["resolution"] is the store's copy of OfflineChange["resolution"].
|
|
89
|
+
resolution: resolution as QueueEntry["resolution"],
|
|
90
|
+
});
|
|
91
|
+
if (entry) await store.removeQueueEntry(entry.seq);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
async function resolve(store: DeviceStore, conflict: Conflict, choice: ConflictChoice): Promise<void> {
|
|
95
|
+
const entry = (await store.queue()).find((candidate) => candidate.seq === conflict.queueSeq);
|
|
96
|
+
const dropEntry = async () => {
|
|
97
|
+
if (entry) await store.removeQueueEntry(entry.seq);
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
switch (choice.kind) {
|
|
101
|
+
case "mine":
|
|
102
|
+
case "fields": {
|
|
103
|
+
if (choice.kind === "fields" && Object.values(choice.choices).every((value) => value === "theirs")) {
|
|
104
|
+
return resolve(store, conflict, { kind: "theirs" });
|
|
105
|
+
}
|
|
106
|
+
// The ORIGINAL change goes back (original body and base, so the server's three-way merge
|
|
107
|
+
// keeps every field only the server changed), carrying how each conflicting field is settled.
|
|
108
|
+
const resolution: OfflineChangeResolution =
|
|
109
|
+
choice.kind === "mine"
|
|
110
|
+
? { kind: "mine" }
|
|
111
|
+
: {
|
|
112
|
+
kind: "merged",
|
|
113
|
+
choices: choice.choices,
|
|
114
|
+
...(choice.mergedBody !== undefined ? { mergedBody: choice.mergedBody } : {}),
|
|
115
|
+
};
|
|
116
|
+
await requeueOriginal(store, conflict, entry, "PATCH", resolution);
|
|
117
|
+
if (choice.kind === "fields") await applyChoicesToRecord(store, conflict, choice.choices, choice.mergedBody);
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
case "theirs": {
|
|
121
|
+
// Only the device changes: the server's version replaces mine.
|
|
122
|
+
await dropEntry();
|
|
123
|
+
if (conflict.recordType && conflict.recordId) {
|
|
124
|
+
if (conflict.theirs) {
|
|
125
|
+
await store.putRecord({
|
|
126
|
+
type: conflict.recordType,
|
|
127
|
+
id: conflict.recordId,
|
|
128
|
+
rootId: await rootIdOf(store, conflict, entry),
|
|
129
|
+
resource: conflict.theirs,
|
|
130
|
+
updatedAt: updatedAtOf(conflict.theirs),
|
|
131
|
+
base: null,
|
|
132
|
+
localOnly: false,
|
|
133
|
+
});
|
|
134
|
+
} else {
|
|
135
|
+
await store.deleteRecord(conflict.recordType, conflict.recordId);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
break;
|
|
139
|
+
}
|
|
140
|
+
case "restore": {
|
|
141
|
+
// Deleted on the server: created again with my version.
|
|
142
|
+
if (!conflict.recordType) throw new Error(`[offline] conflict ${conflict.id} has no record to restore`);
|
|
143
|
+
const body = conflict.mine ?? entry?.body;
|
|
144
|
+
const rootId = await rootIdOf(store, conflict, entry);
|
|
145
|
+
await store.enqueue({
|
|
146
|
+
rootId,
|
|
147
|
+
method: "POST",
|
|
148
|
+
path: collectionPath(conflict.recordType),
|
|
149
|
+
body,
|
|
150
|
+
recordType: conflict.recordType,
|
|
151
|
+
recordId: conflict.recordId,
|
|
152
|
+
base: null,
|
|
153
|
+
});
|
|
154
|
+
await dropEntry();
|
|
155
|
+
if (conflict.recordId) {
|
|
156
|
+
const record = await store.getRecord(conflict.recordType, conflict.recordId);
|
|
157
|
+
// localOnly keeps it on the device until the server has it again.
|
|
158
|
+
if (record) await store.putRecord({ ...record, base: null, localOnly: true });
|
|
159
|
+
else if (body?.data)
|
|
160
|
+
await store.putRecord({
|
|
161
|
+
type: conflict.recordType,
|
|
162
|
+
id: conflict.recordId,
|
|
163
|
+
rootId,
|
|
164
|
+
resource: body.data,
|
|
165
|
+
updatedAt: null,
|
|
166
|
+
base: null,
|
|
167
|
+
localOnly: true,
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
case "accept-deletion": {
|
|
173
|
+
await dropEntry();
|
|
174
|
+
if (conflict.recordType && conflict.recordId) await store.deleteRecord(conflict.recordType, conflict.recordId);
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
case "delete-anyway": {
|
|
178
|
+
// The original DELETE with a resolution: the server skips the changed-on-server check.
|
|
179
|
+
await requeueOriginal(store, conflict, entry, "DELETE", { kind: "mine" });
|
|
180
|
+
break;
|
|
181
|
+
}
|
|
182
|
+
case "retry": {
|
|
183
|
+
if (entry) await store.setQueueStatus(entry.seq, "pending");
|
|
184
|
+
break;
|
|
185
|
+
}
|
|
186
|
+
case "discard": {
|
|
187
|
+
await dropEntry();
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
await store.removeConflict(conflict.id);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Applies the GM's choice for one conflict. "keep mine", per-field choices, "restore" and "delete anyway"
|
|
197
|
+
* queue one more change (sent with the next package); "keep theirs" and "let the deletion
|
|
198
|
+
* stand" only update the device. Online, the queue is sent right away.
|
|
199
|
+
*/
|
|
200
|
+
export async function resolveConflict(id: string, choice: ConflictChoice): Promise<void> {
|
|
201
|
+
const store = await getOfflineStore();
|
|
202
|
+
const conflict = (await store.conflicts()).find((candidate) => candidate.id === id);
|
|
203
|
+
if (!conflict) throw new Error(`[offline] no conflict ${id}`);
|
|
204
|
+
|
|
205
|
+
await store.transaction((tx) => resolve(tx, conflict, choice));
|
|
206
|
+
await refreshOfflineCounts(store);
|
|
207
|
+
|
|
208
|
+
if (getOfflineState().online) {
|
|
209
|
+
drainQueue().catch((error) => console.warn("[offline] sending the resolved change failed", error));
|
|
210
|
+
}
|
|
211
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { useSyncExternalStore } from "react";
|
|
2
|
+
import { getOfflineConfig, getOfflineStore, isOfflineConfigured } from "../config";
|
|
3
|
+
import { setOfflineState } from "../state";
|
|
4
|
+
import type { Conflict, DeviceStore } from "../store/DeviceStore";
|
|
5
|
+
|
|
6
|
+
const EMPTY: Conflict[] = [];
|
|
7
|
+
let _conflicts: Conflict[] = EMPTY;
|
|
8
|
+
const _listeners = new Set<() => void>();
|
|
9
|
+
|
|
10
|
+
function subscribe(listener: () => void): () => void {
|
|
11
|
+
_listeners.add(listener);
|
|
12
|
+
return () => {
|
|
13
|
+
_listeners.delete(listener);
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Re-reads the queue and the conflicts: updates `pending`/`conflicts` and the `useConflicts` list. */
|
|
18
|
+
export async function refreshOfflineCounts(store?: DeviceStore): Promise<void> {
|
|
19
|
+
if (!store) {
|
|
20
|
+
if (!isOfflineConfigured() || !getOfflineConfig().getUserId()) return;
|
|
21
|
+
store = await getOfflineStore();
|
|
22
|
+
}
|
|
23
|
+
const entries = await store.queue();
|
|
24
|
+
_conflicts = await store.conflicts();
|
|
25
|
+
for (const listener of [..._listeners]) listener();
|
|
26
|
+
setOfflineState({
|
|
27
|
+
pending: entries.filter((entry) => entry.status !== "conflict").length,
|
|
28
|
+
conflicts: _conflicts.length,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The unresolved conflicts of the signed-in user's device. */
|
|
33
|
+
export function useConflicts(): Conflict[] {
|
|
34
|
+
return useSyncExternalStore(
|
|
35
|
+
subscribe,
|
|
36
|
+
() => _conflicts,
|
|
37
|
+
() => EMPTY,
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { EndpointCreator } from "../../core/endpoint/EndpointCreator";
|
|
2
|
+
import { Modules } from "../../core/registry/ModuleRegistry";
|
|
3
|
+
import { buildUrl, JsonApiPost } from "../../unified/JsonApiRequest";
|
|
4
|
+
import { getOfflineConfig, getOfflineStore, isOfflineConfigured } from "../config";
|
|
5
|
+
import { allowWhileReconnecting, getOfflineState, setOfflineState } from "../state";
|
|
6
|
+
import type { DeviceStore, QueueEntry } from "../store/DeviceStore";
|
|
7
|
+
import { isRootLocked } from "../store/lifecycle";
|
|
8
|
+
import { writeThrough } from "../write-through";
|
|
9
|
+
import { refreshOfflineCounts } from "./counts";
|
|
10
|
+
import { OfflineChangeSet } from "./models/OfflineChangeSet";
|
|
11
|
+
import { OfflineChange, OfflineChangeResult } from "./models/OfflineChangeSetInterface";
|
|
12
|
+
import { currentLocale, refreshSession } from "./session-refresh";
|
|
13
|
+
import { recordKeysOfEntry, waitingRecordKeys } from "./waiting";
|
|
14
|
+
|
|
15
|
+
export type DrainResult = { applied: number; conflicts: number; stoppedForLogin: boolean };
|
|
16
|
+
|
|
17
|
+
const syncedTypeNames = () => new Set(getOfflineConfig().types.map((typeConfig) => typeConfig.module.name));
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* The body as sent: each resource's server-owned fields (AI summaries, tl;dr, `aiStatus`, …) are
|
|
21
|
+
* left out of its attributes. The stored body and the stored record keep them.
|
|
22
|
+
*/
|
|
23
|
+
function withoutServerOwnedFields(body: any): any {
|
|
24
|
+
const data = body?.data;
|
|
25
|
+
if (!data) return body;
|
|
26
|
+
const owned = new Map(
|
|
27
|
+
getOfflineConfig().types.map((typeConfig) => [typeConfig.module.name, typeConfig.serverOwnedFields]),
|
|
28
|
+
);
|
|
29
|
+
const strip = (resource: any) => {
|
|
30
|
+
const fields = owned.get(resource?.type) ?? [];
|
|
31
|
+
if (!resource?.attributes || fields.length === 0) return resource;
|
|
32
|
+
const attributes = { ...resource.attributes };
|
|
33
|
+
for (const field of fields) delete attributes[field];
|
|
34
|
+
return { ...resource, attributes };
|
|
35
|
+
};
|
|
36
|
+
return { ...body, data: Array.isArray(data) ? data.map(strip) : strip(data) };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function toChange(entry: QueueEntry): OfflineChange {
|
|
40
|
+
const change: OfflineChange = { seq: entry.seq, method: entry.method as OfflineChange["method"], path: entry.path };
|
|
41
|
+
if (entry.body !== undefined) change.body = withoutServerOwnedFields(entry.body);
|
|
42
|
+
if (entry.recordType) change.recordType = entry.recordType;
|
|
43
|
+
if (entry.recordId) change.recordId = entry.recordId;
|
|
44
|
+
change.base = entry.base ?? null;
|
|
45
|
+
if (entry.resolution) change.resolution = entry.resolution;
|
|
46
|
+
return change;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The merge the server settled, as a resource the record type's model can rehydrate: the server's
|
|
51
|
+
* current resource with the merged attribute values laid over it (decision piece 2: the library
|
|
52
|
+
* shapes stored resources). Undefined when the server gave no merge or no current record.
|
|
53
|
+
*/
|
|
54
|
+
function mergedResource(current: any, merged: Record<string, unknown> | undefined): any | undefined {
|
|
55
|
+
if (!merged || !current?.type) return undefined;
|
|
56
|
+
return { ...current, attributes: { ...(current.attributes ?? {}), ...merged } };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function newPackageId(): string {
|
|
60
|
+
return globalThis.crypto.randomUUID();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* The id of the package to send: the id of a package that never got its answer (the page
|
|
65
|
+
* closed, the network dropped) when one of the entries carries it, so the server applies it
|
|
66
|
+
* once; a fresh id otherwise.
|
|
67
|
+
*/
|
|
68
|
+
function packageIdFor(entries: QueueEntry[]): string {
|
|
69
|
+
return entries.find((entry) => entry.packageId)?.packageId ?? newPackageId();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** The entries to send: pending, of unlocked root records, not behind a conflict on the same record. */
|
|
73
|
+
async function entriesToSend(store: DeviceStore): Promise<QueueEntry[]> {
|
|
74
|
+
const all = await store.queue();
|
|
75
|
+
const typeNames = syncedTypeNames();
|
|
76
|
+
const blocked = waitingRecordKeys(
|
|
77
|
+
all.filter((entry) => entry.status === "conflict"),
|
|
78
|
+
typeNames,
|
|
79
|
+
);
|
|
80
|
+
const lockedIds = new Set(getOfflineState().lockedRootIds);
|
|
81
|
+
const lockedByRoot = new Map<string, boolean>();
|
|
82
|
+
const now = new Date();
|
|
83
|
+
|
|
84
|
+
const result: QueueEntry[] = [];
|
|
85
|
+
for (const entry of all) {
|
|
86
|
+
// "sending" is a package that never got its answer (the page closed): it is sent again.
|
|
87
|
+
if (entry.status !== "pending" && entry.status !== "sending") continue;
|
|
88
|
+
if (recordKeysOfEntry(entry, typeNames).some((key) => blocked.has(key))) continue;
|
|
89
|
+
|
|
90
|
+
if (!lockedByRoot.has(entry.rootId)) {
|
|
91
|
+
lockedByRoot.set(
|
|
92
|
+
entry.rootId,
|
|
93
|
+
lockedIds.has(entry.rootId) || isRootLocked(await store.getSyncState(entry.rootId), now),
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
if (lockedByRoot.get(entry.rootId)) continue;
|
|
97
|
+
result.push(entry);
|
|
98
|
+
}
|
|
99
|
+
return result.sort((a, b) => a.seq - b.seq);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function applyResult(
|
|
103
|
+
store: DeviceStore,
|
|
104
|
+
entry: QueueEntry,
|
|
105
|
+
result: OfflineChangeResult,
|
|
106
|
+
): Promise<"applied" | "conflict" | "pending"> {
|
|
107
|
+
if (result.status === "applied") {
|
|
108
|
+
await store.removeQueueEntry(entry.seq);
|
|
109
|
+
// The record's base is kept only while it has unsent changes; cleared first, so the
|
|
110
|
+
// write-through below may store the server's answer (it never overwrites a record that
|
|
111
|
+
// still has a base or a waiting change).
|
|
112
|
+
const typeNames = syncedTypeNames();
|
|
113
|
+
const stillWaiting = waitingRecordKeys(await store.queue(), typeNames);
|
|
114
|
+
for (const key of recordKeysOfEntry(entry, typeNames)) {
|
|
115
|
+
if (stillWaiting.has(key)) continue;
|
|
116
|
+
const separator = key.indexOf(":");
|
|
117
|
+
const record = await store.getRecord(key.slice(0, separator), key.slice(separator + 1));
|
|
118
|
+
if (record && (record.base !== null || record.localOnly)) {
|
|
119
|
+
await store.putRecord({ ...record, base: null, localOnly: false });
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
if (result.response) {
|
|
123
|
+
await writeThrough(
|
|
124
|
+
{ method: entry.method, url: entry.path },
|
|
125
|
+
{ ok: true, status: 200, statusText: "OK", data: result.response },
|
|
126
|
+
);
|
|
127
|
+
} else if (entry.method === "DELETE") {
|
|
128
|
+
await writeThrough(
|
|
129
|
+
{ method: entry.method, url: entry.path },
|
|
130
|
+
{ ok: true, status: 204, statusText: "OK", data: undefined },
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
return "applied";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (result.status === "conflict" || result.status === "could-not-apply") {
|
|
137
|
+
await store.addConflict({
|
|
138
|
+
queueSeq: entry.seq,
|
|
139
|
+
kind: result.status === "could-not-apply" ? "could-not-apply" : (result.conflict ?? "fields"),
|
|
140
|
+
recordType: entry.recordType,
|
|
141
|
+
recordId: entry.recordId,
|
|
142
|
+
mine: entry.body ?? null,
|
|
143
|
+
theirs: result.current ?? null,
|
|
144
|
+
base: entry.base ?? null,
|
|
145
|
+
merged: mergedResource(result.current, result.merged),
|
|
146
|
+
fields: result.conflictFields ?? [],
|
|
147
|
+
blocks: result.conflictBlocks,
|
|
148
|
+
reason: result.detail,
|
|
149
|
+
});
|
|
150
|
+
await store.setQueueStatus(entry.seq, "conflict");
|
|
151
|
+
return "conflict";
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
await store.setQueueStatus(entry.seq, "pending");
|
|
155
|
+
return "pending";
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
async function drain(): Promise<DrainResult> {
|
|
159
|
+
const outcome: DrainResult = { applied: 0, conflicts: 0, stoppedForLogin: false };
|
|
160
|
+
if (!isOfflineConfigured() || !getOfflineConfig().getUserId()) return outcome;
|
|
161
|
+
|
|
162
|
+
const store = await getOfflineStore();
|
|
163
|
+
const entries = await entriesToSend(store);
|
|
164
|
+
if (entries.length === 0) return outcome;
|
|
165
|
+
|
|
166
|
+
const session = await refreshSession();
|
|
167
|
+
if (session === "unreachable") return outcome;
|
|
168
|
+
if (session === "expired") return { ...outcome, stoppedForLogin: true };
|
|
169
|
+
|
|
170
|
+
setOfflineState({ draining: true });
|
|
171
|
+
const handled = new Set<number>();
|
|
172
|
+
try {
|
|
173
|
+
const packageId = packageIdFor(entries);
|
|
174
|
+
// Stored with the entries before sending: an unanswered package keeps its id for the resend.
|
|
175
|
+
await store.setQueuePackage(
|
|
176
|
+
entries.map((entry) => entry.seq),
|
|
177
|
+
packageId,
|
|
178
|
+
);
|
|
179
|
+
for (const entry of entries) await store.setQueueStatus(entry.seq, "sending");
|
|
180
|
+
|
|
181
|
+
// ONE request with every waiting change, whatever its size: never split (Carlo, 2026-11-01).
|
|
182
|
+
const endpoint = new EndpointCreator({ endpoint: Modules.OfflineChangeSet }).generate();
|
|
183
|
+
// During a reconnect attempt the session is still offline: this request goes to the network.
|
|
184
|
+
const release = allowWhileReconnecting(buildUrl(endpoint));
|
|
185
|
+
let response: Awaited<ReturnType<typeof JsonApiPost>>;
|
|
186
|
+
try {
|
|
187
|
+
response = await JsonApiPost({
|
|
188
|
+
classKey: Modules.OfflineChangeSet,
|
|
189
|
+
endpoint,
|
|
190
|
+
body: { id: packageId, changes: entries.map(toChange) },
|
|
191
|
+
language: currentLocale(),
|
|
192
|
+
});
|
|
193
|
+
} finally {
|
|
194
|
+
release();
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if (!response.ok) {
|
|
198
|
+
if (response.response === 401) outcome.stoppedForLogin = true;
|
|
199
|
+
return outcome;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// The server answered for this package: every entry left in the queue leaves it.
|
|
203
|
+
await store.setQueuePackage(
|
|
204
|
+
entries.map((entry) => entry.seq),
|
|
205
|
+
null,
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
const results = new Map((response.data as OfflineChangeSet).results.map((result) => [result.seq, result]));
|
|
209
|
+
for (const entry of entries) {
|
|
210
|
+
const result = results.get(entry.seq);
|
|
211
|
+
const status = result ? await applyResult(store, entry, result) : "pending";
|
|
212
|
+
if (!result) await store.setQueueStatus(entry.seq, "pending");
|
|
213
|
+
handled.add(entry.seq);
|
|
214
|
+
if (status === "applied") outcome.applied++;
|
|
215
|
+
if (status === "conflict") outcome.conflicts++;
|
|
216
|
+
}
|
|
217
|
+
return outcome;
|
|
218
|
+
} catch (error) {
|
|
219
|
+
console.error("[offline] sending the waiting changes failed", error);
|
|
220
|
+
return outcome;
|
|
221
|
+
} finally {
|
|
222
|
+
// Nothing is dropped: every entry without an answer goes back to pending.
|
|
223
|
+
for (const entry of entries) {
|
|
224
|
+
if (!handled.has(entry.seq)) await store.setQueueStatus(entry.seq, "pending");
|
|
225
|
+
}
|
|
226
|
+
setOfflineState({ draining: false });
|
|
227
|
+
await refreshOfflineCounts(store);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
let _inFlight: Promise<DrainResult> | undefined;
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Sends every waiting change of the unlocked root records in ONE `offline-change-sets` request,
|
|
235
|
+
* after renewing the sign-in. Applied entries are removed and their answer written to the
|
|
236
|
+
* device copy; conflicts become Conflict rows; waiting entries stay pending. A network error
|
|
237
|
+
* or an expired sign-in stops it and keeps every entry.
|
|
238
|
+
*/
|
|
239
|
+
export function drainQueue(): Promise<DrainResult> {
|
|
240
|
+
if (!_inFlight) {
|
|
241
|
+
_inFlight = drain().finally(() => {
|
|
242
|
+
_inFlight = undefined;
|
|
243
|
+
});
|
|
244
|
+
}
|
|
245
|
+
return _inFlight;
|
|
246
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { getOfflineConfig, getOfflineStore } from "../config";
|
|
2
|
+
import { setOfflineState } from "../state";
|
|
3
|
+
import type { DeviceStore } from "../store/DeviceStore";
|
|
4
|
+
import { isRootLocked } from "../store/lifecycle";
|
|
5
|
+
|
|
6
|
+
let _lastSyncRoots: string[] = [];
|
|
7
|
+
|
|
8
|
+
/** Remembers the root records the last online `listSyncRoots()` returned. */
|
|
9
|
+
export function rememberSyncRoots(ids: string[]): void {
|
|
10
|
+
_lastSyncRoots = [...ids];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Every root record the device knows of: its stored root records, the root records with waiting
|
|
15
|
+
* changes, and the last sync roots (a locked root record is never downloaded, so it may have
|
|
16
|
+
* no stored record).
|
|
17
|
+
*/
|
|
18
|
+
export async function knownRootIds(store: DeviceStore): Promise<string[]> {
|
|
19
|
+
const config = getOfflineConfig();
|
|
20
|
+
const ids = new Set<string>(_lastSyncRoots);
|
|
21
|
+
for (const record of await store.listRecords({ type: config.rootModule.name })) ids.add(record.id);
|
|
22
|
+
for (const entry of await store.queue()) ids.add(entry.rootId);
|
|
23
|
+
return [...ids].sort();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Recomputes `lockedRootIds` from the stored sync states, now. */
|
|
27
|
+
export async function refreshLockedRoots(): Promise<void> {
|
|
28
|
+
const config = getOfflineConfig();
|
|
29
|
+
if (!config.getUserId()) return;
|
|
30
|
+
const store = await getOfflineStore();
|
|
31
|
+
const now = new Date();
|
|
32
|
+
const locked: string[] = [];
|
|
33
|
+
for (const id of await knownRootIds(store)) {
|
|
34
|
+
if (isRootLocked(await store.getSyncState(id), now)) locked.push(id);
|
|
35
|
+
}
|
|
36
|
+
setOfflineState({ lockedRootIds: locked });
|
|
37
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { AbstractApiData } from "../../../core/abstracts/AbstractApiData";
|
|
2
|
+
import { JsonApiHydratedDataInterface } from "../../../core/interfaces/JsonApiHydratedDataInterface";
|
|
3
|
+
import { Modules } from "../../../core/registry/ModuleRegistry";
|
|
4
|
+
import { OfflineChangeResult, OfflineChangeSetInput, OfflineChangeSetInterface } from "./OfflineChangeSetInterface";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The package of every waiting change the device sends in ONE request
|
|
8
|
+
* (`POST /offline-change-sets`). The answer carries one result per change.
|
|
9
|
+
*/
|
|
10
|
+
export class OfflineChangeSet extends AbstractApiData implements OfflineChangeSetInterface {
|
|
11
|
+
private _results?: OfflineChangeResult[];
|
|
12
|
+
|
|
13
|
+
get results(): OfflineChangeResult[] {
|
|
14
|
+
return this._results ?? [];
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
rehydrate(data: JsonApiHydratedDataInterface): this {
|
|
18
|
+
super.rehydrate(data);
|
|
19
|
+
|
|
20
|
+
this._results = data.jsonApi.attributes?.results ?? [];
|
|
21
|
+
|
|
22
|
+
return this;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
createJsonApi(data: OfflineChangeSetInput) {
|
|
26
|
+
return {
|
|
27
|
+
data: {
|
|
28
|
+
type: Modules.OfflineChangeSet.name,
|
|
29
|
+
id: data.id,
|
|
30
|
+
attributes: { changes: data.changes },
|
|
31
|
+
},
|
|
32
|
+
};
|
|
33
|
+
}
|
|
34
|
+
}
|