@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.
Files changed (254) hide show
  1. package/dist/{ApiData-DPKNfY-9.d.mts → ApiData-Be8yxCOX.d.mts} +2 -0
  2. package/dist/{ApiData-DPKNfY-9.d.ts → ApiData-Be8yxCOX.d.ts} +2 -0
  3. package/dist/{BlockNoteEditor-75ED2CN2.mjs → BlockNoteEditor-22Q4DJBU.mjs} +20 -8
  4. package/dist/BlockNoteEditor-22Q4DJBU.mjs.map +1 -0
  5. package/dist/{BlockNoteEditor-7M5QW2YQ.js → BlockNoteEditor-2VLPSEKS.js} +34 -22
  6. package/dist/BlockNoteEditor-2VLPSEKS.js.map +1 -0
  7. package/dist/{BlockNoteViewer-WGCDB7IJ.js → BlockNoteViewer-2XYASA5Y.js} +8 -5
  8. package/dist/BlockNoteViewer-2XYASA5Y.js.map +1 -0
  9. package/dist/{BlockNoteViewer-MTGS6YJV.mjs → BlockNoteViewer-NBW2H6LC.mjs} +7 -4
  10. package/dist/{BlockNoteViewer-MTGS6YJV.mjs.map → BlockNoteViewer-NBW2H6LC.mjs.map} +1 -1
  11. package/dist/{JsonApiRequest-A66XK5WT.mjs → JsonApiRequest-F6TMJ42G.mjs} +2 -2
  12. package/dist/JsonApiRequest-WISJZSDE.js +26 -0
  13. package/dist/{JsonApiRequest-FZGPLNUF.js.map → JsonApiRequest-WISJZSDE.js.map} +1 -1
  14. package/dist/billing/index.js +499 -494
  15. package/dist/billing/index.js.map +1 -1
  16. package/dist/billing/index.mjs +13 -8
  17. package/dist/billing/index.mjs.map +1 -1
  18. package/dist/chunk-27LQDGNP.mjs +317 -0
  19. package/dist/chunk-27LQDGNP.mjs.map +1 -0
  20. package/dist/{chunk-V3YDVJ63.js → chunk-2H342HNN.js} +948 -921
  21. package/dist/chunk-2H342HNN.js.map +1 -0
  22. package/dist/{chunk-KSYQEKJX.js → chunk-2SV4SS6N.js} +14 -14
  23. package/dist/{chunk-KSYQEKJX.js.map → chunk-2SV4SS6N.js.map} +1 -1
  24. package/dist/{chunk-4FNGWALA.mjs → chunk-47BBY5KA.mjs} +6 -4
  25. package/dist/{chunk-4FNGWALA.mjs.map → chunk-47BBY5KA.mjs.map} +1 -1
  26. package/dist/chunk-6JUCIWDC.mjs +570 -0
  27. package/dist/chunk-6JUCIWDC.mjs.map +1 -0
  28. package/dist/chunk-7DBJTNSU.mjs +1049 -0
  29. package/dist/chunk-7DBJTNSU.mjs.map +1 -0
  30. package/dist/chunk-7JZHPMGW.mjs +704 -0
  31. package/dist/chunk-7JZHPMGW.mjs.map +1 -0
  32. package/dist/{chunk-L6PYFQTO.js → chunk-7ZHTPFFT.js} +16 -14
  33. package/dist/chunk-7ZHTPFFT.js.map +1 -0
  34. package/dist/chunk-B6JGKDP2.js +214 -0
  35. package/dist/chunk-B6JGKDP2.js.map +1 -0
  36. package/dist/{chunk-3YW35YHQ.mjs → chunk-BH6JWIKI.mjs} +5 -5
  37. package/dist/chunk-BZ5J2FIV.js +570 -0
  38. package/dist/chunk-BZ5J2FIV.js.map +1 -0
  39. package/dist/{chunk-V5MI7RNB.mjs → chunk-DGWOVYEC.mjs} +2 -2
  40. package/dist/chunk-DOCIFE6A.mjs +212 -0
  41. package/dist/chunk-DOCIFE6A.mjs.map +1 -0
  42. package/dist/chunk-GB6DBFYY.js +25 -0
  43. package/dist/chunk-GB6DBFYY.js.map +1 -0
  44. package/dist/{chunk-RXQYEEKZ.mjs → chunk-H4NLDX5G.mjs} +51 -24
  45. package/dist/chunk-H4NLDX5G.mjs.map +1 -0
  46. package/dist/{chunk-EWCY6QJA.mjs → chunk-JJMMT24D.mjs} +37 -3
  47. package/dist/chunk-JJMMT24D.mjs.map +1 -0
  48. package/dist/{chunk-JTPRJNT2.js → chunk-KMW7UNL2.js} +38 -3
  49. package/dist/chunk-KMW7UNL2.js.map +1 -0
  50. package/dist/chunk-MHB36MYE.js +317 -0
  51. package/dist/chunk-MHB36MYE.js.map +1 -0
  52. package/dist/{chunk-FF4EIQ6H.js → chunk-MOGEGWXW.js} +1039 -1680
  53. package/dist/chunk-MOGEGWXW.js.map +1 -0
  54. package/dist/{chunk-AA52ORW2.mjs → chunk-P445QGOC.mjs} +61 -702
  55. package/dist/chunk-P445QGOC.mjs.map +1 -0
  56. package/dist/chunk-PV4Z5W6V.js +212 -0
  57. package/dist/chunk-PV4Z5W6V.js.map +1 -0
  58. package/dist/chunk-PXDUJ47N.js +1049 -0
  59. package/dist/chunk-PXDUJ47N.js.map +1 -0
  60. package/dist/{chunk-GHE4VT2R.js → chunk-QKNGZDSQ.js} +2 -2
  61. package/dist/{chunk-GHE4VT2R.js.map → chunk-QKNGZDSQ.js.map} +1 -1
  62. package/dist/chunk-W62MHDWM.js +704 -0
  63. package/dist/chunk-W62MHDWM.js.map +1 -0
  64. package/dist/chunk-XXP3TQHF.mjs +25 -0
  65. package/dist/chunk-XXP3TQHF.mjs.map +1 -0
  66. package/dist/chunk-YRKIFYOE.mjs +214 -0
  67. package/dist/chunk-YRKIFYOE.mjs.map +1 -0
  68. package/dist/{chunk-O3PDL2CV.js → chunk-Z4POQWFG.js} +40 -6
  69. package/dist/chunk-Z4POQWFG.js.map +1 -0
  70. package/dist/{chunk-PI56A4DG.mjs → chunk-ZLTOUZN2.mjs} +37 -2
  71. package/dist/chunk-ZLTOUZN2.mjs.map +1 -0
  72. package/dist/client/index.d.mts +2 -2
  73. package/dist/client/index.d.ts +2 -2
  74. package/dist/client/index.js +14 -9
  75. package/dist/client/index.js.map +1 -1
  76. package/dist/client/index.mjs +24 -19
  77. package/dist/components/index.d.mts +7 -1
  78. package/dist/components/index.d.ts +7 -1
  79. package/dist/components/index.js +11 -7
  80. package/dist/components/index.js.map +1 -1
  81. package/dist/components/index.mjs +10 -6
  82. package/dist/contexts/index.d.mts +1 -1
  83. package/dist/contexts/index.d.ts +1 -1
  84. package/dist/contexts/index.js +11 -7
  85. package/dist/contexts/index.js.map +1 -1
  86. package/dist/contexts/index.mjs +10 -6
  87. package/dist/core/index.d.mts +128 -2
  88. package/dist/core/index.d.ts +128 -2
  89. package/dist/core/index.js +17 -4
  90. package/dist/core/index.js.map +1 -1
  91. package/dist/core/index.mjs +22 -9
  92. package/dist/email-automations/index.js +101 -96
  93. package/dist/email-automations/index.js.map +1 -1
  94. package/dist/email-automations/index.mjs +13 -8
  95. package/dist/email-automations/index.mjs.map +1 -1
  96. package/dist/features/analytics/index.js +128 -123
  97. package/dist/features/analytics/index.js.map +1 -1
  98. package/dist/features/analytics/index.mjs +13 -8
  99. package/dist/features/analytics/index.mjs.map +1 -1
  100. package/dist/features/analytics/server.js +11 -8
  101. package/dist/features/analytics/server.js.map +1 -1
  102. package/dist/features/analytics/server.mjs +9 -6
  103. package/dist/features/analytics/server.mjs.map +1 -1
  104. package/dist/features/blog/index.js +158 -153
  105. package/dist/features/blog/index.js.map +1 -1
  106. package/dist/features/blog/index.mjs +14 -9
  107. package/dist/features/blog/index.mjs.map +1 -1
  108. package/dist/features/blog/server.js +30 -26
  109. package/dist/features/blog/server.js.map +1 -1
  110. package/dist/features/blog/server.mjs +10 -6
  111. package/dist/features/blog/server.mjs.map +1 -1
  112. package/dist/features/help/index.js +44 -40
  113. package/dist/features/help/index.js.map +1 -1
  114. package/dist/features/help/index.mjs +10 -6
  115. package/dist/features/help/index.mjs.map +1 -1
  116. package/dist/features/tokenusage/index.js +134 -129
  117. package/dist/features/tokenusage/index.js.map +1 -1
  118. package/dist/features/tokenusage/index.mjs +13 -8
  119. package/dist/features/tokenusage/index.mjs.map +1 -1
  120. package/dist/index.d.mts +2 -2
  121. package/dist/index.d.ts +2 -2
  122. package/dist/index.js +18 -5
  123. package/dist/index.js.map +1 -1
  124. package/dist/index.mjs +24 -11
  125. package/dist/lifecycle-4GUOTB46.js +23 -0
  126. package/dist/lifecycle-4GUOTB46.js.map +1 -0
  127. package/dist/lifecycle-LKGPRBS7.mjs +23 -0
  128. package/dist/local-transport-7H3SPZSA.js +15 -0
  129. package/dist/local-transport-7H3SPZSA.js.map +1 -0
  130. package/dist/local-transport-QYFKA6AN.mjs +15 -0
  131. package/dist/local-transport-QYFKA6AN.mjs.map +1 -0
  132. package/dist/offline/index.d.mts +607 -0
  133. package/dist/offline/index.d.ts +607 -0
  134. package/dist/offline/index.js +1052 -0
  135. package/dist/offline/index.js.map +1 -0
  136. package/dist/offline/index.mjs +1052 -0
  137. package/dist/offline/index.mjs.map +1 -0
  138. package/dist/offline/sqlite/device-worker.mjs +62 -0
  139. package/dist/offline/sqlite/index.mjs +17392 -0
  140. package/dist/offline/sqlite/sqlite3-opfs-async-proxy.js +881 -0
  141. package/dist/offline/sqlite/sqlite3.wasm +0 -0
  142. package/dist/request-KTBV6HHH.js +10 -0
  143. package/dist/request-KTBV6HHH.js.map +1 -0
  144. package/dist/request-SQOUNWZ4.mjs +10 -0
  145. package/dist/request-SQOUNWZ4.mjs.map +1 -0
  146. package/dist/server/cachedGet.d.mts +1 -1
  147. package/dist/server/cachedGet.d.ts +1 -1
  148. package/dist/server/cachedGet.stub.d.mts +1 -1
  149. package/dist/server/cachedGet.stub.d.ts +1 -1
  150. package/dist/server/index.d.mts +28 -2
  151. package/dist/server/index.d.ts +28 -2
  152. package/dist/server/index.js +18 -8
  153. package/dist/server/index.js.map +1 -1
  154. package/dist/server/index.mjs +18 -8
  155. package/dist/{useSocket-IRHtk2xc.d.ts → useSocket-Bo9M0t0J.d.ts} +3 -1
  156. package/dist/{useSocket-C8fqU4od.d.mts → useSocket-J5qanQPN.d.mts} +3 -1
  157. package/dist/write-through-FDQ7N4YI.js +15 -0
  158. package/dist/write-through-FDQ7N4YI.js.map +1 -0
  159. package/dist/write-through-R25VQMH6.mjs +15 -0
  160. package/dist/write-through-R25VQMH6.mjs.map +1 -0
  161. package/package.json +7 -1
  162. package/pwa/sw.js +277 -26
  163. package/src/client/request.ts +37 -0
  164. package/src/components/editors/BlockNoteEditor.tsx +14 -0
  165. package/src/components/editors/__tests__/BlockNoteEditor.empty-change.spec.tsx +205 -0
  166. package/src/contexts/SocketContext.tsx +7 -1
  167. package/src/contexts/__tests__/SocketContext.session.spec.tsx +52 -5
  168. package/src/core/factories/RehydrationFactory.ts +28 -8
  169. package/src/core/factories/__tests__/RehydrationFactory.spec.ts +33 -0
  170. package/src/core/index.ts +3 -0
  171. package/src/core/interfaces/ApiData.ts +2 -0
  172. package/src/core/registry/ModuleRegistry.ts +3 -0
  173. package/src/features/auth/components/forms/Logout.tsx +8 -1
  174. package/src/features/auth/components/forms/__tests__/Logout.spec.tsx +62 -0
  175. package/src/hooks/__tests__/useSocket.pause.spec.ts +39 -0
  176. package/src/hooks/useSocket.ts +20 -1
  177. package/src/offline/__tests__/answer-builders.spec.ts +176 -0
  178. package/src/offline/__tests__/device-switch.spec.ts +179 -0
  179. package/src/offline/__tests__/generic-answers.spec.ts +380 -0
  180. package/src/offline/__tests__/local-transport.spec.ts +150 -0
  181. package/src/offline/__tests__/logout.spec.ts +122 -0
  182. package/src/offline/__tests__/offline-fixtures.ts +89 -0
  183. package/src/offline/__tests__/router.spec.ts +56 -0
  184. package/src/offline/__tests__/state.spec.ts +62 -0
  185. package/src/offline/__tests__/write-through.spec.ts +137 -0
  186. package/src/offline/config.ts +229 -0
  187. package/src/offline/configure.ts +14 -0
  188. package/src/offline/device-switch.ts +75 -0
  189. package/src/offline/generic-answers.ts +706 -0
  190. package/src/offline/index.ts +67 -0
  191. package/src/offline/local-transport.ts +189 -0
  192. package/src/offline/logout.ts +41 -0
  193. package/src/offline/router.ts +41 -0
  194. package/src/offline/server/__tests__/build-offline-manifest.spec.ts +76 -0
  195. package/src/offline/server/build-offline-manifest.ts +49 -0
  196. package/src/offline/state-hooks.ts +10 -0
  197. package/src/offline/state.ts +143 -0
  198. package/src/offline/store/DeviceStore.ts +112 -0
  199. package/src/offline/store/__tests__/device-store-lifecycle.spec.ts +343 -0
  200. package/src/offline/store/__tests__/shared-database.spec.ts +151 -0
  201. package/src/offline/store/__tests__/sql-device-store.spec.ts +399 -0
  202. package/src/offline/store/lifecycle.ts +315 -0
  203. package/src/offline/store/schema.ts +101 -0
  204. package/src/offline/store/shared-database.ts +321 -0
  205. package/src/offline/store/sql-device-store.ts +469 -0
  206. package/src/offline/store/worker-client.ts +71 -0
  207. package/src/offline/store/worker.ts +87 -0
  208. package/src/offline/sync/__tests__/conflicts.spec.ts +185 -0
  209. package/src/offline/sync/__tests__/queue-drain.spec.ts +364 -0
  210. package/src/offline/sync/__tests__/runtime.spec.ts +337 -0
  211. package/src/offline/sync/__tests__/sync-fixtures.ts +103 -0
  212. package/src/offline/sync/__tests__/sync-root.spec.ts +727 -0
  213. package/src/offline/sync/artwork-cache.ts +67 -0
  214. package/src/offline/sync/conflicts.ts +211 -0
  215. package/src/offline/sync/counts.ts +39 -0
  216. package/src/offline/sync/drain-queue.ts +246 -0
  217. package/src/offline/sync/known-roots.ts +37 -0
  218. package/src/offline/sync/models/OfflineChangeSet.ts +34 -0
  219. package/src/offline/sync/models/OfflineChangeSetInterface.ts +54 -0
  220. package/src/offline/sync/models/RootSync.ts +68 -0
  221. package/src/offline/sync/models/RootSyncInterface.ts +20 -0
  222. package/src/offline/sync/models/__tests__/OfflineChangeSet.spec.ts +47 -0
  223. package/src/offline/sync/models/__tests__/RootSync.spec.ts +54 -0
  224. package/src/offline/sync/models/index.ts +5 -0
  225. package/src/offline/sync/models/offline.modules.ts +14 -0
  226. package/src/offline/sync/runtime.ts +226 -0
  227. package/src/offline/sync/session-refresh.ts +32 -0
  228. package/src/offline/sync/sync-root.ts +336 -0
  229. package/src/offline/sync/waiting.ts +40 -0
  230. package/src/offline/types.ts +115 -0
  231. package/src/offline/write-through.ts +189 -0
  232. package/src/pwa/__tests__/sw.spec.ts +358 -1
  233. package/src/server/index.ts +5 -0
  234. package/src/unified/__tests__/JsonApiRequest.offline.spec.ts +243 -0
  235. package/dist/BlockNoteEditor-75ED2CN2.mjs.map +0 -1
  236. package/dist/BlockNoteEditor-7M5QW2YQ.js.map +0 -1
  237. package/dist/BlockNoteViewer-WGCDB7IJ.js.map +0 -1
  238. package/dist/JsonApiRequest-FZGPLNUF.js +0 -26
  239. package/dist/chunk-AA52ORW2.mjs.map +0 -1
  240. package/dist/chunk-EWCY6QJA.mjs.map +0 -1
  241. package/dist/chunk-FF4EIQ6H.js.map +0 -1
  242. package/dist/chunk-JTPRJNT2.js.map +0 -1
  243. package/dist/chunk-L6PYFQTO.js.map +0 -1
  244. package/dist/chunk-O3PDL2CV.js.map +0 -1
  245. package/dist/chunk-PI56A4DG.mjs.map +0 -1
  246. package/dist/chunk-RXQYEEKZ.mjs.map +0 -1
  247. package/dist/chunk-V3YDVJ63.js.map +0 -1
  248. package/dist/request-5RLK2AM3.mjs +0 -9
  249. package/dist/request-EUGTI3BE.js +0 -9
  250. package/dist/request-EUGTI3BE.js.map +0 -1
  251. /package/dist/{JsonApiRequest-A66XK5WT.mjs.map → JsonApiRequest-F6TMJ42G.mjs.map} +0 -0
  252. /package/dist/{chunk-3YW35YHQ.mjs.map → chunk-BH6JWIKI.mjs.map} +0 -0
  253. /package/dist/{chunk-V5MI7RNB.mjs.map → chunk-DGWOVYEC.mjs.map} +0 -0
  254. /package/dist/{request-5RLK2AM3.mjs.map → lifecycle-LKGPRBS7.mjs.map} +0 -0
@@ -0,0 +1,336 @@
1
+ import { EndpointCreator } from "../../core/endpoint/EndpointCreator";
2
+ import { ApiRequestDataTypeInterface } from "../../core/interfaces/ApiRequestDataTypeInterface";
3
+ import { Modules } from "../../core/registry/ModuleRegistry";
4
+ import { JsonApiGet } from "../../unified/JsonApiRequest";
5
+ import { getOfflineConfig, getOfflineStore, isOfflineConfigured, writeOfflineLastSync } from "../config";
6
+ import { canReachApi, setOfflineState } from "../state";
7
+ import type { DeviceStore, SkippedRecord, StoredRecord } from "../store/DeviceStore";
8
+ import { cacheArtworkImage, imageUrlOf, removeArtworkImage } from "./artwork-cache";
9
+ import { knownRootIds, refreshLockedRoots, rememberSyncRoots } from "./known-roots";
10
+ import { ROOT_SYNC_ENDPOINT, RootSync } from "./models/RootSync";
11
+ import { currentLocale } from "./session-refresh";
12
+ import { recordKeyOf as keyOf, waitingRecordKeys } from "./waiting";
13
+
14
+ class SyncInterruptedError extends Error {}
15
+
16
+ function assertStillOnline(): void {
17
+ if (!canReachApi()) throw new SyncInterruptedError("The connection dropped during the sync");
18
+ }
19
+
20
+ /**
21
+ * A record the GM cannot read (403) or that is gone (404) is skipped by this sync (it never
22
+ * blocks the sync state). It is not stored; it is remembered with its `updatedAt` from the sync
23
+ * answer, and a later sync asks for it again only once that `updatedAt` is newer.
24
+ */
25
+ const isSkippable = (status: number) => status === 403 || status === 404;
26
+
27
+ async function fetchResources(
28
+ module: ApiRequestDataTypeInterface,
29
+ endpoint: string,
30
+ skipped: string[],
31
+ included: any[],
32
+ ): Promise<any[]> {
33
+ const response = await JsonApiGet({ classKey: module, endpoint, language: currentLocale() });
34
+ assertStillOnline();
35
+ if (!response.ok) {
36
+ if (isSkippable(response.response)) {
37
+ skipped.push(endpoint);
38
+ return [];
39
+ }
40
+ throw new Error(`[offline] GET ${endpoint} answered ${response.response}`);
41
+ }
42
+ included.push(...(response.raw?.included ?? []));
43
+ const data = response.raw?.data;
44
+ if (!data) return [];
45
+ return Array.isArray(data) ? data : [data];
46
+ }
47
+
48
+ async function imagesOfRoot(store: DeviceStore, rootId: string): Promise<{ type: string; id: string; url: string }[]> {
49
+ const images: { type: string; id: string; url: string }[] = [];
50
+ for (const typeConfig of getOfflineConfig().types) {
51
+ for (const record of await store.listRecords({ type: typeConfig.module.name, rootId })) {
52
+ const url = imageUrlOf(record.resource);
53
+ if (url) images.push({ type: record.type, id: record.id, url });
54
+ }
55
+ }
56
+ return images;
57
+ }
58
+
59
+ /**
60
+ * Removes a root record from the device (records, links, sync state, images) unless it has waiting
61
+ * changes. A shared record another root record still uses stays, with its image.
62
+ */
63
+ async function removeRootIfIdle(store: DeviceStore, rootId: string): Promise<void> {
64
+ if ((await store.queue()).some((entry) => entry.rootId === rootId)) return;
65
+ const images = await imagesOfRoot(store, rootId);
66
+ await store.removeRoot(rootId);
67
+ for (const image of images) {
68
+ if (!(await store.getRecord(image.type, image.id))) await removeArtworkImage(image.url);
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Brings one root record's device copy up to date with the sync answer (C2): records changed since
74
+ * the stored server time are fetched (C3), records gone from the root record are deleted, the links
75
+ * are replaced, and artwork images are downloaded. The sync state is stored only once every
76
+ * fetch succeeded (or answered 403/404). A record with a waiting change is never overwritten.
77
+ */
78
+ export async function syncRoot(rootId: string): Promise<void> {
79
+ const config = getOfflineConfig();
80
+ const store = await getOfflineStore();
81
+ const previous = await store.getSyncState(rootId);
82
+
83
+ const syncEndpoint = new EndpointCreator({
84
+ endpoint: config.rootModule,
85
+ id: rootId,
86
+ childEndpoint: ROOT_SYNC_ENDPOINT,
87
+ }).generate();
88
+ const syncResponse = await JsonApiGet({
89
+ classKey: Modules.RootSync,
90
+ endpoint: syncEndpoint,
91
+ language: currentLocale(),
92
+ });
93
+ assertStillOnline();
94
+ if (!syncResponse.ok) {
95
+ // Deleted, or no longer the GM's: removed from the device unless changes still wait.
96
+ if (isSkippable(syncResponse.response)) {
97
+ await removeRootIfIdle(store, rootId);
98
+ return;
99
+ }
100
+ throw new Error(`[offline] GET ${syncEndpoint} answered ${syncResponse.response}`);
101
+ }
102
+
103
+ const sync = syncResponse.data as RootSync;
104
+ if (sync.locked) {
105
+ // Locked online, locked offline: nothing is downloaded, waiting changes are kept.
106
+ await store.setSyncState(rootId, {
107
+ serverTime: previous.serverTime,
108
+ locked: true,
109
+ lockedFrom: sync.lockedFrom ?? null,
110
+ });
111
+ await refreshLockedRoots();
112
+ return;
113
+ }
114
+
115
+ const typeNames = new Set(config.types.map((typeConfig) => typeConfig.module.name));
116
+ const entries = sync.entries.filter((entry) => typeNames.has(entry.type));
117
+ const entryUpdatedAt = new Map(entries.map((entry) => [keyOf(entry.type, entry.id), entry.updatedAt]));
118
+ const since = previous.serverTime;
119
+ /** Inside the `updatedSince` window: a list read with `updatedSince` brings it. */
120
+ const inWindow = (entry: { updatedAt: Date }) => !since || entry.updatedAt.getTime() > since.getTime();
121
+
122
+ // What the device holds for this root record now. A record is fetched when it changed since the
123
+ // last sync, and also when the device does not hold it or holds it as stale (updatedAt cleared
124
+ // while it had a waiting change, see below) — unless an earlier sync skipped it (403/404, or
125
+ // absent from the answer) and it has not changed on the server since.
126
+ const stored = new Map<string, StoredRecord>();
127
+ for (const typeConfig of config.types) {
128
+ for (const record of await store.listRecords({ type: typeConfig.module.name, rootId })) {
129
+ stored.set(keyOf(record.type, record.id), record);
130
+ }
131
+ }
132
+ const previouslySkipped = new Map(
133
+ (await store.skippedRecords(rootId)).map((record) => [keyOf(record.type, record.id), record.updatedAt]),
134
+ );
135
+ const isStale = (entry: { type: string; id: string; updatedAt: Date }) => {
136
+ const key = keyOf(entry.type, entry.id);
137
+ const record = stored.get(key);
138
+ if (record && record.updatedAt !== null) return false;
139
+ const skippedAt = previouslySkipped.get(key);
140
+ return !skippedAt || entry.updatedAt.getTime() > skippedAt.getTime();
141
+ };
142
+ const changed = entries.filter((entry) => inWindow(entry) || isStale(entry));
143
+
144
+ const skipped: string[] = [];
145
+ const fetched: any[] = [];
146
+ // Records the answers include whose type the device does not sync itself (the root record's
147
+ // owner): kept for display only, so a local answer can include them as the server does.
148
+ const included: any[] = [];
149
+ for (const typeConfig of config.types) {
150
+ const changedOfType = changed.filter((entry) => entry.type === typeConfig.module.name);
151
+ if (changedOfType.length === 0) continue;
152
+
153
+ if (typeConfig.fetch === "root-list") {
154
+ // One list read per type. With `updatedSince` when every changed record is inside the
155
+ // window; without it when a record older than the window is missing or stale on the
156
+ // device, so the whole list comes in the same single request (never one read per record).
157
+ const endpoint = new EndpointCreator({
158
+ endpoint: config.rootModule,
159
+ id: rootId,
160
+ childEndpoint: typeConfig.module,
161
+ }).addAdditionalParam("fetchAll", "true");
162
+ if (since && changedOfType.every(inWindow)) endpoint.addAdditionalParam("updatedSince", since.toISOString());
163
+ fetched.push(...(await fetchResources(typeConfig.module, endpoint.generate(), skipped, included)));
164
+ } else if (typeConfig.fetch === "linked-list") {
165
+ // Read only as a list per target: the targets are the other ends of the changed records'
166
+ // sync links of `linkKind`; each target's list is read once, and only the type's entries kept.
167
+ if (!typeConfig.linkedList) throw new Error(`[offline] ${typeConfig.module.name}: linked-list needs linkedList`);
168
+ const { linkKind, endpoint } = typeConfig.linkedList;
169
+ const changedIds = new Set(changedOfType.map((entry) => entry.id));
170
+ const targets = new Map<string, { type: string; id: string }>();
171
+ for (const link of sync.links) {
172
+ if (link.kind !== linkKind || link.fromType !== typeConfig.module.name || !changedIds.has(link.fromId))
173
+ continue;
174
+ targets.set(keyOf(link.toType, link.toId), { type: link.toType, id: link.toId });
175
+ }
176
+ for (const target of targets.values()) {
177
+ const resources = await fetchResources(typeConfig.module, endpoint(rootId, target), skipped, included);
178
+ fetched.push(
179
+ ...resources.filter((resource) => resource?.type === typeConfig.module.name && changedIds.has(resource.id)),
180
+ );
181
+ }
182
+ } else {
183
+ for (const entry of changedOfType) {
184
+ const endpoint = new EndpointCreator({ endpoint: typeConfig.module, id: entry.id }).generate();
185
+ fetched.push(...(await fetchResources(typeConfig.module, endpoint, skipped, included)));
186
+ }
187
+ }
188
+ }
189
+
190
+ const fetchedKeys = new Set(fetched.map((resource) => keyOf(resource?.type, resource?.id)));
191
+ const previousImageUrls = new Map<string, string>();
192
+ const removedImageUrls: string[] = [];
193
+
194
+ await store.transaction(async (tx) => {
195
+ // A record with a waiting change (pending, sending, in conflict or failed) is never
196
+ // overwritten nor deleted here. Read inside this transaction, so an edit queued while the
197
+ // records were being fetched is seen.
198
+ const waiting = waitingRecordKeys(await tx.queue(), typeNames);
199
+
200
+ for (const typeConfig of config.types) {
201
+ for (const record of await tx.listRecords({ type: typeConfig.module.name, rootId })) {
202
+ const key = keyOf(record.type, record.id);
203
+ if (waiting.has(key)) {
204
+ // Marked stale: once the change stops waiting (sent, resolved, discarded), the next
205
+ // sync fetches the record again, whatever its updatedAt.
206
+ if (record.updatedAt !== null) await tx.putRecord({ ...record, updatedAt: null });
207
+ continue;
208
+ }
209
+ if (entryUpdatedAt.has(key)) continue;
210
+ // Leaves this root record; deleted only when no other root record keeps it (a shared record).
211
+ await tx.removeRecordFromRoot(record.type, record.id, rootId);
212
+ const url = imageUrlOf(record.resource);
213
+ if (url && !(await tx.getRecord(record.type, record.id))) removedImageUrls.push(url);
214
+ }
215
+ }
216
+
217
+ for (const resource of fetched) {
218
+ const key = keyOf(resource?.type, resource?.id);
219
+ if (!entryUpdatedAt.has(key)) continue;
220
+ if (waiting.has(key)) continue;
221
+ const existing = await tx.getRecord(resource.type, resource.id);
222
+ const previousUrl = imageUrlOf(existing?.resource);
223
+ if (previousUrl) previousImageUrls.set(key, previousUrl);
224
+ await tx.putRecord({
225
+ type: resource.type,
226
+ id: resource.id,
227
+ rootId,
228
+ resource,
229
+ updatedAt: entryUpdatedAt.get(key) ?? null,
230
+ base: null,
231
+ localOnly: false,
232
+ });
233
+ }
234
+
235
+ for (const resource of included) {
236
+ if (!resource?.type || !resource?.id || typeNames.has(resource.type)) continue;
237
+ await tx.putRecord({
238
+ type: resource.type,
239
+ id: resource.id,
240
+ rootId,
241
+ resource,
242
+ updatedAt: null,
243
+ base: null,
244
+ localOnly: false,
245
+ });
246
+ }
247
+
248
+ // Remembered skips: a changed record no answer brought (403/404, or absent from the list), and
249
+ // an earlier skip still in the root record that was not asked for again.
250
+ const skippedRecords: SkippedRecord[] = [];
251
+ const changedKeys = new Set(changed.map((entry) => keyOf(entry.type, entry.id)));
252
+ for (const entry of entries) {
253
+ const key = keyOf(entry.type, entry.id);
254
+ if (waiting.has(key) || fetchedKeys.has(key)) continue;
255
+ if (changedKeys.has(key)) skippedRecords.push({ type: entry.type, id: entry.id, updatedAt: entry.updatedAt });
256
+ else {
257
+ const skippedAt = previouslySkipped.get(key);
258
+ if (skippedAt) skippedRecords.push({ type: entry.type, id: entry.id, updatedAt: skippedAt });
259
+ }
260
+ }
261
+ await tx.setSkippedRecords(rootId, skippedRecords);
262
+
263
+ await tx.replaceRootLinks(rootId, sync.links);
264
+ await tx.setSyncState(rootId, {
265
+ serverTime: sync.serverTime,
266
+ locked: false,
267
+ lockedFrom: sync.lockedFrom ?? null,
268
+ });
269
+ });
270
+
271
+ if (skipped.length > 0) {
272
+ console.warn(`[offline] root record ${rootId}: skipped records the GM cannot read:`, skipped.join(", "));
273
+ }
274
+
275
+ for (const url of removedImageUrls) await removeArtworkImage(url);
276
+ for (const typeConfig of config.types) {
277
+ for (const record of await store.listRecords({ type: typeConfig.module.name, rootId })) {
278
+ const url = imageUrlOf(record.resource);
279
+ if (url) await cacheArtworkImage(url, previousImageUrls.get(keyOf(record.type, record.id)));
280
+ }
281
+ }
282
+
283
+ await refreshLockedRoots();
284
+ }
285
+
286
+ /**
287
+ * Syncs every root record `listSyncRoots()` returns, then removes from the device the root records
288
+ * that are no longer roots (unless they still have waiting changes). Does nothing offline.
289
+ */
290
+ export async function syncAll(): Promise<void> {
291
+ if (!isOfflineConfigured()) return;
292
+ const config = getOfflineConfig();
293
+ if (!config.getUserId() || !canReachApi()) return;
294
+
295
+ let roots: string[];
296
+ try {
297
+ roots = await config.listSyncRoots();
298
+ } catch (error) {
299
+ console.warn("[offline] could not list the root records to sync", error);
300
+ return;
301
+ }
302
+ if (!canReachApi()) return;
303
+ rememberSyncRoots(roots);
304
+
305
+ const total = roots.length;
306
+ setOfflineState({ syncProgress: { done: 0, total } });
307
+ try {
308
+ let done = 0;
309
+ for (const rootId of roots) {
310
+ try {
311
+ await syncRoot(rootId);
312
+ } catch (error) {
313
+ console.warn(`[offline] sync of root record ${rootId} failed`, error);
314
+ if (!canReachApi()) return;
315
+ } finally {
316
+ done += 1;
317
+ setOfflineState({ syncProgress: { done, total } });
318
+ }
319
+ }
320
+
321
+ // Every root record was tried: this counts as the device's last sync.
322
+ const finishedAt = Date.now();
323
+ const userId = config.getUserId();
324
+ if (userId) writeOfflineLastSync(userId, finishedAt);
325
+ setOfflineState({ lastSyncedAt: finishedAt, rootCount: total });
326
+
327
+ const store = await getOfflineStore();
328
+ const rootSet = new Set(roots);
329
+ for (const rootId of await knownRootIds(store)) {
330
+ if (!rootSet.has(rootId)) await removeRootIfIdle(store, rootId);
331
+ }
332
+ await refreshLockedRoots();
333
+ } finally {
334
+ setOfflineState({ syncProgress: null });
335
+ }
336
+ }
@@ -0,0 +1,40 @@
1
+ import type { QueueEntry } from "../store/DeviceStore";
2
+
3
+ export const recordKeyOf = (type: string, id: string) => `${type}:${id}`;
4
+
5
+ /**
6
+ * The records a queue entry targets: its `recordType`/`recordId`, the `data.type`/`data.id` of
7
+ * its body (a record created on this device), and the `<type>/<id>` of its path (the last
8
+ * segment naming a synced type, when an id follows it: `npcs/n1`, `npcs/n1/merge`; a collection
9
+ * path such as `roots/C/npcs` targets no record).
10
+ */
11
+ export function recordKeysOfEntry(entry: QueueEntry, typeNames: Set<string>): string[] {
12
+ const keys: string[] = [];
13
+ if (entry.recordType && entry.recordId) keys.push(recordKeyOf(entry.recordType, entry.recordId));
14
+
15
+ const data = entry.body?.data;
16
+ if (data && !Array.isArray(data) && typeof data.type === "string" && typeof data.id === "string") {
17
+ keys.push(recordKeyOf(data.type, data.id));
18
+ }
19
+
20
+ const segments = String(entry.path ?? "")
21
+ .split("?")[0]
22
+ .split("/")
23
+ .filter(Boolean);
24
+ for (let index = segments.length - 1; index >= 0; index--) {
25
+ if (!typeNames.has(segments[index])) continue;
26
+ if (index + 1 < segments.length) keys.push(recordKeyOf(segments[index], segments[index + 1]));
27
+ break;
28
+ }
29
+ return keys;
30
+ }
31
+
32
+ /**
33
+ * Every record with a waiting change: targeted by a queue entry that is pending, sending, in
34
+ * conflict or failed. Such a record is never overwritten or deleted by a sync or a write-through.
35
+ */
36
+ export function waitingRecordKeys(queue: QueueEntry[], typeNames: Set<string>): Set<string> {
37
+ const keys = new Set<string>();
38
+ for (const entry of queue) for (const key of recordKeysOfEntry(entry, typeNames)) keys.add(key);
39
+ return keys;
40
+ }
@@ -0,0 +1,115 @@
1
+ import type { ApiRequestDataTypeInterface } from "../core/interfaces/ApiRequestDataTypeInterface";
2
+ import type { DeviceStore } from "./store/DeviceStore";
3
+
4
+ /** Header of a request the server runs as an offline replay. Never answered locally. */
5
+ export const OFFLINE_REPLAY_HEADER = "x-offline-replay";
6
+
7
+ export type OfflineMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
8
+
9
+ /** A Module (matched by its endpoint), a ":param" capture, or a literal segment ("playing", "merge"). */
10
+ export type PatternSegment = ApiRequestDataTypeInterface | string;
11
+
12
+ /** `url` (beyond the contract, optional): the absolute URL the request was made to, for the `links` of a list. */
13
+ export type OfflineRequest = { method: OfflineMethod; path: string; query: URLSearchParams; body?: any; url?: string };
14
+
15
+ /** `body` is a JSON:API document, `undefined` for a 204. */
16
+ export type OfflineAnswer = { status: number; body: any };
17
+
18
+ export type LocalAnswerContext = { request: OfflineRequest; params: Record<string, string>; store: DeviceStore };
19
+
20
+ export type LocalAnswer = (ctx: LocalAnswerContext) => Promise<OfflineAnswer>;
21
+
22
+ export type OfflineRoute = { method: OfflineMethod; pattern: PatternSegment[]; answer: LocalAnswer; queue: boolean };
23
+
24
+ /**
25
+ * A type the API reads only as a list per target record: each changed record's target is the
26
+ * other end of its sync link of `linkKind` (from the record to the target), and `endpoint`
27
+ * builds that target's list path with `EndpointCreator`.
28
+ */
29
+ export type OfflineLinkedListFetch = {
30
+ linkKind: string;
31
+ endpoint: (rootId: string, target: { type: string; id: string }) => string;
32
+ };
33
+
34
+ export type OfflineTypeConfig = {
35
+ module: ApiRequestDataTypeInterface;
36
+ /**
37
+ * How the sync fetches the type's changed records: the root record's list (`root-list`), one
38
+ * GET per record (`by-id`), or (`linked-list`) the list of each record's target, found through
39
+ * the sync answer's links of `linkedList.linkKind` (e.g. player notes, read per target only).
40
+ */
41
+ fetch: "root-list" | "by-id" | "linked-list";
42
+ /** Required with `fetch: "linked-list"`. */
43
+ linkedList?: OfflineLinkedListFetch;
44
+ readOnly: boolean;
45
+ serverOwnedFields: string[];
46
+ /**
47
+ * Relationship name → Neo4j relationship type (`faction` → `MEMBER_OF`). A link the device
48
+ * writes from a request's relationships carries the mapped type, the kind synced links carry;
49
+ * an unmapped name is stored as the name itself.
50
+ */
51
+ relationshipKinds?: Record<string, string>;
52
+ /** The string attributes the server's fulltext index covers (default: every string attribute). */
53
+ searchFields?: string[];
54
+ /** The server's order with no `orderBy` (default `updatedAt DESC`). */
55
+ defaultOrderBy?: string;
56
+ };
57
+
58
+ export type OfflineConfig = {
59
+ getUserId: () => string | undefined;
60
+ /**
61
+ * Offline mode is off on each device until the user turns it on (`turnOnOffline`), stored per
62
+ * user per device. Default (undefined/false): always on, every device keeps the copy.
63
+ */
64
+ requireOptIn?: boolean;
65
+ /** The module whose records own every other record (in Wyrdli, Modules.Campaign). */
66
+ rootModule: ApiRequestDataTypeInterface;
67
+ types: OfflineTypeConfig[];
68
+ routes: OfflineRoute[];
69
+ /** Ids of the root records to keep on the device. */
70
+ listSyncRoots: () => Promise<string[]>;
71
+ shellPath: (locale: string) => string;
72
+ syncIntervalMs: number;
73
+ /**
74
+ * Where the device worker loads SQLite from at run time (default `/offline-sqlite/index.mjs`).
75
+ * SQLite is never bundled: its own helper worker uses a computed URL that app bundlers refuse.
76
+ * The app serves the files of `dist/offline/sqlite/` at this URL's folder.
77
+ */
78
+ sqliteModuleUrl?: string;
79
+ /**
80
+ * Where the device-copy worker is started from (default `/offline-sqlite/device-worker.mjs`).
81
+ * It is a standalone file in `dist/offline/sqlite/`, never bundled by the app.
82
+ */
83
+ deviceWorkerUrl?: string;
84
+ };
85
+
86
+ export const DEFAULT_SQLITE_MODULE_URL = "/offline-sqlite/index.mjs";
87
+ export const DEVICE_WORKER_FILE = "device-worker.mjs";
88
+ export const DEFAULT_DEVICE_WORKER_URL = `/offline-sqlite/${DEVICE_WORKER_FILE}`;
89
+
90
+ /**
91
+ * The files of `dist/offline/sqlite/` the app serves: the device worker and the SQLite files
92
+ * copied from @sqlite.org/sqlite-wasm.
93
+ */
94
+ export const OFFLINE_SQLITE_FILES = [
95
+ DEVICE_WORKER_FILE,
96
+ "index.mjs",
97
+ "sqlite3.wasm",
98
+ "sqlite3-opfs-async-proxy.js",
99
+ ] as const;
100
+
101
+ /** Thrown by a local answer for something that only the server can do. Answered as a 503 "needs-connection". */
102
+ export class NeedsConnectionError extends Error {
103
+ readonly status = 503;
104
+ readonly code = "needs-connection";
105
+
106
+ constructor(message: string = "Needs a connection") {
107
+ super(message);
108
+ this.name = "NeedsConnectionError";
109
+ }
110
+ }
111
+
112
+ /** Segments joined with "/", modules by their endpoint. */
113
+ export function patternToString(pattern: PatternSegment[]): string {
114
+ return pattern.map((segment) => (typeof segment === "string" ? segment : segment.name)).join("/");
115
+ }
@@ -0,0 +1,189 @@
1
+ import type { ApiData } from "../core/interfaces/ApiData";
2
+ import { getOfflineConfig, getOfflineStore, OfflineUnavailableError } from "./config";
3
+ import { rootIdForResource, linkKindFor, linkKindsOf, linksOfKind } from "./generic-answers";
4
+ import { splitApiUrl } from "./local-transport";
5
+ import { matchPattern } from "./router";
6
+ import type { DeviceStore, StoredLink } from "./store/DeviceStore";
7
+ import type { OfflineTypeConfig } from "./types";
8
+ import { cacheArtworkImage, imageUrlOf } from "./sync/artwork-cache";
9
+ import { knownRootIds } from "./sync/known-roots";
10
+ import { recordKeyOf, waitingRecordKeys } from "./sync/waiting";
11
+
12
+ export type WriteThroughRequest = { method: string; url: string };
13
+
14
+ function updatedAtOf(resource: any): Date | null {
15
+ const value = resource?.attributes?.updatedAt;
16
+ if (!value) return null;
17
+ const date = new Date(value);
18
+ return Number.isNaN(date.getTime()) ? null : date;
19
+ }
20
+
21
+ type Identifier = { type: string; id: string };
22
+
23
+ function identifiersOf(relationship: any): Identifier[] {
24
+ const data = relationship?.data;
25
+ if (!data) return [];
26
+ return (Array.isArray(data) ? data : [data]).filter((item: any) => item && item.type && item.id);
27
+ }
28
+
29
+ /**
30
+ * Brings the stored links of the written record in line with the relationships the answer
31
+ * carries (only those whose `data` it holds), as the local answers do: a link of the
32
+ * relationship (its name or mapped Neo4j type; any kind when the type is not mapped, for an
33
+ * identifier the record held before) to an identifier no longer there is removed, and a new
34
+ * identifier gets a link of `linkKindFor` the relationship.
35
+ */
36
+ async function updateLinks(
37
+ store: DeviceStore,
38
+ typeConfig: OfflineTypeConfig | undefined,
39
+ record: Identifier,
40
+ before: any,
41
+ after: any,
42
+ ): Promise<void> {
43
+ const otherEnd = (link: StoredLink) => (link.fromId === record.id ? link.toId : link.fromId);
44
+ const allLinks = async (): Promise<StoredLink[]> => [
45
+ ...(await store.linksFrom(record.id)),
46
+ ...(await store.linksTo(record.id)),
47
+ ];
48
+
49
+ for (const [name, relationship] of Object.entries<any>(after ?? {})) {
50
+ if (!relationship || !("data" in relationship)) continue;
51
+ const next = new Map(identifiersOf(relationship).map((identifier) => [identifier.id, identifier]));
52
+ const previous = new Set(identifiersOf(before?.[name]).map((identifier) => identifier.id));
53
+ const kinds = linkKindsOf(typeConfig, name) ?? [name];
54
+ const ofRelationship = await linksOfKind(store, record.id, kinds, "both");
55
+ const mapped = linkKindsOf(typeConfig, name) !== undefined;
56
+
57
+ const stale = [
58
+ ...ofRelationship.filter((link) => !next.has(otherEnd(link))),
59
+ // Unmapped: the link to an identifier the record held before may carry any kind.
60
+ ...(mapped ? [] : (await allLinks()).filter((link) => previous.has(otherEnd(link)) && !next.has(otherEnd(link)))),
61
+ ];
62
+ for (const link of stale) await store.deleteLink({ fromId: link.fromId, toId: link.toId, kind: link.kind });
63
+
64
+ const linked = new Set(
65
+ (mapped ? await linksOfKind(store, record.id, kinds, "both") : await allLinks()).map(otherEnd),
66
+ );
67
+ for (const [id, identifier] of next) {
68
+ if (linked.has(id)) continue;
69
+ await store.putLink({
70
+ fromType: record.type,
71
+ fromId: record.id,
72
+ toType: identifier.type,
73
+ toId: id,
74
+ kind: linkKindFor(typeConfig, name),
75
+ props: {},
76
+ });
77
+ }
78
+ }
79
+ }
80
+
81
+ type ImageChange = { url: string; previousUrl?: string };
82
+
83
+ async function storeResource(
84
+ store: DeviceStore,
85
+ resource: any,
86
+ pathRootId: string | undefined,
87
+ waiting: Set<string>,
88
+ kept: Set<string>,
89
+ primary: boolean,
90
+ images: ImageChange[],
91
+ ): Promise<void> {
92
+ const config = getOfflineConfig();
93
+ if (!resource?.type || !resource?.id) return;
94
+ if (!config.types.some((typeConfig) => typeConfig.module.name === resource.type)) return;
95
+ // A record with unsent local changes keeps them: the next sync, once they are sent, brings
96
+ // the server's version.
97
+ if (waiting.has(recordKeyOf(resource.type, resource.id))) return;
98
+
99
+ const existing = await store.getRecord(resource.type, resource.id);
100
+ if (existing && existing.base !== null) return;
101
+ const rootId = existing?.rootId ?? (await rootIdForResource(store, config, resource)) ?? pathRootId;
102
+ // A record that belongs to no root record on this device waits for the next sync.
103
+ if (!rootId) return;
104
+ // Only the root records the device keeps are written through (never a player's root record).
105
+ if (!kept.has(rootId)) return;
106
+
107
+ // An answer can carry a sparse resource (fields limited): it is merged onto what is stored.
108
+ const before = existing?.resource ?? {};
109
+ const merged = {
110
+ ...before,
111
+ ...resource,
112
+ attributes: { ...(before.attributes ?? {}), ...(resource.attributes ?? {}) },
113
+ relationships: { ...(before.relationships ?? {}), ...(resource.relationships ?? {}) },
114
+ };
115
+
116
+ await store.putRecord({
117
+ type: resource.type,
118
+ id: resource.id,
119
+ rootId,
120
+ resource: merged,
121
+ updatedAt: updatedAtOf(resource) ?? existing?.updatedAt ?? null,
122
+ base: null,
123
+ localOnly: false,
124
+ });
125
+ if (primary) {
126
+ const typeConfig = config.types.find((candidate) => candidate.module.name === resource.type);
127
+ await updateLinks(store, typeConfig, resource, before.relationships, resource.relationships);
128
+ }
129
+
130
+ const url = imageUrlOf(merged);
131
+ const previousUrl = imageUrlOf(existing?.resource);
132
+ if (url && url !== previousUrl) images.push({ url, previousUrl });
133
+ }
134
+
135
+ /**
136
+ * After a successful online write, copies the answer into the device copy so it is never behind
137
+ * the GM's own work: the resource and its `included` for POST/PUT/PATCH, the record removed for a
138
+ * DELETE. It never fails the request: a problem is logged and the next sync repairs the copy.
139
+ */
140
+ export async function writeThrough(request: WriteThroughRequest, response: ApiData): Promise<void> {
141
+ const method = request.method.toUpperCase();
142
+ if (method === "GET" || !response.ok) return;
143
+
144
+ try {
145
+ const config = getOfflineConfig();
146
+ if (!config.getUserId()) return;
147
+ const { path } = splitApiUrl(request.url);
148
+ const store = await getOfflineStore();
149
+
150
+ if (method === "DELETE") {
151
+ for (const typeConfig of config.types) {
152
+ const params = matchPattern([typeConfig.module, ":id"], path);
153
+ if (params) {
154
+ await store.deleteRecord(typeConfig.module.name, params.id);
155
+ return;
156
+ }
157
+ }
158
+ return;
159
+ }
160
+
161
+ const rootParams = matchPattern([config.rootModule, ":rootId"], path.split("/").slice(0, 2).join("/"));
162
+ const pathRootId = rootParams?.rootId;
163
+
164
+ const document = response.data;
165
+ const primary = Array.isArray(document?.data) ? document.data : document?.data ? [document.data] : [];
166
+ const waiting = waitingRecordKeys(
167
+ await store.queue(),
168
+ new Set(config.types.map((typeConfig) => typeConfig.module.name)),
169
+ );
170
+ // The root records this device keeps for the signed-in user (stored roots, waiting changes, last
171
+ // sync roots): a write in any other root record (a player's) stores nothing.
172
+ const kept = new Set(await knownRootIds(store));
173
+ const images: ImageChange[] = [];
174
+ await store.transaction(async (transactionStore) => {
175
+ for (const resource of primary) {
176
+ await storeResource(transactionStore, resource, pathRootId, waiting, kept, true, images);
177
+ }
178
+ for (const resource of document?.included ?? []) {
179
+ await storeResource(transactionStore, resource, pathRootId, waiting, kept, false, images);
180
+ }
181
+ });
182
+ // A new image URL of an artwork: downloaded, the old cached image removed (as the sync does).
183
+ for (const image of images) await cacheArtworkImage(image.url, image.previousUrl);
184
+ } catch (error) {
185
+ // A browser that cannot keep the device copy was already warned about once.
186
+ if (error instanceof OfflineUnavailableError) return;
187
+ console.warn("[offline] could not copy the answer to the device", error);
188
+ }
189
+ }