@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,337 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { getOfflineState, isReconnecting, setOfflineState } from "../../state";
3
+ import { markOfflineUnavailable, OfflineUnavailableError } from "../../config";
4
+ import type { DeviceStore } from "../../store/DeviceStore";
5
+ import { setUpSync, tearDownSync } from "./sync-fixtures";
6
+
7
+ const { drainQueue, syncAll } = vi.hoisted(() => ({ drainQueue: vi.fn(), syncAll: vi.fn() }));
8
+ vi.mock("../drain-queue", () => ({ drainQueue }));
9
+ vi.mock("../sync-root", async (importOriginal) => ({
10
+ ...(await importOriginal<typeof import("../sync-root")>()),
11
+ syncAll,
12
+ }));
13
+ const { directFetch } = vi.hoisted(() => ({ directFetch: vi.fn() }));
14
+ vi.mock("../../../client/request", () => ({ directFetch }));
15
+ vi.mock("../../../unified/JsonApiRequest", () => ({
16
+ JsonApiGet: vi.fn(),
17
+ JsonApiPost: vi.fn(),
18
+ getApiUrl: () => "http://api.test/",
19
+ buildUrl: (endpoint: string) => `http://api.test/${endpoint}`,
20
+ }));
21
+
22
+ import { startOfflineRuntime, stopOfflineRuntime } from "../runtime";
23
+
24
+ const flush = async () => {
25
+ for (let i = 0; i < 20; i++) await Promise.resolve();
26
+ await vi.advanceTimersByTimeAsync(0);
27
+ };
28
+
29
+ describe("startOfflineRuntime", () => {
30
+ let store: DeviceStore;
31
+ let stop: (() => void) | undefined;
32
+
33
+ beforeEach(async () => {
34
+ store = await setUpSync({ syncIntervalMs: 300000 });
35
+ vi.useFakeTimers({ toFake: ["setInterval", "clearInterval", "setTimeout", "clearTimeout", "Date"] });
36
+ vi.setSystemTime(new Date("2026-11-01T10:00:00.000Z"));
37
+ drainQueue.mockReset();
38
+ drainQueue.mockResolvedValue({ applied: 0, conflicts: 0, stoppedForLogin: false });
39
+ syncAll.mockReset();
40
+ syncAll.mockResolvedValue(undefined);
41
+ directFetch.mockReset();
42
+ });
43
+
44
+ afterEach(() => {
45
+ stop?.();
46
+ stop = undefined;
47
+ vi.useRealTimers();
48
+ tearDownSync();
49
+ });
50
+
51
+ it("on reconnect runs drainQueue then syncAll", async () => {
52
+ setOfflineState({ online: false });
53
+ const order: string[] = [];
54
+ drainQueue.mockImplementation(async () => {
55
+ order.push("drain");
56
+ return { applied: 0, conflicts: 0, stoppedForLogin: false };
57
+ });
58
+ syncAll.mockImplementation(async () => void order.push("sync"));
59
+
60
+ stop = startOfflineRuntime();
61
+ await flush();
62
+ expect(order).toEqual([]);
63
+
64
+ setOfflineState({ online: true });
65
+ await flush();
66
+
67
+ expect(order).toEqual(["drain", "sync"]);
68
+ });
69
+
70
+ it("runs syncAll every syncIntervalMs while online", async () => {
71
+ stop = startOfflineRuntime();
72
+ await flush();
73
+ const atStart = syncAll.mock.calls.length;
74
+
75
+ await vi.advanceTimersByTimeAsync(300000);
76
+ expect(syncAll.mock.calls.length).toBe(atStart + 1);
77
+
78
+ await vi.advanceTimersByTimeAsync(300000);
79
+ expect(syncAll.mock.calls.length).toBe(atStart + 2);
80
+ });
81
+
82
+ it("does nothing while offline", async () => {
83
+ setOfflineState({ online: false });
84
+ vi.stubGlobal("navigator", { ...navigator, onLine: false });
85
+
86
+ stop = startOfflineRuntime();
87
+ await vi.advanceTimersByTimeAsync(900000);
88
+
89
+ expect(drainQueue).not.toHaveBeenCalled();
90
+ expect(syncAll).not.toHaveBeenCalled();
91
+ vi.unstubAllGlobals();
92
+ });
93
+
94
+ it("recomputes lockedRootIds from the stored sync states on start and every minute", async () => {
95
+ setOfflineState({ online: false });
96
+ vi.stubGlobal("navigator", { ...navigator, onLine: false });
97
+ await store.putRecord({
98
+ type: "roots",
99
+ id: "C",
100
+ rootId: "C",
101
+ resource: { type: "roots", id: "C" },
102
+ updatedAt: null,
103
+ });
104
+ await store.putRecord({
105
+ type: "roots",
106
+ id: "D",
107
+ rootId: "D",
108
+ resource: { type: "roots", id: "D" },
109
+ updatedAt: null,
110
+ });
111
+ await store.setSyncState("C", { serverTime: null, locked: true, lockedFrom: null });
112
+ await store.setSyncState("D", {
113
+ serverTime: null,
114
+ locked: false,
115
+ lockedFrom: new Date("2026-11-01T10:00:30.000Z"),
116
+ });
117
+
118
+ stop = startOfflineRuntime();
119
+ await flush();
120
+ expect(getOfflineState().lockedRootIds).toEqual(["C"]);
121
+
122
+ await vi.advanceTimersByTimeAsync(60000);
123
+ await flush();
124
+ expect([...getOfflineState().lockedRootIds].sort()).toEqual(["C", "D"]);
125
+ vi.unstubAllGlobals();
126
+ });
127
+
128
+ it("while offline with the browser online, probes the API with the replay header and, when it answers, runs drainQueue then syncAll and goes online only once the sync succeeded", async () => {
129
+ await store.putRecord({
130
+ type: "roots",
131
+ id: "C",
132
+ rootId: "C",
133
+ resource: { type: "roots", id: "C" },
134
+ updatedAt: null,
135
+ });
136
+ setOfflineState({ online: false });
137
+ vi.stubGlobal("navigator", { ...navigator, onLine: true });
138
+ directFetch.mockResolvedValue({ ok: false, status: 503, statusText: "", data: undefined, networkError: true });
139
+
140
+ stop = startOfflineRuntime();
141
+ await vi.advanceTimersByTimeAsync(30000);
142
+ await flush();
143
+
144
+ expect(directFetch).toHaveBeenCalledTimes(1);
145
+ const params = directFetch.mock.calls[0][0];
146
+ expect(params.method).toBe("GET");
147
+ expect(params.url).toBe("http://api.test/roots/C/sync");
148
+ expect(params.additionalHeaders).toEqual({ "x-offline-replay": "1" });
149
+ expect(getOfflineState().online).toBe(false);
150
+ expect(drainQueue).not.toHaveBeenCalled();
151
+
152
+ const order: string[] = [];
153
+ drainQueue.mockImplementation(async () => {
154
+ order.push(`drain:${getOfflineState().online ? "online" : "offline"}`);
155
+ return { applied: 0, conflicts: 0, stoppedForLogin: false };
156
+ });
157
+ syncAll.mockImplementation(async () => void order.push(`sync:${getOfflineState().online ? "online" : "offline"}`));
158
+ directFetch.mockResolvedValue({ ok: true, status: 200, statusText: "OK", data: {} });
159
+
160
+ await vi.advanceTimersByTimeAsync(30000);
161
+ await flush();
162
+
163
+ expect(order).toEqual(["drain:offline", "sync:offline"]);
164
+ expect(getOfflineState().online).toBe(true);
165
+ // Going online after the sync does not run the drain and the sync a second time.
166
+ expect(drainQueue).toHaveBeenCalledTimes(1);
167
+ expect(syncAll).toHaveBeenCalledTimes(1);
168
+ vi.unstubAllGlobals();
169
+ });
170
+
171
+ it("a change queued while the reconnect attempt ran (a resolved conflict) is sent as soon as the session goes online", async () => {
172
+ await store.putRecord({
173
+ type: "roots",
174
+ id: "C",
175
+ rootId: "C",
176
+ resource: { type: "roots", id: "C" },
177
+ updatedAt: null,
178
+ });
179
+ setOfflineState({ online: false });
180
+ vi.stubGlobal("navigator", { ...navigator, onLine: true });
181
+ directFetch.mockResolvedValue({ ok: true, status: 200, statusText: "OK", data: {} });
182
+ // The GM resolves a conflict during the sync: the session is still offline, so it only queues.
183
+ syncAll.mockImplementation(async () => void setOfflineState({ pending: 1 }));
184
+
185
+ stop = startOfflineRuntime();
186
+ await vi.advanceTimersByTimeAsync(30000);
187
+ await flush();
188
+
189
+ expect(getOfflineState().online).toBe(true);
190
+ expect(drainQueue).toHaveBeenCalledTimes(2);
191
+ expect(syncAll).toHaveBeenCalledTimes(1);
192
+ vi.unstubAllGlobals();
193
+ });
194
+
195
+ it("after a probe reaches the API, the session stays offline when the network fails during the drain or the sync", async () => {
196
+ setOfflineState({ online: false });
197
+ vi.stubGlobal("navigator", { ...navigator, onLine: true });
198
+ directFetch.mockResolvedValue({ ok: true, status: 200, statusText: "OK", data: {} });
199
+ // A request of the sync fails on the network: the request layer marks the session offline.
200
+ syncAll.mockImplementation(async () => setOfflineState({ online: false }));
201
+
202
+ stop = startOfflineRuntime();
203
+ await vi.advanceTimersByTimeAsync(30000);
204
+ await flush();
205
+
206
+ expect(drainQueue).toHaveBeenCalledTimes(1);
207
+ expect(syncAll).toHaveBeenCalledTimes(1);
208
+ expect(getOfflineState().online).toBe(false);
209
+ expect(isReconnecting()).toBe(false);
210
+
211
+ drainQueue.mockImplementation(async () => {
212
+ setOfflineState({ online: false });
213
+ return { applied: 0, conflicts: 0, stoppedForLogin: false };
214
+ });
215
+ await vi.advanceTimersByTimeAsync(30000);
216
+ await flush();
217
+
218
+ expect(drainQueue).toHaveBeenCalledTimes(2);
219
+ expect(syncAll).toHaveBeenCalledTimes(1);
220
+ expect(getOfflineState().online).toBe(false);
221
+ vi.unstubAllGlobals();
222
+ });
223
+
224
+ it("after a probe reaches the API, an expired sign-in stops the drain: no sync, the session stays offline", async () => {
225
+ const reload = vi.fn();
226
+ vi.stubGlobal("location", { ...window.location, reload, pathname: "/" });
227
+ vi.stubGlobal("navigator", { ...navigator, onLine: true });
228
+ document.cookie = "token=t9; path=/";
229
+ sessionStorage.clear();
230
+ setOfflineState({ online: false });
231
+ directFetch.mockResolvedValue({ ok: true, status: 200, statusText: "OK", data: {} });
232
+ drainQueue.mockResolvedValue({ applied: 0, conflicts: 0, stoppedForLogin: true });
233
+
234
+ stop = startOfflineRuntime();
235
+ await vi.advanceTimersByTimeAsync(30000);
236
+ await flush();
237
+
238
+ expect(drainQueue).toHaveBeenCalledTimes(1);
239
+ expect(syncAll).not.toHaveBeenCalled();
240
+ expect(reload).toHaveBeenCalledTimes(1);
241
+ expect(getOfflineState().online).toBe(false);
242
+
243
+ document.cookie = "token=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/";
244
+ sessionStorage.clear();
245
+ vi.unstubAllGlobals();
246
+ });
247
+
248
+ it("the browser online event probes the API right away and goes online only when the probe reaches it", async () => {
249
+ setOfflineState({ online: false });
250
+ vi.stubGlobal("navigator", { ...navigator, onLine: true });
251
+ directFetch.mockResolvedValue({ ok: false, status: 503, statusText: "", data: undefined, networkError: true });
252
+
253
+ stop = startOfflineRuntime();
254
+ await flush();
255
+ window.dispatchEvent(new Event("online"));
256
+ await flush();
257
+
258
+ expect(directFetch).toHaveBeenCalledTimes(1);
259
+ expect(getOfflineState().online).toBe(false);
260
+
261
+ directFetch.mockResolvedValue({ ok: true, status: 200, statusText: "OK", data: {} });
262
+ window.dispatchEvent(new Event("online"));
263
+ await flush();
264
+
265
+ expect(directFetch).toHaveBeenCalledTimes(2);
266
+ expect(getOfflineState().online).toBe(true);
267
+ vi.unstubAllGlobals();
268
+ });
269
+
270
+ it("an expired sign-in reloads for the login once per sign-in, never in a loop (a 401 while the cookie exists)", async () => {
271
+ const reload = vi.fn();
272
+ vi.stubGlobal("location", { ...window.location, reload, pathname: "/" });
273
+ document.cookie = "token=t1; path=/";
274
+ sessionStorage.clear();
275
+ drainQueue.mockResolvedValue({ applied: 0, conflicts: 0, stoppedForLogin: true });
276
+
277
+ stop = startOfflineRuntime();
278
+ await flush();
279
+ expect(reload).toHaveBeenCalledTimes(1);
280
+
281
+ // The page reloaded and the API still answers 401: the GM stays on the page, the queue stays.
282
+ stop();
283
+ stop = startOfflineRuntime();
284
+ await flush();
285
+ await vi.advanceTimersByTimeAsync(300000);
286
+ await flush();
287
+ expect(reload).toHaveBeenCalledTimes(1);
288
+
289
+ // A new sign-in may reload once more.
290
+ document.cookie = "token=t2; path=/";
291
+ await vi.advanceTimersByTimeAsync(300000);
292
+ await flush();
293
+ expect(reload).toHaveBeenCalledTimes(2);
294
+
295
+ document.cookie = "token=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/";
296
+ sessionStorage.clear();
297
+ vi.unstubAllGlobals();
298
+ });
299
+
300
+ it("stopOfflineRuntime stops every running runtime (logout)", async () => {
301
+ stop = startOfflineRuntime();
302
+ await flush();
303
+ const atStart = syncAll.mock.calls.length;
304
+
305
+ stopOfflineRuntime();
306
+ await vi.advanceTimersByTimeAsync(900000);
307
+
308
+ expect(syncAll.mock.calls.length).toBe(atStart);
309
+ });
310
+
311
+ it("a browser found unable to keep the device copy stops the runtime: no sync, no lock check, no warning per tick", async () => {
312
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
313
+ stop = startOfflineRuntime();
314
+ await flush();
315
+ const atStart = syncAll.mock.calls.length;
316
+
317
+ markOfflineUnavailable(new OfflineUnavailableError("the browser offers no private file storage"));
318
+ await vi.advanceTimersByTimeAsync(900000);
319
+
320
+ expect(syncAll.mock.calls.length).toBe(atStart);
321
+ expect(warn).toHaveBeenCalledTimes(1);
322
+ warn.mockRestore();
323
+ });
324
+
325
+ it("does not start in a browser that cannot keep the device copy", async () => {
326
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
327
+ markOfflineUnavailable(new OfflineUnavailableError("the browser offers no private file storage"));
328
+
329
+ stop = startOfflineRuntime();
330
+ await vi.advanceTimersByTimeAsync(900000);
331
+
332
+ expect(drainQueue).not.toHaveBeenCalled();
333
+ expect(syncAll).not.toHaveBeenCalled();
334
+ expect(getOfflineState().available).toBe(false);
335
+ warn.mockRestore();
336
+ });
337
+ });
@@ -0,0 +1,103 @@
1
+ import { vi } from "vitest";
2
+ import { ApiRequestDataTypeInterface } from "../../../core/interfaces/ApiRequestDataTypeInterface";
3
+ import { ModuleRegistry } from "../../../core/registry/ModuleRegistry";
4
+ import { ModuleFactory, ModuleWithPermissions } from "../../../permissions/types";
5
+ import { configureOffline, resetOfflineConfig, setDeviceStoreOpener } from "../../config";
6
+ import { resetOfflineState } from "../../state";
7
+ import type { DeviceStore } from "../../store/DeviceStore";
8
+ import { openMemoryDeviceStore } from "../../store/lifecycle";
9
+ import type { OfflineConfig } from "../../types";
10
+ import { offlineModules } from "../models/offline.modules";
11
+
12
+ export class TestModel {
13
+ rehydrate() {
14
+ return this;
15
+ }
16
+ }
17
+
18
+ export const Roots = { name: "roots", model: TestModel };
19
+ export const Npcs = { name: "npcs", model: TestModel };
20
+ export const Artworks = { name: "artworks", model: TestModel };
21
+ export const Templates = { name: "templates", model: TestModel };
22
+
23
+ const moduleFactory: ModuleFactory = (params) =>
24
+ ({ pageUrl: params.pageUrl, name: params.name, model: params.model }) as ModuleWithPermissions;
25
+
26
+ export function registerOfflineModules(): void {
27
+ for (const [key, module] of Object.entries(offlineModules(moduleFactory))) {
28
+ try {
29
+ ModuleRegistry.get(key as any);
30
+ } catch {
31
+ ModuleRegistry.register(key, module as ApiRequestDataTypeInterface);
32
+ }
33
+ }
34
+ }
35
+
36
+ export const npc = (id: string, name: string, rootId = "C") => ({
37
+ type: "npcs",
38
+ id,
39
+ attributes: { name },
40
+ relationships: { root: { data: { type: "roots", id: rootId } } },
41
+ });
42
+
43
+ export const artwork = (id: string, imageUrl: string) => ({
44
+ type: "artworks",
45
+ id,
46
+ attributes: { name: id, imageUrl },
47
+ });
48
+
49
+ export function syncTestConfig(overrides: Partial<OfflineConfig> = {}): OfflineConfig {
50
+ return {
51
+ getUserId: () => "u1",
52
+ rootModule: Roots,
53
+ types: [
54
+ { module: Roots, fetch: "by-id", readOnly: false, serverOwnedFields: [] },
55
+ { module: Npcs, fetch: "root-list", readOnly: false, serverOwnedFields: [] },
56
+ { module: Artworks, fetch: "by-id", readOnly: false, serverOwnedFields: [] },
57
+ { module: Templates, fetch: "by-id", readOnly: true, serverOwnedFields: [] },
58
+ ],
59
+ routes: [],
60
+ listSyncRoots: async () => ["C"],
61
+ shellPath: (locale) => (locale === "it" ? "/it/offline-shell" : "/offline-shell"),
62
+ syncIntervalMs: 300000,
63
+ ...overrides,
64
+ };
65
+ }
66
+
67
+ export async function setUpSync(overrides: Partial<OfflineConfig> = {}): Promise<DeviceStore> {
68
+ resetOfflineState();
69
+ resetOfflineConfig();
70
+ registerOfflineModules();
71
+ const store = await openMemoryDeviceStore();
72
+ configureOffline(syncTestConfig(overrides));
73
+ setDeviceStoreOpener(async () => store);
74
+ return store;
75
+ }
76
+
77
+ export function tearDownSync(): void {
78
+ resetOfflineConfig();
79
+ resetOfflineState();
80
+ }
81
+
82
+ /** A minimal Cache Storage: one map per cache name. */
83
+ export function fakeCacheStorage(initial: string[] = ["wyrdli-offline-1"]) {
84
+ const caches = new Map<string, Map<string, unknown>>();
85
+ for (const name of initial) caches.set(name, new Map());
86
+ const cacheOf = (name: string) => {
87
+ if (!caches.has(name)) caches.set(name, new Map());
88
+ const entries = caches.get(name)!;
89
+ return {
90
+ match: vi.fn(async (url: string) => entries.get(url)),
91
+ put: vi.fn(async (url: string, response: unknown) => void entries.set(url, response)),
92
+ delete: vi.fn(async (url: string) => entries.delete(url)),
93
+ };
94
+ };
95
+ return {
96
+ caches,
97
+ storage: {
98
+ keys: vi.fn(async () => [...caches.keys()]),
99
+ open: vi.fn(async (name: string) => cacheOf(name)),
100
+ delete: vi.fn(async (name: string) => caches.delete(name)),
101
+ },
102
+ };
103
+ }