@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.
Files changed (256) 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-7M5QW2YQ.js → BlockNoteEditor-UBVEUFNR.js} +34 -22
  4. package/dist/BlockNoteEditor-UBVEUFNR.js.map +1 -0
  5. package/dist/{BlockNoteEditor-75ED2CN2.mjs → BlockNoteEditor-VXMDSQAA.mjs} +20 -8
  6. package/dist/BlockNoteEditor-VXMDSQAA.mjs.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-KSYQEKJX.js → chunk-2SV4SS6N.js} +14 -14
  21. package/dist/{chunk-KSYQEKJX.js.map → chunk-2SV4SS6N.js.map} +1 -1
  22. package/dist/{chunk-4FNGWALA.mjs → chunk-47BBY5KA.mjs} +6 -4
  23. package/dist/{chunk-4FNGWALA.mjs.map → chunk-47BBY5KA.mjs.map} +1 -1
  24. package/dist/chunk-6JUCIWDC.mjs +570 -0
  25. package/dist/chunk-6JUCIWDC.mjs.map +1 -0
  26. package/dist/chunk-7DBJTNSU.mjs +1049 -0
  27. package/dist/chunk-7DBJTNSU.mjs.map +1 -0
  28. package/dist/chunk-7JZHPMGW.mjs +704 -0
  29. package/dist/chunk-7JZHPMGW.mjs.map +1 -0
  30. package/dist/{chunk-L6PYFQTO.js → chunk-7ZHTPFFT.js} +16 -14
  31. package/dist/chunk-7ZHTPFFT.js.map +1 -0
  32. package/dist/chunk-B6JGKDP2.js +214 -0
  33. package/dist/chunk-B6JGKDP2.js.map +1 -0
  34. package/dist/{chunk-3YW35YHQ.mjs → chunk-BH6JWIKI.mjs} +5 -5
  35. package/dist/chunk-BZ5J2FIV.js +570 -0
  36. package/dist/chunk-BZ5J2FIV.js.map +1 -0
  37. package/dist/{chunk-V5MI7RNB.mjs → chunk-DGWOVYEC.mjs} +2 -2
  38. package/dist/chunk-DOCIFE6A.mjs +212 -0
  39. package/dist/chunk-DOCIFE6A.mjs.map +1 -0
  40. package/dist/chunk-GB6DBFYY.js +25 -0
  41. package/dist/chunk-GB6DBFYY.js.map +1 -0
  42. package/dist/{chunk-EWCY6QJA.mjs → chunk-JJMMT24D.mjs} +37 -3
  43. package/dist/chunk-JJMMT24D.mjs.map +1 -0
  44. package/dist/{chunk-JTPRJNT2.js → chunk-KMW7UNL2.js} +38 -3
  45. package/dist/chunk-KMW7UNL2.js.map +1 -0
  46. package/dist/chunk-MHB36MYE.js +317 -0
  47. package/dist/chunk-MHB36MYE.js.map +1 -0
  48. package/dist/{chunk-FF4EIQ6H.js → chunk-MOGEGWXW.js} +1039 -1680
  49. package/dist/chunk-MOGEGWXW.js.map +1 -0
  50. package/dist/{chunk-RXQYEEKZ.mjs → chunk-N4RZZKL5.mjs} +58 -25
  51. package/dist/chunk-N4RZZKL5.mjs.map +1 -0
  52. package/dist/{chunk-AA52ORW2.mjs → chunk-P445QGOC.mjs} +61 -702
  53. package/dist/chunk-P445QGOC.mjs.map +1 -0
  54. package/dist/chunk-PV4Z5W6V.js +212 -0
  55. package/dist/chunk-PV4Z5W6V.js.map +1 -0
  56. package/dist/chunk-PXDUJ47N.js +1049 -0
  57. package/dist/chunk-PXDUJ47N.js.map +1 -0
  58. package/dist/{chunk-V3YDVJ63.js → chunk-PYRZCKC2.js} +1209 -1176
  59. package/dist/chunk-PYRZCKC2.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/features/onboarding/contexts/OnboardingContext.spec.tsx +42 -1
  176. package/src/features/onboarding/contexts/OnboardingContext.tsx +11 -1
  177. package/src/hooks/__tests__/useSocket.pause.spec.ts +39 -0
  178. package/src/hooks/useSocket.ts +20 -1
  179. package/src/offline/__tests__/answer-builders.spec.ts +176 -0
  180. package/src/offline/__tests__/device-switch.spec.ts +179 -0
  181. package/src/offline/__tests__/generic-answers.spec.ts +380 -0
  182. package/src/offline/__tests__/local-transport.spec.ts +150 -0
  183. package/src/offline/__tests__/logout.spec.ts +122 -0
  184. package/src/offline/__tests__/offline-fixtures.ts +89 -0
  185. package/src/offline/__tests__/router.spec.ts +56 -0
  186. package/src/offline/__tests__/state.spec.ts +62 -0
  187. package/src/offline/__tests__/write-through.spec.ts +137 -0
  188. package/src/offline/config.ts +229 -0
  189. package/src/offline/configure.ts +14 -0
  190. package/src/offline/device-switch.ts +75 -0
  191. package/src/offline/generic-answers.ts +706 -0
  192. package/src/offline/index.ts +67 -0
  193. package/src/offline/local-transport.ts +189 -0
  194. package/src/offline/logout.ts +41 -0
  195. package/src/offline/router.ts +41 -0
  196. package/src/offline/server/__tests__/build-offline-manifest.spec.ts +76 -0
  197. package/src/offline/server/build-offline-manifest.ts +49 -0
  198. package/src/offline/state-hooks.ts +10 -0
  199. package/src/offline/state.ts +143 -0
  200. package/src/offline/store/DeviceStore.ts +112 -0
  201. package/src/offline/store/__tests__/device-store-lifecycle.spec.ts +343 -0
  202. package/src/offline/store/__tests__/shared-database.spec.ts +151 -0
  203. package/src/offline/store/__tests__/sql-device-store.spec.ts +399 -0
  204. package/src/offline/store/lifecycle.ts +315 -0
  205. package/src/offline/store/schema.ts +101 -0
  206. package/src/offline/store/shared-database.ts +321 -0
  207. package/src/offline/store/sql-device-store.ts +469 -0
  208. package/src/offline/store/worker-client.ts +71 -0
  209. package/src/offline/store/worker.ts +87 -0
  210. package/src/offline/sync/__tests__/conflicts.spec.ts +185 -0
  211. package/src/offline/sync/__tests__/queue-drain.spec.ts +364 -0
  212. package/src/offline/sync/__tests__/runtime.spec.ts +337 -0
  213. package/src/offline/sync/__tests__/sync-fixtures.ts +103 -0
  214. package/src/offline/sync/__tests__/sync-root.spec.ts +727 -0
  215. package/src/offline/sync/artwork-cache.ts +67 -0
  216. package/src/offline/sync/conflicts.ts +211 -0
  217. package/src/offline/sync/counts.ts +39 -0
  218. package/src/offline/sync/drain-queue.ts +246 -0
  219. package/src/offline/sync/known-roots.ts +37 -0
  220. package/src/offline/sync/models/OfflineChangeSet.ts +34 -0
  221. package/src/offline/sync/models/OfflineChangeSetInterface.ts +54 -0
  222. package/src/offline/sync/models/RootSync.ts +68 -0
  223. package/src/offline/sync/models/RootSyncInterface.ts +20 -0
  224. package/src/offline/sync/models/__tests__/OfflineChangeSet.spec.ts +47 -0
  225. package/src/offline/sync/models/__tests__/RootSync.spec.ts +54 -0
  226. package/src/offline/sync/models/index.ts +5 -0
  227. package/src/offline/sync/models/offline.modules.ts +14 -0
  228. package/src/offline/sync/runtime.ts +226 -0
  229. package/src/offline/sync/session-refresh.ts +32 -0
  230. package/src/offline/sync/sync-root.ts +336 -0
  231. package/src/offline/sync/waiting.ts +40 -0
  232. package/src/offline/types.ts +115 -0
  233. package/src/offline/write-through.ts +189 -0
  234. package/src/pwa/__tests__/sw.spec.ts +358 -1
  235. package/src/server/index.ts +5 -0
  236. package/src/unified/__tests__/JsonApiRequest.offline.spec.ts +243 -0
  237. package/dist/BlockNoteEditor-75ED2CN2.mjs.map +0 -1
  238. package/dist/BlockNoteEditor-7M5QW2YQ.js.map +0 -1
  239. package/dist/BlockNoteViewer-WGCDB7IJ.js.map +0 -1
  240. package/dist/JsonApiRequest-FZGPLNUF.js +0 -26
  241. package/dist/chunk-AA52ORW2.mjs.map +0 -1
  242. package/dist/chunk-EWCY6QJA.mjs.map +0 -1
  243. package/dist/chunk-FF4EIQ6H.js.map +0 -1
  244. package/dist/chunk-JTPRJNT2.js.map +0 -1
  245. package/dist/chunk-L6PYFQTO.js.map +0 -1
  246. package/dist/chunk-O3PDL2CV.js.map +0 -1
  247. package/dist/chunk-PI56A4DG.mjs.map +0 -1
  248. package/dist/chunk-RXQYEEKZ.mjs.map +0 -1
  249. package/dist/chunk-V3YDVJ63.js.map +0 -1
  250. package/dist/request-5RLK2AM3.mjs +0 -9
  251. package/dist/request-EUGTI3BE.js +0 -9
  252. package/dist/request-EUGTI3BE.js.map +0 -1
  253. /package/dist/{JsonApiRequest-A66XK5WT.mjs.map → JsonApiRequest-F6TMJ42G.mjs.map} +0 -0
  254. /package/dist/{chunk-3YW35YHQ.mjs.map → chunk-BH6JWIKI.mjs.map} +0 -0
  255. /package/dist/{chunk-V5MI7RNB.mjs.map → chunk-DGWOVYEC.mjs.map} +0 -0
  256. /package/dist/{request-5RLK2AM3.mjs.map → lifecycle-LKGPRBS7.mjs.map} +0 -0
@@ -0,0 +1,727 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { DeviceStore } from "../../store/DeviceStore";
3
+ import { RootSync } from "../models/RootSync";
4
+ import { artwork, fakeCacheStorage, npc, setUpSync, syncTestConfig, tearDownSync, TestModel } from "./sync-fixtures";
5
+
6
+ const { JsonApiGet } = vi.hoisted(() => ({ JsonApiGet: vi.fn() }));
7
+ vi.mock("../../../unified/JsonApiRequest", () => ({
8
+ JsonApiGet,
9
+ JsonApiPost: vi.fn(),
10
+ getApiUrl: () => "http://api.test/",
11
+ buildUrl: (endpoint: string) => `http://api.test/${endpoint}`,
12
+ }));
13
+
14
+ import { syncAll, syncRoot } from "../sync-root";
15
+ import { getOfflineState, subscribeOfflineState } from "../../state";
16
+
17
+ type Answer = { ok: boolean; response: number; raw?: any; data?: any };
18
+
19
+ const syncAnswer = (attributes: Record<string, unknown>, id = "C"): Answer => ({
20
+ ok: true,
21
+ response: 200,
22
+ data: new RootSync().rehydrate({
23
+ jsonApi: {
24
+ type: "root-syncs",
25
+ id,
26
+ attributes: {
27
+ serverTime: "2026-11-01T10:00:00.000Z",
28
+ locked: false,
29
+ lockedFrom: null,
30
+ entries: [],
31
+ links: [],
32
+ ...attributes,
33
+ },
34
+ },
35
+ included: [],
36
+ } as any),
37
+ });
38
+
39
+ const listAnswer = (resources: any[]): Answer => ({ ok: true, response: 200, raw: { data: resources }, data: [] });
40
+ const oneAnswer = (resource: any): Answer => ({ ok: true, response: 200, raw: { data: resource }, data: {} });
41
+ const statusAnswer = (status: number): Answer => ({ ok: false, response: status, raw: undefined, data: [] });
42
+
43
+ /** Answers JsonApiGet by endpoint (query string included); unknown endpoints fail the test. */
44
+ function serve(routes: Record<string, Answer>) {
45
+ JsonApiGet.mockImplementation(async (params: { endpoint: string }) => {
46
+ const answer = routes[params.endpoint];
47
+ if (!answer) throw new Error(`unexpected GET ${params.endpoint}`);
48
+ return answer;
49
+ });
50
+ }
51
+
52
+ const entry = (type: string, id: string, updatedAt = "2026-10-31T10:00:00.000Z") => ({ type, id, updatedAt });
53
+
54
+ describe("syncRoot", () => {
55
+ let store: DeviceStore;
56
+ let cache: ReturnType<typeof fakeCacheStorage>;
57
+ const fetchMock = vi.fn();
58
+
59
+ beforeEach(async () => {
60
+ store = await setUpSync();
61
+ JsonApiGet.mockReset();
62
+ cache = fakeCacheStorage();
63
+ vi.stubGlobal("caches", cache.storage);
64
+ fetchMock.mockReset();
65
+ fetchMock.mockImplementation(async (url: string) => ({ ok: true, status: 200, url, clone: () => ({ url }) }));
66
+ vi.stubGlobal("fetch", fetchMock);
67
+ });
68
+
69
+ afterEach(() => {
70
+ tearDownSync();
71
+ vi.unstubAllGlobals();
72
+ });
73
+
74
+ it("deletes local records whose id is not in entries (unless localOnly with a pending queue entry)", async () => {
75
+ await store.putRecord({
76
+ type: "npcs",
77
+ id: "gone",
78
+ rootId: "C",
79
+ resource: npc("gone", "Gone"),
80
+ updatedAt: null,
81
+ });
82
+ await store.putRecord({
83
+ type: "npcs",
84
+ id: "kept",
85
+ rootId: "C",
86
+ resource: npc("kept", "Kept"),
87
+ updatedAt: new Date("2026-10-31T10:00:00.000Z"),
88
+ });
89
+ await store.putRecord({
90
+ type: "npcs",
91
+ id: "new",
92
+ rootId: "C",
93
+ resource: npc("new", "New"),
94
+ updatedAt: null,
95
+ localOnly: true,
96
+ });
97
+ await store.enqueue({
98
+ rootId: "C",
99
+ method: "POST",
100
+ path: "npcs",
101
+ body: { data: npc("new", "New") },
102
+ recordType: "npcs",
103
+ recordId: "new",
104
+ base: null,
105
+ });
106
+ await store.setSyncState("C", {
107
+ serverTime: new Date("2026-11-01T00:00:00.000Z"),
108
+ locked: false,
109
+ lockedFrom: null,
110
+ });
111
+
112
+ serve({ "roots/C/sync": syncAnswer({ entries: [entry("npcs", "kept")] }) });
113
+
114
+ await syncRoot("C");
115
+
116
+ expect(await store.getRecord("npcs", "gone")).toBeUndefined();
117
+ expect(await store.getRecord("npcs", "kept")).toBeDefined();
118
+ expect(await store.getRecord("npcs", "new")).toBeDefined();
119
+ });
120
+
121
+ it("fetches root-list types with updatedSince = the stored serverTime, by-id types per changed entry; URLs from EndpointCreator", async () => {
122
+ await store.setSyncState("C", {
123
+ serverTime: new Date("2026-10-31T00:00:00.000Z"),
124
+ locked: false,
125
+ lockedFrom: null,
126
+ });
127
+ await store.putRecord({
128
+ type: "npcs",
129
+ id: "old",
130
+ rootId: "C",
131
+ resource: npc("old", "Old"),
132
+ updatedAt: new Date("2026-10-20T00:00:00.000Z"),
133
+ });
134
+ await store.putRecord({
135
+ type: "templates",
136
+ id: "t-old",
137
+ rootId: "C",
138
+ resource: { type: "templates", id: "t-old", attributes: { name: "Old" } },
139
+ updatedAt: new Date("2026-10-01T00:00:00.000Z"),
140
+ });
141
+
142
+ serve({
143
+ "roots/C/sync": syncAnswer({
144
+ entries: [
145
+ entry("roots", "C"),
146
+ entry("npcs", "n1"),
147
+ entry("npcs", "old", "2026-10-20T00:00:00.000Z"),
148
+ entry("templates", "t1"),
149
+ entry("templates", "t-old", "2026-10-01T00:00:00.000Z"),
150
+ ],
151
+ }),
152
+ "roots/C": oneAnswer({ type: "roots", id: "C", attributes: { name: "Camp" } }),
153
+ "roots/C/npcs?fetchAll=true&updatedSince=2026-10-31T00:00:00.000Z": listAnswer([npc("n1", "Alda")]),
154
+ "templates/t1": oneAnswer({ type: "templates", id: "t1", attributes: { name: "T" } }),
155
+ });
156
+
157
+ await syncRoot("C");
158
+
159
+ const endpoints = JsonApiGet.mock.calls.map((call) => call[0].endpoint);
160
+ expect(endpoints).toEqual([
161
+ "roots/C/sync",
162
+ "roots/C",
163
+ "roots/C/npcs?fetchAll=true&updatedSince=2026-10-31T00:00:00.000Z",
164
+ "templates/t1",
165
+ ]);
166
+ expect(JsonApiGet.mock.calls[0][0].classKey.name).toBe("root-syncs");
167
+ expect((await store.getRecord("npcs", "n1"))?.resource.attributes.name).toBe("Alda");
168
+ expect((await store.getRecord("npcs", "n1"))?.updatedAt).toEqual(new Date("2026-10-31T10:00:00.000Z"));
169
+ expect((await store.getRecord("templates", "t1"))?.rootId).toBe("C");
170
+ expect((await store.getRecord("roots", "C"))?.resource.attributes.name).toBe("Camp");
171
+ });
172
+
173
+ it("keeps the included records of types the device does not sync (the root record's owner) for display, never those of synced types", async () => {
174
+ serve({
175
+ "roots/C/sync": syncAnswer({ entries: [entry("roots", "C")] }),
176
+ "roots/C": {
177
+ ok: true,
178
+ response: 200,
179
+ data: {},
180
+ raw: {
181
+ data: {
182
+ type: "roots",
183
+ id: "C",
184
+ attributes: { name: "Camp" },
185
+ relationships: { owner: { data: { type: "users", id: "u1" } } },
186
+ },
187
+ included: [{ type: "users", id: "u1", attributes: { name: "The GM" } }, npc("n-included", "Not in entries")],
188
+ },
189
+ },
190
+ });
191
+
192
+ await syncRoot("C");
193
+
194
+ const owner = await store.getRecord("users", "u1");
195
+ expect(owner?.resource.attributes.name).toBe("The GM");
196
+ expect(owner?.rootId).toBe("C");
197
+ expect(await store.getRecord("npcs", "n-included")).toBeUndefined();
198
+ });
199
+
200
+ it("a first sync sends no updatedSince", async () => {
201
+ serve({
202
+ "roots/C/sync": syncAnswer({ entries: [entry("npcs", "n1")] }),
203
+ "roots/C/npcs?fetchAll=true": listAnswer([npc("n1", "Alda")]),
204
+ });
205
+
206
+ await syncRoot("C");
207
+
208
+ expect(await store.getRecord("npcs", "n1")).toBeDefined();
209
+ });
210
+
211
+ it("replaces the root record's links", async () => {
212
+ await store.putRecord({
213
+ type: "npcs",
214
+ id: "n1",
215
+ rootId: "C",
216
+ resource: npc("n1", "A"),
217
+ updatedAt: new Date("2026-10-31T10:00:00.000Z"),
218
+ });
219
+ await store.putRecord({
220
+ type: "npcs",
221
+ id: "n2",
222
+ rootId: "C",
223
+ resource: npc("n2", "B"),
224
+ updatedAt: new Date("2026-10-31T10:00:00.000Z"),
225
+ });
226
+ await store.putLink({ fromType: "npcs", fromId: "n1", toType: "npcs", toId: "n2", kind: "OLD", props: {} });
227
+ await store.setSyncState("C", {
228
+ serverTime: new Date("2026-11-01T00:00:00.000Z"),
229
+ locked: false,
230
+ lockedFrom: null,
231
+ });
232
+
233
+ const link = { fromType: "npcs", fromId: "n2", toType: "npcs", toId: "n1", kind: "KNOWS", props: { x: 3 } };
234
+ serve({ "roots/C/sync": syncAnswer({ entries: [entry("npcs", "n1"), entry("npcs", "n2")], links: [link] }) });
235
+
236
+ await syncRoot("C");
237
+
238
+ expect(await store.linksFrom("n1")).toEqual([]);
239
+ expect(await store.linksFrom("n2")).toEqual([link]);
240
+ });
241
+
242
+ it("stores the sync state only after every fetch succeeded or answered 403/404 (a record the GM cannot read, e.g. a shared template, is skipped and listed in the console)", async () => {
243
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
244
+ serve({
245
+ "roots/C/sync": syncAnswer({ entries: [entry("templates", "t1"), entry("templates", "t2")] }),
246
+ "templates/t1": statusAnswer(403),
247
+ "templates/t2": statusAnswer(404),
248
+ });
249
+
250
+ await syncRoot("C");
251
+
252
+ expect((await store.getSyncState("C")).serverTime).toEqual(new Date("2026-11-01T10:00:00.000Z"));
253
+ expect(warn.mock.calls.flat().join(" ")).toContain("templates/t1");
254
+ expect(warn.mock.calls.flat().join(" ")).toContain("templates/t2");
255
+
256
+ // A failure that is not 403/404 leaves the sync state untouched.
257
+ await store.setSyncState("C", { serverTime: null, locked: false, lockedFrom: null });
258
+ serve({
259
+ "roots/C/sync": syncAnswer({ entries: [entry("templates", "t1")] }),
260
+ "templates/t1": statusAnswer(500),
261
+ });
262
+
263
+ await expect(syncRoot("C")).rejects.toThrow();
264
+ expect((await store.getSyncState("C")).serverTime).toBeNull();
265
+ warn.mockRestore();
266
+ });
267
+
268
+ it("never overwrites a record that has a pending queue entry", async () => {
269
+ await store.putRecord({ type: "npcs", id: "n1", rootId: "C", resource: npc("n1", "Mine"), updatedAt: null });
270
+ await store.enqueue({
271
+ rootId: "C",
272
+ method: "PATCH",
273
+ path: "npcs/n1",
274
+ body: { data: npc("n1", "Mine") },
275
+ recordType: "npcs",
276
+ recordId: "n1",
277
+ base: npc("n1", "Base"),
278
+ });
279
+
280
+ serve({
281
+ "roots/C/sync": syncAnswer({ entries: [entry("npcs", "n1"), entry("npcs", "n2")] }),
282
+ "roots/C/npcs?fetchAll=true": listAnswer([npc("n1", "Theirs"), npc("n2", "Other")]),
283
+ });
284
+
285
+ await syncRoot("C");
286
+
287
+ expect((await store.getRecord("npcs", "n1"))?.resource.attributes.name).toBe("Mine");
288
+ expect((await store.getRecord("npcs", "n2"))?.resource.attributes.name).toBe("Other");
289
+ });
290
+
291
+ it("keeps a record created on this device while its POST is unsent, though the entry has no recordType/recordId", async () => {
292
+ await store.putRecord({
293
+ type: "npcs",
294
+ id: "fresh",
295
+ rootId: "C",
296
+ resource: npc("fresh", "Fresh"),
297
+ updatedAt: null,
298
+ localOnly: true,
299
+ });
300
+ await store.enqueue({
301
+ rootId: "C",
302
+ method: "POST",
303
+ path: "npcs",
304
+ body: { data: npc("fresh", "Fresh") },
305
+ base: null,
306
+ });
307
+ await store.setSyncState("C", {
308
+ serverTime: new Date("2026-11-01T00:00:00.000Z"),
309
+ locked: false,
310
+ lockedFrom: null,
311
+ });
312
+
313
+ serve({ "roots/C/sync": syncAnswer({ entries: [] }) });
314
+
315
+ await syncRoot("C");
316
+
317
+ expect((await store.getRecord("npcs", "fresh"))?.resource.attributes.name).toBe("Fresh");
318
+ });
319
+
320
+ it("treats a record targeted by a queued path <type>/<id> (a custom write) as waiting: never overwritten", async () => {
321
+ await store.putRecord({ type: "npcs", id: "n1", rootId: "C", resource: npc("n1", "Mine"), updatedAt: null });
322
+ await store.enqueue({ rootId: "C", method: "POST", path: "npcs/n1/merge", body: {}, base: null });
323
+
324
+ serve({
325
+ "roots/C/sync": syncAnswer({ entries: [entry("npcs", "n1")] }),
326
+ "roots/C/npcs?fetchAll=true": listAnswer([npc("n1", "Theirs")]),
327
+ });
328
+
329
+ await syncRoot("C");
330
+
331
+ expect((await store.getRecord("npcs", "n1"))?.resource.attributes.name).toBe("Mine");
332
+ });
333
+
334
+ it("a shared artwork another root record still uses is not deleted when it leaves this root", async () => {
335
+ const art = artwork("a1", "http://api.test/artworks/a1/image?v=1");
336
+ await store.putRecord({ type: "artworks", id: "a1", rootId: "C", resource: art, updatedAt: null });
337
+ await store.putRecord({ type: "artworks", id: "a1", rootId: "D", resource: art, updatedAt: null });
338
+ await store.setSyncState("C", {
339
+ serverTime: new Date("2026-11-01T00:00:00.000Z"),
340
+ locked: false,
341
+ lockedFrom: null,
342
+ });
343
+
344
+ serve({ "roots/C/sync": syncAnswer({ entries: [] }) });
345
+
346
+ await syncRoot("C");
347
+
348
+ expect(await store.getRecord("artworks", "a1")).toBeDefined();
349
+ expect(await store.listRecords({ type: "artworks", rootId: "C" })).toEqual([]);
350
+ expect((await store.listRecords({ type: "artworks", rootId: "D" })).map((r) => r.id)).toEqual(["a1"]);
351
+ });
352
+
353
+ it("refetches a record once it leaves the waiting state, even when its updatedAt is not newer than the last sync", async () => {
354
+ await store.putRecord({
355
+ type: "npcs",
356
+ id: "n1",
357
+ rootId: "C",
358
+ resource: npc("n1", "Mine"),
359
+ updatedAt: new Date("2026-10-01T00:00:00.000Z"),
360
+ base: npc("n1", "Base"),
361
+ });
362
+ const seq = await store.enqueue({
363
+ rootId: "C",
364
+ method: "PATCH",
365
+ path: "npcs/n1",
366
+ body: { data: npc("n1", "Mine") },
367
+ recordType: "npcs",
368
+ recordId: "n1",
369
+ base: npc("n1", "Base"),
370
+ });
371
+ await store.setSyncState("C", {
372
+ serverTime: new Date("2026-10-31T00:00:00.000Z"),
373
+ locked: false,
374
+ lockedFrom: null,
375
+ });
376
+
377
+ serve({
378
+ "roots/C/sync": syncAnswer({ entries: [entry("npcs", "n1", "2026-10-31T10:00:00.000Z")] }),
379
+ "roots/C/npcs?fetchAll=true&updatedSince=2026-10-31T00:00:00.000Z": listAnswer([npc("n1", "Theirs")]),
380
+ });
381
+ await syncRoot("C");
382
+ expect((await store.getRecord("npcs", "n1"))?.resource.attributes.name).toBe("Mine");
383
+
384
+ // The GM discards the change: the next sync must bring the server's version back.
385
+ await store.removeQueueEntry(seq);
386
+ serve({
387
+ "roots/C/sync": syncAnswer({
388
+ serverTime: "2026-11-02T10:00:00.000Z",
389
+ entries: [entry("npcs", "n1", "2026-10-31T10:00:00.000Z")],
390
+ }),
391
+ // Older than the updatedSince window: the whole list in one read, never one read per record.
392
+ "roots/C/npcs?fetchAll=true": listAnswer([npc("n1", "Theirs")]),
393
+ });
394
+ await syncRoot("C");
395
+
396
+ expect((await store.getRecord("npcs", "n1"))?.resource.attributes.name).toBe("Theirs");
397
+ });
398
+
399
+ it("retries a record skipped with 403/404 only when its updatedAt in the sync answer is newer than when it was skipped", async () => {
400
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
401
+ serve({
402
+ "roots/C/sync": syncAnswer({ entries: [entry("templates", "t1")] }),
403
+ "templates/t1": statusAnswer(403),
404
+ });
405
+ await syncRoot("C");
406
+ expect(await store.getRecord("templates", "t1")).toBeUndefined();
407
+
408
+ // Same updatedAt: not asked for again (an unknown GET would fail the test).
409
+ serve({
410
+ "roots/C/sync": syncAnswer({ serverTime: "2026-11-02T10:00:00.000Z", entries: [entry("templates", "t1")] }),
411
+ });
412
+ await syncRoot("C");
413
+ expect(JsonApiGet.mock.calls.map((call) => call[0].endpoint)).toEqual([
414
+ "roots/C/sync",
415
+ "templates/t1",
416
+ "roots/C/sync",
417
+ ]);
418
+
419
+ // Changed on the server since it was skipped: asked for again.
420
+ serve({
421
+ "roots/C/sync": syncAnswer({
422
+ serverTime: "2026-11-03T10:00:00.000Z",
423
+ entries: [entry("templates", "t1", "2026-11-02T12:00:00.000Z")],
424
+ }),
425
+ "templates/t1": oneAnswer({ type: "templates", id: "t1", attributes: { name: "T" } }),
426
+ });
427
+ await syncRoot("C");
428
+
429
+ expect((await store.getRecord("templates", "t1"))?.resource.attributes.name).toBe("T");
430
+ warn.mockRestore();
431
+ });
432
+
433
+ it("a root-list record the GM cannot read (absent from the list) is not refetched, and never drops updatedSince for the list", async () => {
434
+ await store.setSyncState("C", {
435
+ serverTime: new Date("2026-10-31T00:00:00.000Z"),
436
+ locked: false,
437
+ lockedFrom: null,
438
+ });
439
+ serve({
440
+ "roots/C/sync": syncAnswer({ entries: [entry("npcs", "hidden", "2026-10-31T10:00:00.000Z")] }),
441
+ "roots/C/npcs?fetchAll=true&updatedSince=2026-10-31T00:00:00.000Z": listAnswer([]),
442
+ });
443
+ await syncRoot("C");
444
+ expect(await store.getRecord("npcs", "hidden")).toBeUndefined();
445
+
446
+ // Unchanged on the server: no list download at all (an unknown GET would fail the test).
447
+ serve({
448
+ "roots/C/sync": syncAnswer({
449
+ serverTime: "2026-11-02T10:00:00.000Z",
450
+ entries: [entry("npcs", "hidden", "2026-10-31T10:00:00.000Z"), entry("npcs", "n2", "2026-11-02T09:00:00.000Z")],
451
+ }),
452
+ "roots/C/npcs?fetchAll=true&updatedSince=2026-11-01T10:00:00.000Z": listAnswer([npc("n2", "Two")]),
453
+ });
454
+ await syncRoot("C");
455
+
456
+ expect((await store.getRecord("npcs", "n2"))?.resource.attributes.name).toBe("Two");
457
+ expect(JsonApiGet.mock.calls.map((call) => call[0].endpoint)).not.toContain("roots/C/npcs?fetchAll=true");
458
+ });
459
+
460
+ it("a stale root-list record older than the updatedSince window brings the whole list in one read, never a read by id", async () => {
461
+ await store.putRecord({
462
+ type: "npcs",
463
+ id: "n1",
464
+ rootId: "C",
465
+ resource: npc("n1", "Mine"),
466
+ updatedAt: null,
467
+ });
468
+ await store.setSyncState("C", {
469
+ serverTime: new Date("2026-11-01T00:00:00.000Z"),
470
+ locked: false,
471
+ lockedFrom: null,
472
+ });
473
+ serve({
474
+ "roots/C/sync": syncAnswer({
475
+ entries: [entry("npcs", "n1", "2026-10-20T00:00:00.000Z"), entry("npcs", "n2", "2026-11-01T09:00:00.000Z")],
476
+ }),
477
+ "roots/C/npcs?fetchAll=true": listAnswer([npc("n1", "Theirs"), npc("n2", "Two")]),
478
+ });
479
+
480
+ await syncRoot("C");
481
+
482
+ expect((await store.getRecord("npcs", "n1"))?.resource.attributes.name).toBe("Theirs");
483
+ expect((await store.getRecord("npcs", "n1"))?.updatedAt).toEqual(new Date("2026-10-20T00:00:00.000Z"));
484
+ expect((await store.getRecord("npcs", "n2"))?.resource.attributes.name).toBe("Two");
485
+ expect(JsonApiGet.mock.calls.map((call) => call[0].endpoint)).toEqual([
486
+ "roots/C/sync",
487
+ "roots/C/npcs?fetchAll=true",
488
+ ]);
489
+ });
490
+
491
+ it("never overwrites a record whose edit is queued while the sync runs (the waiting set is read in the write transaction)", async () => {
492
+ await store.putRecord({
493
+ type: "npcs",
494
+ id: "n1",
495
+ rootId: "C",
496
+ resource: npc("n1", "Old"),
497
+ updatedAt: new Date("2026-10-01T00:00:00.000Z"),
498
+ });
499
+ // The GM saves an edit right after the sync read the queue (the reconnect window).
500
+ const readQueue = store.queue.bind(store);
501
+ let edited = false;
502
+ store.queue = async (filter) => {
503
+ const entries = await readQueue(filter);
504
+ if (!edited) {
505
+ edited = true;
506
+ void store.putRecord({
507
+ type: "npcs",
508
+ id: "n1",
509
+ rootId: "C",
510
+ resource: npc("n1", "Mine"),
511
+ updatedAt: new Date("2026-10-01T00:00:00.000Z"),
512
+ base: npc("n1", "Old"),
513
+ });
514
+ void store.enqueue({
515
+ rootId: "C",
516
+ method: "PATCH",
517
+ path: "npcs/n1",
518
+ body: { data: npc("n1", "Mine") },
519
+ recordType: "npcs",
520
+ recordId: "n1",
521
+ base: npc("n1", "Old"),
522
+ });
523
+ }
524
+ return entries;
525
+ };
526
+ serve({
527
+ "roots/C/sync": syncAnswer({ entries: [entry("npcs", "n1")] }),
528
+ "roots/C/npcs?fetchAll=true": listAnswer([npc("n1", "Theirs")]),
529
+ });
530
+
531
+ await syncRoot("C");
532
+
533
+ const record = await store.getRecord("npcs", "n1");
534
+ expect(record?.resource.attributes.name).toBe("Mine");
535
+ expect(record?.base?.attributes.name).toBe("Old");
536
+ });
537
+
538
+ it("downloads each artwork's imageUrl into the images cache under that exact URL, again whenever a sync brings a new URL for the same artwork, removing the old entry", async () => {
539
+ serve({
540
+ "roots/C/sync": syncAnswer({ entries: [entry("artworks", "a1")] }),
541
+ "artworks/a1": oneAnswer(artwork("a1", "http://api.test/artworks/a1/image?v=1")),
542
+ });
543
+
544
+ await syncRoot("C");
545
+
546
+ expect(fetchMock).toHaveBeenCalledWith("http://api.test/artworks/a1/image?v=1");
547
+ const images = cache.caches.get("wyrdli-images")!;
548
+ expect([...images.keys()]).toEqual(["http://api.test/artworks/a1/image?v=1"]);
549
+
550
+ serve({
551
+ "roots/C/sync": syncAnswer({
552
+ serverTime: "2026-11-02T10:00:00.000Z",
553
+ entries: [entry("artworks", "a1", "2026-11-02T09:00:00.000Z")],
554
+ }),
555
+ "artworks/a1": oneAnswer(artwork("a1", "http://api.test/artworks/a1/image?v=2")),
556
+ });
557
+
558
+ await syncRoot("C");
559
+
560
+ expect([...images.keys()]).toEqual(["http://api.test/artworks/a1/image?v=2"]);
561
+ });
562
+
563
+ it("linked-list types: each changed record's target found through its sync link, each target's list read once, only entries of the type kept", async () => {
564
+ tearDownSync();
565
+ const PlayerNotes = { name: "player-notes", model: TestModel };
566
+ store = await setUpSync({
567
+ types: [
568
+ ...syncTestConfig().types,
569
+ {
570
+ module: PlayerNotes,
571
+ fetch: "linked-list",
572
+ linkedList: {
573
+ linkKind: "NOTE_ON",
574
+ endpoint: (rootId, target) => `roots/${rootId}/${target.type}/${target.id}/player-notes`,
575
+ },
576
+ readOnly: true,
577
+ serverOwnedFields: [],
578
+ },
579
+ ],
580
+ });
581
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
582
+ const note = (id: string) => ({ type: "player-notes", id, attributes: { text: id } });
583
+ const link = (fromId: string, toType: string, toId: string) => ({
584
+ fromType: "player-notes",
585
+ fromId,
586
+ toType,
587
+ toId,
588
+ kind: "NOTE_ON",
589
+ props: {},
590
+ });
591
+ serve({
592
+ "roots/C/sync": syncAnswer({
593
+ entries: [entry("player-notes", "p1"), entry("player-notes", "p2"), entry("player-notes", "p3")],
594
+ links: [link("p1", "sessions", "s1"), link("p2", "clues", "k1"), link("p3", "sessions", "s1")],
595
+ }),
596
+ "roots/C/sessions/s1/player-notes": listAnswer([note("p1"), note("p3"), note("other")]),
597
+ "roots/C/clues/k1/player-notes": statusAnswer(403),
598
+ });
599
+
600
+ await syncRoot("C");
601
+
602
+ const calls = JsonApiGet.mock.calls.map((call) => call[0]);
603
+ expect(calls.map((call) => call.endpoint)).toEqual([
604
+ "roots/C/sync",
605
+ "roots/C/sessions/s1/player-notes",
606
+ "roots/C/clues/k1/player-notes",
607
+ ]);
608
+ expect(calls[1].classKey).toBe(PlayerNotes);
609
+ expect((await store.getRecord("player-notes", "p1"))?.updatedAt).toEqual(new Date("2026-10-31T10:00:00.000Z"));
610
+ expect(await store.getRecord("player-notes", "p3")).toBeDefined();
611
+ expect(await store.getRecord("player-notes", "p2")).toBeUndefined();
612
+ expect(await store.getRecord("player-notes", "other")).toBeUndefined();
613
+ expect((await store.getSyncState("C")).serverTime).toEqual(new Date("2026-11-01T10:00:00.000Z"));
614
+ warn.mockRestore();
615
+ });
616
+
617
+ it("a locked answer stores locked and downloads nothing", async () => {
618
+ serve({
619
+ "roots/C/sync": syncAnswer({ locked: true, lockedFrom: "2026-10-30T00:00:00.000Z", entries: [], links: [] }),
620
+ });
621
+
622
+ await syncRoot("C");
623
+
624
+ expect(JsonApiGet).toHaveBeenCalledTimes(1);
625
+ const state = await store.getSyncState("C");
626
+ expect(state.locked).toBe(true);
627
+ expect(state.lockedFrom).toEqual(new Date("2026-10-30T00:00:00.000Z"));
628
+ expect(fetchMock).not.toHaveBeenCalled();
629
+ });
630
+ });
631
+
632
+ describe("syncAll", () => {
633
+ let store: DeviceStore;
634
+
635
+ beforeEach(async () => {
636
+ store = await setUpSync({ listSyncRoots: async () => ["C"] });
637
+ JsonApiGet.mockReset();
638
+ vi.stubGlobal("caches", fakeCacheStorage().storage);
639
+ vi.stubGlobal(
640
+ "fetch",
641
+ vi.fn(async () => ({ ok: true, status: 200, clone: () => ({}) })),
642
+ );
643
+ });
644
+
645
+ afterEach(() => {
646
+ tearDownSync();
647
+ vi.unstubAllGlobals();
648
+ });
649
+
650
+ it("removes a root record that is no longer a sync root, unless its queue has entries", async () => {
651
+ await store.putRecord({
652
+ type: "roots",
653
+ id: "C",
654
+ rootId: "C",
655
+ resource: { type: "roots", id: "C" },
656
+ updatedAt: null,
657
+ });
658
+ await store.putRecord({
659
+ type: "roots",
660
+ id: "D",
661
+ rootId: "D",
662
+ resource: { type: "roots", id: "D" },
663
+ updatedAt: null,
664
+ });
665
+ await store.putRecord({ type: "npcs", id: "d1", rootId: "D", resource: npc("d1", "D1", "D"), updatedAt: null });
666
+ await store.putRecord({
667
+ type: "roots",
668
+ id: "E",
669
+ rootId: "E",
670
+ resource: { type: "roots", id: "E" },
671
+ updatedAt: null,
672
+ });
673
+ await store.putRecord({ type: "npcs", id: "e1", rootId: "E", resource: npc("e1", "E1", "E"), updatedAt: null });
674
+ await store.enqueue({
675
+ rootId: "E",
676
+ method: "PATCH",
677
+ path: "npcs/e1",
678
+ body: { data: npc("e1", "E1", "E") },
679
+ recordType: "npcs",
680
+ recordId: "e1",
681
+ base: null,
682
+ });
683
+
684
+ serve({
685
+ "roots/C/sync": syncAnswer({ entries: [entry("roots", "C", "2026-01-01T00:00:00.000Z")] }),
686
+ "roots/C": oneAnswer({ type: "roots", id: "C" }),
687
+ });
688
+
689
+ await syncAll();
690
+
691
+ expect(await store.getRecord("roots", "D")).toBeUndefined();
692
+ expect(await store.getRecord("npcs", "d1")).toBeUndefined();
693
+ expect(await store.getRecord("roots", "E")).toBeDefined();
694
+ expect(await store.getRecord("npcs", "e1")).toBeDefined();
695
+ expect(await store.getRecord("roots", "C")).toBeDefined();
696
+ });
697
+
698
+ it("reports progress per root record and, once every root record was tried, the last sync time and the root record count", async () => {
699
+ tearDownSync();
700
+ localStorage.clear();
701
+ store = await setUpSync({ listSyncRoots: async () => ["C", "X"] });
702
+ serve({
703
+ "roots/C/sync": syncAnswer({ entries: [] }),
704
+ "roots/X/sync": statusAnswer(500),
705
+ });
706
+ const progress: ({ done: number; total: number } | null)[] = [];
707
+ const unsubscribe = subscribeOfflineState((state) => {
708
+ if (progress[progress.length - 1] !== state.syncProgress) progress.push(state.syncProgress);
709
+ });
710
+ const now = vi.spyOn(Date, "now").mockReturnValue(1761990000000);
711
+
712
+ await syncAll();
713
+ unsubscribe();
714
+
715
+ expect(progress.filter((value) => value !== null)).toEqual([
716
+ { done: 0, total: 2 },
717
+ { done: 1, total: 2 },
718
+ { done: 2, total: 2 },
719
+ ]);
720
+ expect(getOfflineState().syncProgress).toBeNull();
721
+ expect(getOfflineState().lastSyncedAt).toBe(1761990000000);
722
+ expect(getOfflineState().rootCount).toBe(2);
723
+ expect(localStorage.getItem("nja-offline-last-sync:u1")).toBe("1761990000000");
724
+ now.mockRestore();
725
+ localStorage.clear();
726
+ });
727
+ });