@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,179 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import {
3
+ configureOffline,
4
+ DeviceStoreUnavailableError,
5
+ getOfflineStore,
6
+ isOfflineConfigured,
7
+ isOfflineEnabledOnDevice,
8
+ resetOfflineConfig,
9
+ setDeviceStoreOpener,
10
+ } from "../config";
11
+ import { turnOffOffline, turnOnOffline } from "../device-switch";
12
+ import { getOfflineState, resetOfflineState, setOfflineState } from "../state";
13
+ import type { DeviceStore, QueueEntry } from "../store/DeviceStore";
14
+ import type { OfflineConfig } from "../types";
15
+
16
+ const lifecycle = vi.hoisted(() => ({ deleteDeviceStore: vi.fn(), hasUnsentChanges: vi.fn() }));
17
+ vi.mock("../store/lifecycle", () => lifecycle);
18
+ const runtime = vi.hoisted(() => ({ stopOfflineRuntime: vi.fn() }));
19
+ vi.mock("../sync/runtime", () => runtime);
20
+
21
+ const ENABLED_KEY = "nja-offline-enabled:u1";
22
+ const LAST_SYNC_KEY = "nja-offline-last-sync:u1";
23
+
24
+ function config(overrides: Partial<OfflineConfig> = {}): OfflineConfig {
25
+ return {
26
+ getUserId: () => "u1",
27
+ rootModule: { name: "roots" } as any,
28
+ types: [],
29
+ routes: [],
30
+ listSyncRoots: async () => [],
31
+ shellPath: () => "/offline-shell",
32
+ syncIntervalMs: 300000,
33
+ ...overrides,
34
+ };
35
+ }
36
+
37
+ function storeWithQueue(entries: Partial<QueueEntry>[]): DeviceStore {
38
+ return { queue: vi.fn(async () => entries as QueueEntry[]) } as unknown as DeviceStore;
39
+ }
40
+
41
+ describe("offline device switch", () => {
42
+ beforeEach(() => {
43
+ localStorage.clear();
44
+ resetOfflineConfig();
45
+ resetOfflineState();
46
+ lifecycle.deleteDeviceStore.mockReset().mockResolvedValue(undefined);
47
+ lifecycle.hasUnsentChanges.mockReset().mockResolvedValue(false);
48
+ runtime.stopOfflineRuntime.mockReset();
49
+ vi.stubGlobal("caches", { keys: vi.fn(async () => ["app-images"]), delete: vi.fn(async () => true) });
50
+ });
51
+
52
+ afterEach(() => {
53
+ resetOfflineConfig();
54
+ resetOfflineState();
55
+ localStorage.clear();
56
+ vi.unstubAllGlobals();
57
+ });
58
+
59
+ it("without requireOptIn offline mode is on, as before", async () => {
60
+ configureOffline(config());
61
+ setDeviceStoreOpener(async () => storeWithQueue([]));
62
+
63
+ expect(isOfflineEnabledOnDevice("u1")).toBe(true);
64
+ expect(isOfflineConfigured()).toBe(true);
65
+ expect(getOfflineState().enabled).toBe(true);
66
+ await expect(getOfflineStore()).resolves.toBeDefined();
67
+ });
68
+
69
+ it("with requireOptIn and no stored switch, offline mode is off and the store is refused", async () => {
70
+ configureOffline(config({ requireOptIn: true }));
71
+ const opener = vi.fn(async () => storeWithQueue([]));
72
+ setDeviceStoreOpener(opener);
73
+
74
+ expect(isOfflineConfigured()).toBe(false);
75
+ expect(getOfflineState().enabled).toBe(false);
76
+ await expect(getOfflineStore()).rejects.toBeInstanceOf(DeviceStoreUnavailableError);
77
+ expect(opener).not.toHaveBeenCalled();
78
+ });
79
+
80
+ it("with requireOptIn and no signed-in user, offline mode is off", () => {
81
+ configureOffline(config({ requireOptIn: true, getUserId: () => undefined }));
82
+ expect(isOfflineEnabledOnDevice(undefined)).toBe(false);
83
+ expect(isOfflineConfigured()).toBe(false);
84
+ });
85
+
86
+ it("reads the stored switch and the last sync time at configure", () => {
87
+ localStorage.setItem(ENABLED_KEY, "1");
88
+ localStorage.setItem(LAST_SYNC_KEY, "1761990000000");
89
+ configureOffline(config({ requireOptIn: true }));
90
+
91
+ expect(isOfflineConfigured()).toBe(true);
92
+ expect(getOfflineState()).toEqual(expect.objectContaining({ enabled: true, lastSyncedAt: 1761990000000 }));
93
+ });
94
+
95
+ it("turnOnOffline stores the switch and sets enabled", async () => {
96
+ const persist = vi.fn(async () => true);
97
+ vi.stubGlobal("navigator", { ...navigator, storage: { persist } });
98
+ configureOffline(config({ requireOptIn: true }));
99
+
100
+ await turnOnOffline();
101
+
102
+ expect(localStorage.getItem(ENABLED_KEY)).toBe("1");
103
+ expect(getOfflineState().enabled).toBe(true);
104
+ expect(isOfflineConfigured()).toBe(true);
105
+ expect(persist).toHaveBeenCalled();
106
+ });
107
+
108
+ it("turnOnOffline ignores a failing persist request", async () => {
109
+ const persist = vi.fn(async () => Promise.reject(new Error("no")));
110
+ vi.stubGlobal("navigator", { ...navigator, storage: { persist } });
111
+ configureOffline(config({ requireOptIn: true }));
112
+
113
+ await expect(turnOnOffline()).resolves.toBeUndefined();
114
+ expect(getOfflineState().enabled).toBe(true);
115
+ });
116
+
117
+ it("turnOffOffline refuses, changing nothing, while changes wait to be sent", async () => {
118
+ localStorage.setItem(ENABLED_KEY, "1");
119
+ configureOffline(config({ requireOptIn: true }));
120
+ setDeviceStoreOpener(async () => storeWithQueue([{ status: "pending" }, { status: "failed" }]));
121
+
122
+ expect(await turnOffOffline()).toEqual({ turnedOff: false, pending: 2 });
123
+
124
+ expect(localStorage.getItem(ENABLED_KEY)).toBe("1");
125
+ expect(getOfflineState().enabled).toBe(true);
126
+ expect(runtime.stopOfflineRuntime).not.toHaveBeenCalled();
127
+ expect(lifecycle.deleteDeviceStore).not.toHaveBeenCalled();
128
+ });
129
+
130
+ it("turnOffOffline refuses when the copy is held by another tab and its marker says changes wait", async () => {
131
+ localStorage.setItem(ENABLED_KEY, "1");
132
+ configureOffline(config({ requireOptIn: true }));
133
+ setDeviceStoreOpener(async () => {
134
+ throw new Error("held by another tab");
135
+ });
136
+ lifecycle.hasUnsentChanges.mockResolvedValue(true);
137
+
138
+ expect(await turnOffOffline()).toEqual({ turnedOff: false, pending: 1 });
139
+
140
+ expect(lifecycle.hasUnsentChanges).toHaveBeenCalledWith("u1");
141
+ expect(localStorage.getItem(ENABLED_KEY)).toBe("1");
142
+ expect(lifecycle.deleteDeviceStore).not.toHaveBeenCalled();
143
+ });
144
+
145
+ it("turnOffOffline deletes the device copy and clears the switch and the state", async () => {
146
+ localStorage.setItem(ENABLED_KEY, "1");
147
+ localStorage.setItem(LAST_SYNC_KEY, "1761990000000");
148
+ configureOffline(config({ requireOptIn: true }));
149
+ const opener = vi.fn(async () => storeWithQueue([]));
150
+ setDeviceStoreOpener(opener);
151
+ setOfflineState({ rootCount: 3, syncProgress: { done: 1, total: 3 } });
152
+
153
+ expect(await turnOffOffline()).toEqual({ turnedOff: true });
154
+
155
+ expect(runtime.stopOfflineRuntime).toHaveBeenCalled();
156
+ expect(lifecycle.deleteDeviceStore).toHaveBeenCalledWith("u1");
157
+ expect(caches.delete).toHaveBeenCalledWith("app-images");
158
+ expect(localStorage.getItem(ENABLED_KEY)).toBeNull();
159
+ expect(localStorage.getItem(LAST_SYNC_KEY)).toBeNull();
160
+ expect(getOfflineState()).toEqual(
161
+ expect.objectContaining({ enabled: false, syncProgress: null, lastSyncedAt: null, rootCount: null }),
162
+ );
163
+ expect(isOfflineConfigured()).toBe(false);
164
+ await expect(getOfflineStore()).rejects.toBeInstanceOf(DeviceStoreUnavailableError);
165
+ expect(opener).toHaveBeenCalledTimes(1);
166
+ });
167
+
168
+ it("turnOffOffline on a browser that cannot keep the copy only clears the switch and the state", async () => {
169
+ localStorage.setItem(ENABLED_KEY, "1");
170
+ configureOffline(config({ requireOptIn: true }));
171
+ setOfflineState({ available: false });
172
+
173
+ expect(await turnOffOffline()).toEqual({ turnedOff: true });
174
+
175
+ expect(lifecycle.deleteDeviceStore).not.toHaveBeenCalled();
176
+ expect(localStorage.getItem(ENABLED_KEY)).toBeNull();
177
+ expect(getOfflineState().enabled).toBe(false);
178
+ });
179
+ });
@@ -0,0 +1,380 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
2
+ import { buildGenericRoutes, linksOfKind, listAnswer } from "../generic-answers";
3
+ import { matchRoute } from "../router";
4
+ import { setOfflineState } from "../state";
5
+ import type { DeviceStore } from "../store/DeviceStore";
6
+ import type { OfflineAnswer, OfflineConfig, OfflineMethod } from "../types";
7
+ import { npc, offlineConfig, seededStore } from "./offline-fixtures";
8
+
9
+ const API = "http://api.test/";
10
+
11
+ async function answer(
12
+ store: DeviceStore,
13
+ method: OfflineMethod,
14
+ relative: string,
15
+ body?: any,
16
+ config: OfflineConfig = offlineConfig(),
17
+ ): Promise<OfflineAnswer> {
18
+ const [path, query = ""] = relative.split("?");
19
+ const match = matchRoute(buildGenericRoutes(config), method, path);
20
+ if (!match) throw new Error(`no generic route for ${method} ${path}`);
21
+ return match.route.answer({
22
+ request: { method, path, query: new URLSearchParams(query), body, url: `${API}${relative}` },
23
+ params: match.params,
24
+ store,
25
+ });
26
+ }
27
+
28
+ describe("generic answers", () => {
29
+ let store: DeviceStore;
30
+
31
+ beforeEach(async () => {
32
+ store = await seededStore();
33
+ });
34
+
35
+ afterEach(() => {
36
+ setOfflineState({ lockedRootIds: [] });
37
+ });
38
+
39
+ it("GET npcs/:id answers the resource with the linked resources as included", async () => {
40
+ const result = await answer(store, "GET", "npcs/n1");
41
+
42
+ expect(result.status).toBe(200);
43
+ expect(result.body.data).toEqual(npc("n1", "Alda", "C"));
44
+ expect(result.body.included).toEqual([
45
+ { type: "factions", id: "f1", attributes: { name: "Guild" } },
46
+ { type: "roots", id: "C", attributes: { name: "Camp C" } },
47
+ ]);
48
+ });
49
+
50
+ it("included also follows the relationships of the record and of its included records to stored records (the root record, then its owner)", async () => {
51
+ await store.putRecord({
52
+ type: "roots",
53
+ id: "C",
54
+ rootId: "C",
55
+ resource: {
56
+ type: "roots",
57
+ id: "C",
58
+ attributes: { name: "Camp C" },
59
+ relationships: { owner: { data: { type: "users", id: "u1" } } },
60
+ },
61
+ updatedAt: null,
62
+ });
63
+ await store.putRecord({
64
+ type: "users",
65
+ id: "u1",
66
+ rootId: "C",
67
+ resource: { type: "users", id: "u1", attributes: { name: "The GM" } },
68
+ updatedAt: null,
69
+ });
70
+
71
+ const result = await answer(store, "GET", "npcs/n1");
72
+
73
+ expect(
74
+ result.body.included.map((resource: { type: string; id: string }) => `${resource.type}:${resource.id}`),
75
+ ).toEqual(["factions:f1", "roots:C", "users:u1"]);
76
+ });
77
+
78
+ it("GET npcs/:id of a record not on the device (or a literal endpoint with no answer) needs a connection", async () => {
79
+ for (const relative of ["npcs/missing", "npcs/playing"]) {
80
+ const result = await answer(store, "GET", relative);
81
+ expect(result.status).toBe(503);
82
+ expect(result.body.errors[0].code).toBe("needs-connection");
83
+ }
84
+ });
85
+
86
+ it("a list of a root record or parent that is not on the device needs a connection", async () => {
87
+ for (const relative of ["roots/X/npcs?fetchAll=true", "factions/missing/npcs?fetchAll=true"]) {
88
+ const result = await answer(store, "GET", relative);
89
+ expect(result.status).toBe(503);
90
+ expect(result.body.errors[0].code).toBe("needs-connection");
91
+ }
92
+ });
93
+
94
+ it("a stored parent with no linked records answers 200 with an empty list", async () => {
95
+ const result = await answer(store, "GET", "npcs/n2/factions?fetchAll=true");
96
+
97
+ expect(result.status).toBe(200);
98
+ expect(result.body.data).toEqual([]);
99
+ });
100
+
101
+ it("GET roots/C/npcs lists only C's NPCs", async () => {
102
+ const result = await answer(store, "GET", "roots/C/npcs?fetchAll=true");
103
+
104
+ expect(result.body.data.map((r: any) => r.id).sort()).toEqual(["n1", "n2"]);
105
+ expect(result.body.links).toEqual({ self: `${API}roots/C/npcs?fetchAll=true` });
106
+ });
107
+
108
+ it("honours search like the server's fulltext index: every token, case-insensitive, in any string attribute", async () => {
109
+ const byName = await answer(store, "GET", "roots/C/npcs?fetchAll=true&search=BRO");
110
+ const acrossFields = await answer(store, "GET", "roots/C/npcs?fetchAll=true&search=brom%20TEXT");
111
+ const noMatch = await answer(store, "GET", "roots/C/npcs?fetchAll=true&search=brom%20guild");
112
+
113
+ expect(byName.body.data.map((r: any) => r.id)).toEqual(["n2"]);
114
+ expect(acrossFields.body.data.map((r: any) => r.id)).toEqual(["n2"]);
115
+ expect(noMatch.body.data).toEqual([]);
116
+ });
117
+
118
+ it("honours orderBy as the server reads it: `field` or `field ASC|DESC`", async () => {
119
+ const ascending = await answer(store, "GET", "roots/C/npcs?fetchAll=true&orderBy=name");
120
+ const descending = await answer(store, "GET", "roots/C/npcs?fetchAll=true&orderBy=name%20DESC");
121
+ const explicitAsc = await answer(store, "GET", "roots/C/npcs?fetchAll=true&orderBy=name%20asc");
122
+
123
+ expect(ascending.body.data.map((r: any) => r.id)).toEqual(["n1", "n2"]);
124
+ expect(descending.body.data.map((r: any) => r.id)).toEqual(["n2", "n1"]);
125
+ expect(explicitAsc.body.data.map((r: any) => r.id)).toEqual(["n1", "n2"]);
126
+ });
127
+
128
+ it("an orderBy the server refuses (`-name`) answers 400 like the server", async () => {
129
+ expect((await answer(store, "GET", "roots/C/npcs?fetchAll=true&orderBy=-name")).status).toBe(400);
130
+ });
131
+
132
+ it("with no orderBy, lists in the server's default order (updatedAt DESC)", async () => {
133
+ const older = { ...npc("n1", "Alda", "C"), attributes: { name: "Alda", updatedAt: "2026-01-01T00:00:00.000Z" } };
134
+ const newer = { ...npc("n2", "brom", "C"), attributes: { name: "brom", updatedAt: "2026-02-01T00:00:00.000Z" } };
135
+ await store.putRecord({
136
+ type: "npcs",
137
+ id: "n1",
138
+ rootId: "C",
139
+ resource: older,
140
+ updatedAt: new Date("2026-01-01"),
141
+ });
142
+ await store.putRecord({
143
+ type: "npcs",
144
+ id: "n2",
145
+ rootId: "C",
146
+ resource: newer,
147
+ updatedAt: new Date("2026-02-01"),
148
+ });
149
+
150
+ const result = await answer(store, "GET", "roots/C/npcs?fetchAll=true");
151
+
152
+ expect(result.body.data.map((r: any) => r.id)).toEqual(["n2", "n1"]);
153
+ });
154
+
155
+ it("listAnswer pages, searches and orders any set of stored records for app answers", async () => {
156
+ const records = await store.listRecords({ type: "npcs", rootId: "C" });
157
+
158
+ const result = await listAnswer(
159
+ {
160
+ request: { method: "GET", path: "x", query: new URLSearchParams("orderBy=name%20DESC&page[size]=1") },
161
+ params: {},
162
+ store,
163
+ },
164
+ records,
165
+ );
166
+
167
+ expect(result.body.data.map((r: any) => r.id)).toEqual(["n2"]);
168
+ expect(result.body.meta.total).toBe(2);
169
+ });
170
+
171
+ it("pages with page[size]/page[offset] and links shaped like the server's", async () => {
172
+ const first = await answer(store, "GET", "roots/C/npcs?orderBy=name&page[size]=1");
173
+
174
+ expect(first.body.data.map((r: any) => r.id)).toEqual(["n1"]);
175
+ expect(first.body.links.self).toBe(`${API}roots/C/npcs?orderBy=name&page[size]=1`);
176
+ expect(first.body.links.next).toBe(`${API}roots/C/npcs?orderBy=name&page[size]=1&page[offset]=1`);
177
+ expect(first.body.links.prev).toBeUndefined();
178
+
179
+ const second = await answer(store, "GET", "roots/C/npcs?orderBy=name&page[size]=1&page[offset]=1");
180
+
181
+ expect(second.body.data.map((r: any) => r.id)).toEqual(["n2"]);
182
+ expect(second.body.links.next).toBeUndefined();
183
+ expect(second.body.links.prev).toBe(`${API}roots/C/npcs?orderBy=name&page[size]=1&page[offset]=0`);
184
+ });
185
+
186
+ it("GET factions/:id/npcs and npcs/:id/factions answer records linked in either direction", async () => {
187
+ const npcs = await answer(store, "GET", "factions/f1/npcs?fetchAll=true");
188
+ const factions = await answer(store, "GET", "npcs/n1/factions?fetchAll=true");
189
+
190
+ expect(npcs.body.data.map((r: any) => r.id)).toEqual(["n1"]);
191
+ expect(factions.body.data.map((r: any) => r.id)).toEqual(["f1"]);
192
+ });
193
+
194
+ it("POST npcs stores a local-only record, queues the body with base null and answers 201", async () => {
195
+ const body = { data: npc("n9", "New", "C") };
196
+
197
+ const result = await answer(store, "POST", "npcs", body);
198
+
199
+ expect(result.status).toBe(201);
200
+ expect(result.body.data).toEqual(npc("n9", "New", "C"));
201
+ expect(result.body.included).toEqual([{ type: "roots", id: "C", attributes: { name: "Camp C" } }]);
202
+ const stored = await store.getRecord("npcs", "n9");
203
+ expect(stored).toMatchObject({ rootId: "C", localOnly: true, base: null });
204
+ const [entry] = await store.queue();
205
+ expect(entry).toMatchObject({ rootId: "C", method: "POST", path: "npcs", body, base: null, status: "pending" });
206
+ });
207
+
208
+ it.each(["PATCH", "PUT"] as const)(
209
+ "%s npcs/:id applies the body, sets base once and queues the body untouched",
210
+ async (method) => {
211
+ const body = { data: { type: "npcs", id: "n1", attributes: { name: "Alda the Bold" } } };
212
+
213
+ const result = await answer(store, method, "npcs/n1", body);
214
+ await answer(store, method, "npcs/n1", { data: { type: "npcs", id: "n1", attributes: { name: "Alda II" } } });
215
+
216
+ expect(result.status).toBe(200);
217
+ expect(result.body.data.attributes).toEqual({ name: "Alda the Bold", description: "Alda text" });
218
+ expect(result.body.included.map((r: any) => `${r.type}:${r.id}`)).toEqual(["factions:f1", "roots:C"]);
219
+ const stored = await store.getRecord("npcs", "n1");
220
+ expect(stored?.resource.attributes.name).toBe("Alda II");
221
+ expect(stored?.base).toEqual(npc("n1", "Alda", "C"));
222
+ const entries = await store.queue();
223
+ expect(entries).toHaveLength(2);
224
+ expect(entries[0]).toMatchObject({
225
+ method,
226
+ path: "npcs/n1",
227
+ body,
228
+ recordType: "npcs",
229
+ recordId: "n1",
230
+ base: npc("n1", "Alda", "C"),
231
+ });
232
+ expect(entries[1].base).toEqual(npc("n1", "Alda", "C"));
233
+ },
234
+ );
235
+
236
+ it("DELETE npcs/:id removes the record and its links, queues it with base and answers 204", async () => {
237
+ const result = await answer(store, "DELETE", "npcs/n1");
238
+
239
+ expect(result).toEqual({ status: 204, body: undefined });
240
+ expect(await store.getRecord("npcs", "n1")).toBeUndefined();
241
+ expect(await store.linksFrom("f1")).toEqual([]);
242
+ const [entry] = await store.queue();
243
+ expect(entry).toMatchObject({
244
+ method: "DELETE",
245
+ path: "npcs/n1",
246
+ recordType: "npcs",
247
+ recordId: "n1",
248
+ base: npc("n1", "Alda", "C"),
249
+ });
250
+ });
251
+
252
+ describe("link kinds", () => {
253
+ const withKinds = (): OfflineConfig => {
254
+ const config = offlineConfig();
255
+ config.types = config.types.map((typeConfig) =>
256
+ typeConfig.module.name === "npcs" ? { ...typeConfig, relationshipKinds: { faction: "MEMBER_OF" } } : typeConfig,
257
+ );
258
+ return config;
259
+ };
260
+ const withFaction = (id: string, name: string, factionIds: string[]) => ({
261
+ ...npc(id, name, "C"),
262
+ relationships: {
263
+ root: { data: { type: "roots", id: "C" } },
264
+ faction: { data: factionIds.map((factionId) => ({ type: "factions", id: factionId })) },
265
+ },
266
+ });
267
+
268
+ it("a link written on the device carries the Neo4j type when the type config maps the relationship name", async () => {
269
+ await answer(store, "PATCH", "npcs/n2", { data: withFaction("n2", "brom", ["f1"]) }, withKinds());
270
+
271
+ expect((await store.linksFrom("n2")).map((link) => link.kind)).toEqual(["MEMBER_OF"]);
272
+ });
273
+
274
+ it("without a mapping the link carries the relationship name", async () => {
275
+ await answer(store, "PATCH", "npcs/n2", { data: withFaction("n2", "brom", ["f1"]) });
276
+
277
+ expect((await store.linksFrom("n2")).map((link) => link.kind)).toEqual(["faction"]);
278
+ });
279
+
280
+ it("removing a relationship target removes only links of that relationship (name or Neo4j type)", async () => {
281
+ await store.putRecord({
282
+ type: "npcs",
283
+ id: "n1",
284
+ rootId: "C",
285
+ resource: withFaction("n1", "Alda", ["f1"]),
286
+ updatedAt: null,
287
+ });
288
+ await store.putLink({
289
+ fromType: "factions",
290
+ fromId: "f1",
291
+ toType: "npcs",
292
+ toId: "n1",
293
+ kind: "ENEMY_OF",
294
+ props: {},
295
+ });
296
+
297
+ await answer(store, "PATCH", "npcs/n1", { data: withFaction("n1", "Alda", []) }, withKinds());
298
+
299
+ expect((await store.linksTo("n1")).map((link) => link.kind)).toEqual(["ENEMY_OF"]);
300
+ });
301
+
302
+ it("a related list follows only the links of the relationship toward the parent, by name or by Neo4j type", async () => {
303
+ await store.putRecord({
304
+ type: "npcs",
305
+ id: "n1",
306
+ rootId: "C",
307
+ resource: withFaction("n1", "Alda", ["f1"]),
308
+ updatedAt: null,
309
+ });
310
+ await store.putLink({
311
+ fromType: "factions",
312
+ fromId: "f1",
313
+ toType: "npcs",
314
+ toId: "n2",
315
+ kind: "ENEMY_OF",
316
+ props: {},
317
+ });
318
+ await store.putLink({
319
+ fromType: "npcs",
320
+ fromId: "n3",
321
+ toType: "factions",
322
+ toId: "f1",
323
+ kind: "faction",
324
+ props: {},
325
+ });
326
+ await store.putRecord({
327
+ type: "npcs",
328
+ id: "n3",
329
+ rootId: "C",
330
+ resource: npc("n3", "Cora", "C"),
331
+ updatedAt: null,
332
+ });
333
+
334
+ const result = await answer(store, "GET", "factions/f1/npcs?fetchAll=true&orderBy=name", undefined, withKinds());
335
+
336
+ expect(result.body.data.map((r: any) => r.id)).toEqual(["n1", "n3"]);
337
+ });
338
+
339
+ it("linksOfKind answers the links of any of the kinds, in the direction asked", async () => {
340
+ await store.putLink({
341
+ fromType: "npcs",
342
+ fromId: "n1",
343
+ toType: "factions",
344
+ toId: "f1",
345
+ kind: "faction",
346
+ props: {},
347
+ });
348
+
349
+ const both = await linksOfKind(store, "n1", ["MEMBER_OF", "faction"], "both");
350
+ const out = await linksOfKind(store, "n1", ["MEMBER_OF", "faction"], "out");
351
+
352
+ expect(both.map((link) => link.kind).sort()).toEqual(["MEMBER_OF", "faction"]);
353
+ expect(out.map((link) => link.kind)).toEqual(["faction"]);
354
+ });
355
+ });
356
+
357
+ it("a write to a readOnly type answers 503 needs-connection", async () => {
358
+ const result = await answer(store, "PATCH", "characters/ch1", {
359
+ data: { type: "characters", id: "ch1", attributes: { name: "X" } },
360
+ });
361
+
362
+ expect(result.status).toBe(503);
363
+ expect(result.body.errors[0].code).toBe("needs-connection");
364
+ expect(await store.queue()).toEqual([]);
365
+ });
366
+
367
+ it("a write to a locked root record answers 503 needs-connection", async () => {
368
+ setOfflineState({ lockedRootIds: ["C"] });
369
+
370
+ const patch = await answer(store, "PATCH", "npcs/n1", {
371
+ data: { type: "npcs", id: "n1", attributes: { name: "X" } },
372
+ });
373
+ const post = await answer(store, "POST", "npcs", { data: npc("n9", "New", "C") });
374
+
375
+ expect(patch.status).toBe(503);
376
+ expect(patch.body.errors[0].code).toBe("needs-connection");
377
+ expect(post.status).toBe(503);
378
+ expect(await store.queue()).toEqual([]);
379
+ });
380
+ });