@carlonicora/nextjs-jsonapi 6.12.1 → 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 (255) 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-N2NBYCPC.mjs → BlockNoteEditor-22Q4DJBU.mjs} +20 -8
  4. package/dist/BlockNoteEditor-22Q4DJBU.mjs.map +1 -0
  5. package/dist/{BlockNoteEditor-CBYKEXAQ.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-6NZKWIOR.js → chunk-2H342HNN.js} +956 -929
  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-6XJOQFCP.mjs → chunk-H4NLDX5G.mjs} +59 -32
  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/containers/RoundPageContainer.tsx +8 -8
  165. package/src/components/editors/BlockNoteEditor.tsx +14 -0
  166. package/src/components/editors/__tests__/BlockNoteEditor.empty-change.spec.tsx +205 -0
  167. package/src/contexts/SocketContext.tsx +7 -1
  168. package/src/contexts/__tests__/SocketContext.session.spec.tsx +52 -5
  169. package/src/core/factories/RehydrationFactory.ts +28 -8
  170. package/src/core/factories/__tests__/RehydrationFactory.spec.ts +33 -0
  171. package/src/core/index.ts +3 -0
  172. package/src/core/interfaces/ApiData.ts +2 -0
  173. package/src/core/registry/ModuleRegistry.ts +3 -0
  174. package/src/features/auth/components/forms/Logout.tsx +8 -1
  175. package/src/features/auth/components/forms/__tests__/Logout.spec.tsx +62 -0
  176. package/src/hooks/__tests__/useSocket.pause.spec.ts +39 -0
  177. package/src/hooks/useSocket.ts +20 -1
  178. package/src/offline/__tests__/answer-builders.spec.ts +176 -0
  179. package/src/offline/__tests__/device-switch.spec.ts +179 -0
  180. package/src/offline/__tests__/generic-answers.spec.ts +380 -0
  181. package/src/offline/__tests__/local-transport.spec.ts +150 -0
  182. package/src/offline/__tests__/logout.spec.ts +122 -0
  183. package/src/offline/__tests__/offline-fixtures.ts +89 -0
  184. package/src/offline/__tests__/router.spec.ts +56 -0
  185. package/src/offline/__tests__/state.spec.ts +62 -0
  186. package/src/offline/__tests__/write-through.spec.ts +137 -0
  187. package/src/offline/config.ts +229 -0
  188. package/src/offline/configure.ts +14 -0
  189. package/src/offline/device-switch.ts +75 -0
  190. package/src/offline/generic-answers.ts +706 -0
  191. package/src/offline/index.ts +67 -0
  192. package/src/offline/local-transport.ts +189 -0
  193. package/src/offline/logout.ts +41 -0
  194. package/src/offline/router.ts +41 -0
  195. package/src/offline/server/__tests__/build-offline-manifest.spec.ts +76 -0
  196. package/src/offline/server/build-offline-manifest.ts +49 -0
  197. package/src/offline/state-hooks.ts +10 -0
  198. package/src/offline/state.ts +143 -0
  199. package/src/offline/store/DeviceStore.ts +112 -0
  200. package/src/offline/store/__tests__/device-store-lifecycle.spec.ts +343 -0
  201. package/src/offline/store/__tests__/shared-database.spec.ts +151 -0
  202. package/src/offline/store/__tests__/sql-device-store.spec.ts +399 -0
  203. package/src/offline/store/lifecycle.ts +315 -0
  204. package/src/offline/store/schema.ts +101 -0
  205. package/src/offline/store/shared-database.ts +321 -0
  206. package/src/offline/store/sql-device-store.ts +469 -0
  207. package/src/offline/store/worker-client.ts +71 -0
  208. package/src/offline/store/worker.ts +87 -0
  209. package/src/offline/sync/__tests__/conflicts.spec.ts +185 -0
  210. package/src/offline/sync/__tests__/queue-drain.spec.ts +364 -0
  211. package/src/offline/sync/__tests__/runtime.spec.ts +337 -0
  212. package/src/offline/sync/__tests__/sync-fixtures.ts +103 -0
  213. package/src/offline/sync/__tests__/sync-root.spec.ts +727 -0
  214. package/src/offline/sync/artwork-cache.ts +67 -0
  215. package/src/offline/sync/conflicts.ts +211 -0
  216. package/src/offline/sync/counts.ts +39 -0
  217. package/src/offline/sync/drain-queue.ts +246 -0
  218. package/src/offline/sync/known-roots.ts +37 -0
  219. package/src/offline/sync/models/OfflineChangeSet.ts +34 -0
  220. package/src/offline/sync/models/OfflineChangeSetInterface.ts +54 -0
  221. package/src/offline/sync/models/RootSync.ts +68 -0
  222. package/src/offline/sync/models/RootSyncInterface.ts +20 -0
  223. package/src/offline/sync/models/__tests__/OfflineChangeSet.spec.ts +47 -0
  224. package/src/offline/sync/models/__tests__/RootSync.spec.ts +54 -0
  225. package/src/offline/sync/models/index.ts +5 -0
  226. package/src/offline/sync/models/offline.modules.ts +14 -0
  227. package/src/offline/sync/runtime.ts +226 -0
  228. package/src/offline/sync/session-refresh.ts +32 -0
  229. package/src/offline/sync/sync-root.ts +336 -0
  230. package/src/offline/sync/waiting.ts +40 -0
  231. package/src/offline/types.ts +115 -0
  232. package/src/offline/write-through.ts +189 -0
  233. package/src/pwa/__tests__/sw.spec.ts +358 -1
  234. package/src/server/index.ts +5 -0
  235. package/src/unified/__tests__/JsonApiRequest.offline.spec.ts +243 -0
  236. package/dist/BlockNoteEditor-CBYKEXAQ.js.map +0 -1
  237. package/dist/BlockNoteEditor-N2NBYCPC.mjs.map +0 -1
  238. package/dist/BlockNoteViewer-WGCDB7IJ.js.map +0 -1
  239. package/dist/JsonApiRequest-FZGPLNUF.js +0 -26
  240. package/dist/chunk-6NZKWIOR.js.map +0 -1
  241. package/dist/chunk-6XJOQFCP.mjs.map +0 -1
  242. package/dist/chunk-AA52ORW2.mjs.map +0 -1
  243. package/dist/chunk-EWCY6QJA.mjs.map +0 -1
  244. package/dist/chunk-FF4EIQ6H.js.map +0 -1
  245. package/dist/chunk-JTPRJNT2.js.map +0 -1
  246. package/dist/chunk-L6PYFQTO.js.map +0 -1
  247. package/dist/chunk-O3PDL2CV.js.map +0 -1
  248. package/dist/chunk-PI56A4DG.mjs.map +0 -1
  249. package/dist/request-5RLK2AM3.mjs +0 -9
  250. package/dist/request-EUGTI3BE.js +0 -9
  251. package/dist/request-EUGTI3BE.js.map +0 -1
  252. /package/dist/{JsonApiRequest-A66XK5WT.mjs.map → JsonApiRequest-F6TMJ42G.mjs.map} +0 -0
  253. /package/dist/{chunk-3YW35YHQ.mjs.map → chunk-BH6JWIKI.mjs.map} +0 -0
  254. /package/dist/{chunk-V5MI7RNB.mjs.map → chunk-DGWOVYEC.mjs.map} +0 -0
  255. /package/dist/{request-5RLK2AM3.mjs.map → lifecycle-LKGPRBS7.mjs.map} +0 -0
@@ -0,0 +1,176 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import * as offline from "../index";
3
+ import {
4
+ errorAnswer,
5
+ mergeResource,
6
+ orderedListAnswer,
7
+ resourceFromDocument,
8
+ resourceListAnswer,
9
+ singleAnswer,
10
+ } from "../generic-answers";
11
+ import type { DeviceStore } from "../store/DeviceStore";
12
+ import { configureOffline, resetOfflineConfig } from "../config";
13
+ import { offlineConfig, seededStore } from "./offline-fixtures";
14
+
15
+ describe("answer builders", () => {
16
+ let store: DeviceStore;
17
+
18
+ beforeEach(async () => {
19
+ store = await seededStore();
20
+ });
21
+
22
+ it("the ./offline entry exports the builders an app's custom answers use", () => {
23
+ for (const builder of [
24
+ singleAnswer,
25
+ errorAnswer,
26
+ orderedListAnswer,
27
+ resourceListAnswer,
28
+ mergeResource,
29
+ resourceFromDocument,
30
+ ]) {
31
+ expect(typeof builder).toBe("function");
32
+ }
33
+ expect(offline.singleAnswer).toBe(singleAnswer);
34
+ expect(offline.errorAnswer).toBe(errorAnswer);
35
+ expect(offline.orderedListAnswer).toBe(orderedListAnswer);
36
+ expect(offline.resourceListAnswer).toBe(resourceListAnswer);
37
+ expect(offline.mergeResource).toBe(mergeResource);
38
+ expect(offline.resourceFromDocument).toBe(resourceFromDocument);
39
+ });
40
+
41
+ it("errorAnswer answers a JSON:API errors document with that status", () => {
42
+ expect(errorAnswer(409, "Conflict", "Already taken")).toEqual({
43
+ status: 409,
44
+ body: { errors: [{ status: "409", title: "Conflict", detail: "Already taken" }] },
45
+ });
46
+ });
47
+
48
+ it("singleAnswer answers the stored resource with its included records", async () => {
49
+ const record = (await store.getRecord("npcs", "n1"))!;
50
+ const result = await singleAnswer(store, record, 201);
51
+ expect(result.status).toBe(201);
52
+ expect(result.body.data).toEqual(record.resource);
53
+ expect(Array.isArray(result.body.included)).toBe(true);
54
+ });
55
+
56
+ it("included records keep their relationships whole: a root record keeps its owner even when the owner is also linked to the record", async () => {
57
+ const put = (type: string, id: string, relationships: Record<string, any>) =>
58
+ store.putRecord({
59
+ type,
60
+ id,
61
+ rootId: "C",
62
+ resource: { type, id, attributes: {}, relationships },
63
+ updatedAt: null,
64
+ });
65
+ // The npc points at its root record and at the user; the root record points back at the npc and at the user.
66
+ await put("npcs", "cy-npc", {
67
+ root: { data: { type: "roots", id: "cy-camp" } },
68
+ author: { data: { type: "users", id: "cy-user" } },
69
+ });
70
+ await put("roots", "cy-camp", {
71
+ owner: { data: { type: "users", id: "cy-user" } },
72
+ npcs: { data: [{ type: "npcs", id: "cy-npc" }] },
73
+ });
74
+ await put("users", "cy-user", { company: { data: { type: "companies", id: "cy-co" } } });
75
+ await put("companies", "cy-co", { owner: { data: { type: "users", id: "cy-user" } } });
76
+
77
+ const record = (await store.getRecord("npcs", "cy-npc"))!;
78
+ const included = (await singleAnswer(store, record)).body.included as any[];
79
+ const byKey = new Map(included.map((resource) => [`${resource.type}:${resource.id}`, resource]));
80
+
81
+ expect(byKey.get("roots:cy-camp").relationships.owner.data).toEqual({ type: "users", id: "cy-user" });
82
+ expect(byKey.get("roots:cy-camp").relationships.npcs.data).toEqual([{ type: "npcs", id: "cy-npc" }]);
83
+ expect(byKey.get("companies:cy-co").relationships.owner.data).toEqual({ type: "users", id: "cy-user" });
84
+ });
85
+
86
+ it("a relationship to the signed-in user (a root record's owner) resolves from the stored sign-in, though users are not on the device", async () => {
87
+ configureOffline(offlineConfig());
88
+ localStorage.setItem(
89
+ "user",
90
+ JSON.stringify({
91
+ jsonApi: {
92
+ type: "users",
93
+ id: "u1",
94
+ attributes: { name: "Carlo" },
95
+ relationships: { company: { data: { type: "companies", id: "co" } } },
96
+ },
97
+ }),
98
+ );
99
+ try {
100
+ await store.putRecord({
101
+ type: "roots",
102
+ id: "own-camp",
103
+ rootId: "own-camp",
104
+ resource: {
105
+ type: "roots",
106
+ id: "own-camp",
107
+ attributes: {},
108
+ relationships: { owner: { data: { type: "users", id: "u1" } } },
109
+ },
110
+ updatedAt: null,
111
+ });
112
+ await store.putRecord({
113
+ type: "npcs",
114
+ id: "own-npc",
115
+ rootId: "own-camp",
116
+ resource: {
117
+ type: "npcs",
118
+ id: "own-npc",
119
+ attributes: {},
120
+ relationships: { root: { data: { type: "roots", id: "own-camp" } } },
121
+ },
122
+ updatedAt: null,
123
+ });
124
+
125
+ const included = (await singleAnswer(store, (await store.getRecord("npcs", "own-npc"))!)).body.included as any[];
126
+ const owner = included.find((resource) => resource.type === "users");
127
+ // Only the signed-in user, with no relationships of its own.
128
+ expect(owner).toEqual({ type: "users", id: "u1", attributes: { name: "Carlo" }, meta: undefined });
129
+ } finally {
130
+ localStorage.removeItem("user");
131
+ resetOfflineConfig();
132
+ }
133
+ });
134
+
135
+ it("orderedListAnswer answers the records in the given order, in one page", async () => {
136
+ const records = await store.listRecords({ type: "npcs" });
137
+ const reversed = [...records].reverse();
138
+ const result = await orderedListAnswer(store, reversed);
139
+ expect(result.status).toBe(200);
140
+ expect(result.body.data.map((resource: { id: string }) => resource.id)).toEqual(
141
+ reversed.map((record) => record.id),
142
+ );
143
+ expect(result.body.meta).toEqual({ total: reversed.length });
144
+ });
145
+
146
+ it("resourceListAnswer answers resources the device computed, as they are, with nothing included", () => {
147
+ const resources = [{ type: "searches", id: "a", attributes: { name: "A" } }];
148
+ expect(resourceListAnswer(resources)).toEqual({ status: 200, body: { data: resources, included: [] } });
149
+ });
150
+
151
+ it("mergeResource writes a document's attributes, relationships and meta over the resource", () => {
152
+ const before = {
153
+ type: "npcs",
154
+ id: "n1",
155
+ attributes: { name: "Old", age: 3 },
156
+ relationships: { faction: { data: { type: "factions", id: "f1" } } },
157
+ meta: { a: 1 },
158
+ };
159
+ expect(mergeResource(before, { type: "npcs", id: "n1" }, { attributes: { name: "New" }, meta: { b: 2 } })).toEqual({
160
+ type: "npcs",
161
+ id: "n1",
162
+ attributes: { name: "New", age: 3 },
163
+ relationships: { faction: { data: { type: "factions", id: "f1" } } },
164
+ meta: { a: 1, b: 2 },
165
+ });
166
+ });
167
+
168
+ it("resourceFromDocument keeps the document's attributes, relationships and meta under the given identity", () => {
169
+ expect(
170
+ resourceFromDocument(
171
+ { data: { type: "npcs", id: "x", attributes: { name: "A" }, meta: { m: 1 } } },
172
+ { type: "npcs", id: "n9" },
173
+ ),
174
+ ).toEqual({ type: "npcs", id: "n9", attributes: { name: "A" }, meta: { m: 1 } });
175
+ });
176
+ });
@@ -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
+ });