@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,89 @@
1
+ import { configureOffline, resetOfflineConfig, setDeviceStoreOpener } from "../config";
2
+ import { resetOfflineState } from "../state";
3
+ import type { DeviceStore } from "../store/DeviceStore";
4
+ import { openMemoryDeviceStore } from "../store/lifecycle";
5
+ import type { OfflineConfig, OfflineRoute } from "../types";
6
+
7
+ export class TestModel {
8
+ id = "";
9
+ name = "";
10
+ rehydrate(data: { jsonApi: any }) {
11
+ this.id = data.jsonApi.id;
12
+ this.name = data.jsonApi.attributes?.name ?? "";
13
+ return this;
14
+ }
15
+ }
16
+
17
+ export const Roots = { name: "roots", model: TestModel };
18
+ export const Npcs = { name: "npcs", model: TestModel };
19
+ export const Factions = { name: "factions", model: TestModel };
20
+ export const Characters = { name: "characters", model: TestModel };
21
+
22
+ export const npc = (id: string, name: string, rootId: string) => ({
23
+ type: "npcs",
24
+ id,
25
+ attributes: { name, description: `${name} text` },
26
+ relationships: { root: { data: { type: "roots", id: rootId } } },
27
+ });
28
+
29
+ export function offlineConfig(routes: OfflineRoute[] = []): OfflineConfig {
30
+ return {
31
+ getUserId: () => "u1",
32
+ rootModule: Roots,
33
+ types: [
34
+ { module: Roots, fetch: "by-id", readOnly: false, serverOwnedFields: [] },
35
+ { module: Npcs, fetch: "root-list", readOnly: false, serverOwnedFields: ["tldr"] },
36
+ { module: Factions, fetch: "root-list", readOnly: false, serverOwnedFields: [] },
37
+ { module: Characters, fetch: "root-list", readOnly: true, serverOwnedFields: [] },
38
+ ],
39
+ routes,
40
+ listSyncRoots: async () => ["C", "D"],
41
+ shellPath: () => "/offline-shell",
42
+ syncIntervalMs: 300000,
43
+ };
44
+ }
45
+
46
+ /** Two NPCs and a faction in root record C, one NPC in D; the faction is linked to n1. */
47
+ export async function seededStore(): Promise<DeviceStore> {
48
+ const store = await openMemoryDeviceStore();
49
+ await store.putRecord({
50
+ type: "roots",
51
+ id: "C",
52
+ rootId: "C",
53
+ resource: { type: "roots", id: "C", attributes: { name: "Camp C" } },
54
+ updatedAt: null,
55
+ });
56
+ await store.putRecord({ type: "npcs", id: "n1", rootId: "C", resource: npc("n1", "Alda", "C"), updatedAt: null });
57
+ await store.putRecord({ type: "npcs", id: "n2", rootId: "C", resource: npc("n2", "brom", "C"), updatedAt: null });
58
+ await store.putRecord({ type: "npcs", id: "n3", rootId: "D", resource: npc("n3", "Cora", "D"), updatedAt: null });
59
+ await store.putRecord({
60
+ type: "factions",
61
+ id: "f1",
62
+ rootId: "C",
63
+ resource: { type: "factions", id: "f1", attributes: { name: "Guild" } },
64
+ updatedAt: null,
65
+ });
66
+ await store.putRecord({
67
+ type: "characters",
68
+ id: "ch1",
69
+ rootId: "C",
70
+ resource: { type: "characters", id: "ch1", attributes: { name: "Hero" } },
71
+ updatedAt: null,
72
+ });
73
+ await store.putLink({ fromType: "factions", fromId: "f1", toType: "npcs", toId: "n1", kind: "MEMBER_OF", props: {} });
74
+ return store;
75
+ }
76
+
77
+ export async function setUpOffline(routes: OfflineRoute[] = []): Promise<DeviceStore> {
78
+ resetOfflineState();
79
+ resetOfflineConfig();
80
+ const store = await seededStore();
81
+ configureOffline(offlineConfig(routes));
82
+ setDeviceStoreOpener(async () => store);
83
+ return store;
84
+ }
85
+
86
+ export function tearDownOffline(): void {
87
+ resetOfflineConfig();
88
+ resetOfflineState();
89
+ }
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { matchRoute } from "../router";
3
+ import type { OfflineRoute } from "../types";
4
+ import { patternToString } from "../types";
5
+
6
+ const Npcs = { name: "npcs", model: class {} };
7
+ const Roots = { name: "roots", model: class {} };
8
+ const answer = (label: string) => async () => ({ status: 200, body: { label } });
9
+
10
+ describe("matchRoute", () => {
11
+ it("custom routes win over generic ones", async () => {
12
+ const custom: OfflineRoute = {
13
+ method: "GET",
14
+ pattern: [Roots, ":id", "scenes", "playing"],
15
+ answer: answer("custom"),
16
+ queue: false,
17
+ };
18
+ const generic: OfflineRoute = {
19
+ method: "GET",
20
+ pattern: [":parentType", ":parentId", "scenes", ":x"],
21
+ answer: answer("generic"),
22
+ queue: false,
23
+ };
24
+
25
+ const match = matchRoute([custom, generic], "GET", "roots/C/scenes/playing");
26
+
27
+ expect(match?.route).toBe(custom);
28
+ });
29
+
30
+ it("a Module segment matches its endpoint", () => {
31
+ const route: OfflineRoute = { method: "GET", pattern: [Npcs, ":id"], answer: answer("one"), queue: false };
32
+
33
+ expect(matchRoute([route], "GET", "npcs/n1")?.params).toEqual({ id: "n1" });
34
+ expect(matchRoute([route], "GET", "factions/n1")).toBeNull();
35
+ expect(patternToString([Roots, ":id", Npcs])).toBe("roots/:id/npcs");
36
+ });
37
+
38
+ it(":id captures one segment", () => {
39
+ const route: OfflineRoute = { method: "GET", pattern: [Npcs, ":id"], answer: answer("one"), queue: false };
40
+
41
+ expect(matchRoute([route], "GET", "npcs/n1/factions")).toBeNull();
42
+ expect(matchRoute([route], "GET", "npcs")).toBeNull();
43
+ expect(matchRoute([route], "PATCH", "npcs/n1")).toBeNull();
44
+ });
45
+
46
+ it("the query string is ignored for matching", () => {
47
+ const route: OfflineRoute = {
48
+ method: "GET",
49
+ pattern: [Roots, ":id", Npcs],
50
+ answer: answer("list"),
51
+ queue: false,
52
+ };
53
+
54
+ expect(matchRoute([route], "GET", "roots/C/npcs?fetchAll=true&page[size]=5")?.params).toEqual({ id: "C" });
55
+ });
56
+ });
@@ -0,0 +1,62 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { configureOffline, resetOfflineConfig } from "../config";
3
+ import { getOfflineState, resetOfflineState, setOfflineState, subscribeOfflineState } from "../state";
4
+ import type { OfflineConfig } from "../types";
5
+
6
+ const config: OfflineConfig = {
7
+ getUserId: () => "u1",
8
+ rootModule: { name: "roots", model: class {} },
9
+ types: [],
10
+ routes: [],
11
+ listSyncRoots: async () => [],
12
+ shellPath: () => "/offline-shell",
13
+ syncIntervalMs: 300000,
14
+ };
15
+
16
+ describe("offline state", () => {
17
+ beforeEach(() => {
18
+ resetOfflineState();
19
+ resetOfflineConfig();
20
+ });
21
+
22
+ afterEach(() => {
23
+ resetOfflineState();
24
+ resetOfflineConfig();
25
+ });
26
+
27
+ it("the offline event marks the session offline; the online event alone never brings it back (a probe or a sync must reach the API)", () => {
28
+ configureOffline(config);
29
+ expect(getOfflineState().online).toBe(true);
30
+
31
+ window.dispatchEvent(new Event("offline"));
32
+ expect(getOfflineState().online).toBe(false);
33
+
34
+ window.dispatchEvent(new Event("online"));
35
+ expect(getOfflineState().online).toBe(false);
36
+ });
37
+
38
+ it("does not follow browser events when offline mode is not configured", () => {
39
+ window.dispatchEvent(new Event("offline"));
40
+ expect(getOfflineState().online).toBe(true);
41
+ });
42
+
43
+ it("listeners are called once per change", () => {
44
+ const listener = vi.fn();
45
+ const unsubscribe = subscribeOfflineState(listener);
46
+
47
+ setOfflineState({ pending: 2 });
48
+ setOfflineState({ pending: 2 });
49
+ setOfflineState({ lockedRootIds: ["C"] });
50
+ setOfflineState({ lockedRootIds: ["C"] });
51
+ setOfflineState({ online: false, draining: false });
52
+
53
+ expect(listener).toHaveBeenCalledTimes(3);
54
+ expect(listener).toHaveBeenLastCalledWith(
55
+ expect.objectContaining({ online: false, pending: 2, lockedRootIds: ["C"] }),
56
+ );
57
+
58
+ unsubscribe();
59
+ setOfflineState({ pending: 3 });
60
+ expect(listener).toHaveBeenCalledTimes(3);
61
+ });
62
+ });
@@ -0,0 +1,137 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { DeviceStore } from "../store/DeviceStore";
3
+ import { artwork, fakeCacheStorage, npc, setUpSync, tearDownSync } from "../sync/__tests__/sync-fixtures";
4
+ import { markOfflineUnavailable, OfflineUnavailableError } from "../config";
5
+ import { writeThrough } from "../write-through";
6
+
7
+ const ok = (data: any) => ({ ok: true, status: 200, statusText: "OK", data });
8
+
9
+ describe("writeThrough", () => {
10
+ let store: DeviceStore;
11
+ let cache: ReturnType<typeof fakeCacheStorage>;
12
+
13
+ beforeEach(async () => {
14
+ store = await setUpSync();
15
+ // The device keeps root record C (its root record is stored).
16
+ await store.putRecord({
17
+ type: "roots",
18
+ id: "C",
19
+ rootId: "C",
20
+ resource: { type: "roots", id: "C", attributes: { name: "C" } },
21
+ updatedAt: null,
22
+ });
23
+ cache = fakeCacheStorage(["wyrdli-offline-1", "wyrdli-images"]);
24
+ vi.stubGlobal("caches", cache.storage);
25
+ });
26
+
27
+ afterEach(() => {
28
+ tearDownSync();
29
+ vi.unstubAllGlobals();
30
+ });
31
+
32
+ it("stores the answer of an online write", async () => {
33
+ await writeThrough({ method: "PATCH", url: "http://api.test/npcs/n1" }, ok({ data: npc("n1", "Saved") }));
34
+
35
+ expect((await store.getRecord("npcs", "n1"))?.resource.attributes.name).toBe("Saved");
36
+ });
37
+
38
+ it("in a browser that cannot keep the device copy, stores nothing and logs nothing per request", async () => {
39
+ const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
40
+ markOfflineUnavailable(new OfflineUnavailableError("the browser offers no private file storage"));
41
+ warn.mockClear();
42
+
43
+ await writeThrough({ method: "PATCH", url: "http://api.test/npcs/n1" }, ok({ data: npc("n1", "Saved") }));
44
+
45
+ expect(await store.getRecord("npcs", "n1")).toBeUndefined();
46
+ expect(warn).not.toHaveBeenCalled();
47
+ warn.mockRestore();
48
+ });
49
+
50
+ it("stores nothing for a write in a root record the device does not keep (a player's root record)", async () => {
51
+ await writeThrough(
52
+ { method: "PATCH", url: "http://api.test/npcs/p1" },
53
+ ok({ data: npc("p1", "Player's", "P"), included: [npc("p2", "Other", "P")] }),
54
+ );
55
+ await writeThrough(
56
+ { method: "POST", url: "http://api.test/roots/P/npcs" },
57
+ ok({ data: { type: "npcs", id: "p3", attributes: { name: "No relationship" } } }),
58
+ );
59
+
60
+ expect(await store.getRecord("npcs", "p1")).toBeUndefined();
61
+ expect(await store.getRecord("npcs", "p2")).toBeUndefined();
62
+ expect(await store.getRecord("npcs", "p3")).toBeUndefined();
63
+ expect(await store.linksFrom("p1")).toEqual([]);
64
+ });
65
+
66
+ it("never overwrites a record that has a waiting change (a queue entry targets it) or a base", async () => {
67
+ await store.putRecord({ type: "npcs", id: "n1", rootId: "C", resource: npc("n1", "Mine"), updatedAt: null });
68
+ await store.enqueue({
69
+ rootId: "C",
70
+ method: "POST",
71
+ path: "npcs",
72
+ body: { data: npc("n1", "Mine") },
73
+ base: null,
74
+ });
75
+ await store.putRecord({
76
+ type: "npcs",
77
+ id: "n2",
78
+ rootId: "C",
79
+ resource: npc("n2", "Mine"),
80
+ updatedAt: null,
81
+ base: npc("n2", "Base"),
82
+ });
83
+
84
+ await writeThrough(
85
+ { method: "PATCH", url: "http://api.test/npcs/n1" },
86
+ ok({ data: npc("n1", "Server"), included: [npc("n2", "Server")] }),
87
+ );
88
+
89
+ expect((await store.getRecord("npcs", "n1"))?.resource.attributes.name).toBe("Mine");
90
+ expect((await store.getRecord("npcs", "n2"))?.resource.attributes.name).toBe("Mine");
91
+ });
92
+
93
+ it("updates the links of the written record from the answer's relationships", async () => {
94
+ await store.putRecord({ type: "npcs", id: "n1", rootId: "C", resource: npc("n1", "A"), updatedAt: null });
95
+ await store.putLink({ fromType: "npcs", fromId: "n1", toType: "npcs", toId: "old", kind: "friends", props: {} });
96
+
97
+ await writeThrough(
98
+ { method: "PATCH", url: "http://api.test/npcs/n1" },
99
+ ok({
100
+ data: {
101
+ ...npc("n1", "A"),
102
+ relationships: {
103
+ root: { data: { type: "roots", id: "C" } },
104
+ friends: { data: [{ type: "npcs", id: "new" }] },
105
+ portrait: { data: { type: "artworks", id: "a1" } },
106
+ },
107
+ },
108
+ }),
109
+ );
110
+
111
+ const links = (await store.linksFrom("n1")).map((link) => `${link.kind}:${link.toId}`).sort();
112
+ expect(links).toEqual(["friends:new", "portrait:a1", "root:C"]);
113
+ });
114
+
115
+ it("removes the old cached image when an online write changes an artwork's image URL", async () => {
116
+ const images = cache.caches.get("wyrdli-images")!;
117
+ images.set("http://api.test/artworks/a1/image?v=1", {});
118
+ await store.putRecord({
119
+ type: "artworks",
120
+ id: "a1",
121
+ rootId: "C",
122
+ resource: artwork("a1", "http://api.test/artworks/a1/image?v=1"),
123
+ updatedAt: null,
124
+ });
125
+ vi.stubGlobal(
126
+ "fetch",
127
+ vi.fn(async () => ({ ok: true, status: 200 })),
128
+ );
129
+
130
+ await writeThrough(
131
+ { method: "PATCH", url: "http://api.test/artworks/a1" },
132
+ ok({ data: artwork("a1", "http://api.test/artworks/a1/image?v=2") }),
133
+ );
134
+
135
+ expect(images.has("http://api.test/artworks/a1/image?v=1")).toBe(false);
136
+ });
137
+ });
@@ -0,0 +1,229 @@
1
+ import { attachBrowserOnlineEvents, getOfflineState, setOfflineState } from "./state";
2
+ import type { DeviceStore } from "./store/DeviceStore";
3
+ import {
4
+ DEFAULT_DEVICE_WORKER_URL,
5
+ DEFAULT_SQLITE_MODULE_URL,
6
+ NeedsConnectionError,
7
+ type OfflineConfig,
8
+ } from "./types";
9
+
10
+ // Kept free of store, worker and React imports: `directFetch` reads `isOfflineConfigured()` on
11
+ // every request, so this module is the only offline code an app that never opts in loads.
12
+
13
+ let _config: OfflineConfig | null = null;
14
+ let _opener: ((userId: string) => Promise<DeviceStore>) | null = null;
15
+ // Registered by the `./offline` entry (configure.ts), so the store code is reachable only from it.
16
+ let _defaultOpener: ((userId: string) => Promise<DeviceStore>) | null = null;
17
+ const _stores = new Map<string, Promise<DeviceStore>>();
18
+ // Users whose device store must not be opened again (logged out) until they sign in again.
19
+ const _blockedUsers = new Set<string>();
20
+ // Per user: whether offline mode is on on this device (`requireOptIn`). Read from localStorage
21
+ // once, since `isOfflineConfigured()` runs on every request.
22
+ const _enabledCache = new Map<string, boolean>();
23
+
24
+ const ENABLED_KEY_PREFIX = "nja-offline-enabled:";
25
+ const LAST_SYNC_KEY_PREFIX = "nja-offline-last-sync:";
26
+
27
+ function readStorage(key: string): string | null {
28
+ try {
29
+ return typeof localStorage === "undefined" ? null : localStorage.getItem(key);
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ function writeStorage(key: string, value: string | null): void {
36
+ try {
37
+ if (typeof localStorage === "undefined") return;
38
+ if (value === null) localStorage.removeItem(key);
39
+ else localStorage.setItem(key, value);
40
+ } catch {
41
+ // Storage can be blocked or full: the switch then lasts for this page only.
42
+ }
43
+ }
44
+
45
+ /**
46
+ * The signed-in user's device store cannot be used: it could not be opened (opfs-sahpool holds
47
+ * one connection per origin, so a second tab cannot open it) or the user logged out. A request
48
+ * answered from the device copy then needs a connection (503 "needs-connection").
49
+ */
50
+ export class DeviceStoreUnavailableError extends NeedsConnectionError {
51
+ constructor(message: string = "The device copy is not available in this tab") {
52
+ super(message);
53
+ this.name = "DeviceStoreUnavailableError";
54
+ }
55
+ }
56
+
57
+ /**
58
+ * This browser cannot keep the device copy at all: not a secure context (https or `localhost`),
59
+ * no private file storage (`navigator.storage.getDirectory`), or the worker reports the OPFS
60
+ * APIs missing. Offline mode switches off for the page (`markOfflineUnavailable`). A failure on
61
+ * a browser that can keep the copy (the file held by another tab) is a `DeviceStoreUnavailableError`.
62
+ */
63
+ export class OfflineUnavailableError extends DeviceStoreUnavailableError {
64
+ constructor(reason: string) {
65
+ super(`Offline mode isn't available in this browser: ${reason}`);
66
+ this.name = "OfflineUnavailableError";
67
+ }
68
+ }
69
+
70
+ /**
71
+ * Switches offline mode off for this page, once: one console warning, `available: false` in the
72
+ * offline state. From then on `isOfflineConfigured()` answers false, so no request, sync, drain,
73
+ * write-through or lock check touches the device copy again.
74
+ */
75
+ export function markOfflineUnavailable(error: OfflineUnavailableError): void {
76
+ if (!getOfflineState().available) return;
77
+ console.warn(`[offline] ${error.message}. Offline mode is off in this browser.`);
78
+ setOfflineState({ available: false });
79
+ }
80
+
81
+ /** Whether this browser can keep the device copy (true until found otherwise). */
82
+ export function isOfflineAvailable(): boolean {
83
+ return getOfflineState().available;
84
+ }
85
+
86
+ /** Logout: the user's store is never opened again in this page until `unblockOfflineStore`. */
87
+ export function blockOfflineStore(userId: string): void {
88
+ _blockedUsers.add(userId);
89
+ _stores.delete(userId);
90
+ }
91
+
92
+ /** A sign-in (`onUserSignedIn`): the user's store may be opened again. */
93
+ export function unblockOfflineStore(userId: string): void {
94
+ _blockedUsers.delete(userId);
95
+ }
96
+
97
+ export function isOfflineStoreBlocked(userId: string): boolean {
98
+ return _blockedUsers.has(userId);
99
+ }
100
+
101
+ /** Opts the app into offline mode. Without this call nothing offline runs. */
102
+ export function configureOffline(config: OfflineConfig): void {
103
+ _config = config;
104
+ attachBrowserOnlineEvents();
105
+ refreshOfflineDeviceState();
106
+ }
107
+
108
+ /**
109
+ * Configured, the browser can keep the device copy (see `markOfflineUnavailable`), and offline
110
+ * mode is on on this device for the signed-in user (see `requireOptIn`).
111
+ */
112
+ export function isOfflineConfigured(): boolean {
113
+ return _config !== null && isOfflineAvailable() && isOfflineEnabledOnDevice(_config.getUserId());
114
+ }
115
+
116
+ /**
117
+ * Whether offline mode is on on this device for `userId`. Always true without
118
+ * `OfflineConfig.requireOptIn`; otherwise the stored switch, false with no user.
119
+ */
120
+ export function isOfflineEnabledOnDevice(userId?: string | null): boolean {
121
+ if (!_config?.requireOptIn) return true;
122
+ if (!userId) return false;
123
+ let enabled = _enabledCache.get(userId);
124
+ if (enabled === undefined) {
125
+ enabled = readStorage(ENABLED_KEY_PREFIX + userId) === "1";
126
+ _enabledCache.set(userId, enabled);
127
+ }
128
+ return enabled;
129
+ }
130
+
131
+ /** Stores the device switch for `userId` (`turnOnOffline` / `turnOffOffline`). */
132
+ export function setOfflineEnabledOnDevice(userId: string, enabled: boolean): void {
133
+ writeStorage(ENABLED_KEY_PREFIX + userId, enabled ? "1" : null);
134
+ _enabledCache.set(userId, enabled);
135
+ }
136
+
137
+ /** When `userId`'s last full sync on this device finished (epoch ms), or null. */
138
+ export function readOfflineLastSync(userId: string): number | null {
139
+ const value = Number(readStorage(LAST_SYNC_KEY_PREFIX + userId));
140
+ return Number.isFinite(value) && value > 0 ? value : null;
141
+ }
142
+
143
+ /** Stores, or with null removes, when `userId`'s last full sync finished. */
144
+ export function writeOfflineLastSync(userId: string, at: number | null): void {
145
+ writeStorage(LAST_SYNC_KEY_PREFIX + userId, at === null ? null : String(at));
146
+ }
147
+
148
+ /**
149
+ * Reads the device switch and the last sync time of the signed-in user into the offline state
150
+ * (`enabled`, `lastSyncedAt`). Does nothing before `configureOffline`.
151
+ */
152
+ export function refreshOfflineDeviceState(): void {
153
+ if (!_config) return;
154
+ const userId = _config.getUserId();
155
+ if (userId) _enabledCache.delete(userId);
156
+ setOfflineState({
157
+ enabled: isOfflineEnabledOnDevice(userId),
158
+ lastSyncedAt: userId ? readOfflineLastSync(userId) : null,
159
+ });
160
+ }
161
+
162
+ export function getOfflineConfig(): OfflineConfig {
163
+ if (!_config) throw new Error("Offline mode is not configured. Call configureOffline() first.");
164
+ return _config;
165
+ }
166
+
167
+ /** The browser opener, set by the `./offline` entry's `configureOffline`. */
168
+ export function setDefaultDeviceStoreOpener(opener: (userId: string) => Promise<DeviceStore>): void {
169
+ _defaultOpener = opener;
170
+ }
171
+
172
+ /** The URL the device worker loads SQLite from (`OfflineConfig.sqliteModuleUrl`). */
173
+ export function getSqliteModuleUrl(): string {
174
+ return _config?.sqliteModuleUrl ?? DEFAULT_SQLITE_MODULE_URL;
175
+ }
176
+
177
+ /** The URL the device worker is started from (`OfflineConfig.deviceWorkerUrl`). */
178
+ export function getDeviceWorkerUrl(): string {
179
+ return _config?.deviceWorkerUrl ?? DEFAULT_DEVICE_WORKER_URL;
180
+ }
181
+
182
+ /** Replaces how a user's device store is opened (tests pass `openMemoryDeviceStore`). */
183
+ export function setDeviceStoreOpener(opener: ((userId: string) => Promise<DeviceStore>) | null): void {
184
+ _opener = opener;
185
+ _stores.clear();
186
+ }
187
+
188
+ /**
189
+ * The signed-in user's device store, opened once per user id. Only the signed-in user's file is
190
+ * ever opened; with no signed-in user the request needs a connection.
191
+ */
192
+ export async function getOfflineStore(): Promise<DeviceStore> {
193
+ const userId = getOfflineConfig().getUserId();
194
+ if (!userId) throw new NeedsConnectionError("No signed-in user");
195
+ if (!isOfflineEnabledOnDevice(userId)) throw new DeviceStoreUnavailableError("Offline mode is off on this device");
196
+ if (_blockedUsers.has(userId)) throw new DeviceStoreUnavailableError("The device copy was deleted at logout");
197
+ if (!isOfflineAvailable()) throw new OfflineUnavailableError("it was found unable to keep the device copy");
198
+
199
+ let store = _stores.get(userId);
200
+ if (!store) {
201
+ const opener = _opener ?? _defaultOpener;
202
+ if (!opener) throw new Error("No device store opener: configure offline mode through the ./offline entry.");
203
+ store = opener(userId).catch((error) => {
204
+ if (error instanceof OfflineUnavailableError) markOfflineUnavailable(error);
205
+ if (error instanceof NeedsConnectionError) throw error;
206
+ console.warn("[offline] the device copy could not be opened", error);
207
+ throw new DeviceStoreUnavailableError(
208
+ `The device copy could not be opened: ${String((error as Error)?.message ?? error)}`,
209
+ );
210
+ });
211
+ _stores.set(userId, store);
212
+ store.catch(() => _stores.delete(userId));
213
+ }
214
+ return store;
215
+ }
216
+
217
+ /** Forgets the opened store of a user (logout, deleted file). */
218
+ export function forgetOfflineStore(userId: string): void {
219
+ _stores.delete(userId);
220
+ }
221
+
222
+ /** Test helper: back to "not configured". */
223
+ export function resetOfflineConfig(): void {
224
+ _config = null;
225
+ _opener = null;
226
+ _stores.clear();
227
+ _blockedUsers.clear();
228
+ _enabledCache.clear();
229
+ }
@@ -0,0 +1,14 @@
1
+ import { configureOffline as configureOfflineLayer, setDefaultDeviceStoreOpener } from "./config";
2
+ import type { OfflineConfig } from "./types";
3
+
4
+ /**
5
+ * Opts the app into offline mode (the `./offline` entry's `configureOffline`). It also registers
6
+ * the browser device-store opener, so the store, the worker and SQLite are reachable only from
7
+ * this entry, never from the request path every other entry shares.
8
+ */
9
+ export function configureOffline(config: OfflineConfig): void {
10
+ setDefaultDeviceStoreOpener((userId) =>
11
+ import("./store/lifecycle").then((lifecycle) => lifecycle.openDeviceStore(userId)),
12
+ );
13
+ configureOfflineLayer(config);
14
+ }
@@ -0,0 +1,75 @@
1
+ import {
2
+ forgetOfflineStore,
3
+ getOfflineConfig,
4
+ getOfflineStore,
5
+ isOfflineAvailable,
6
+ setOfflineEnabledOnDevice,
7
+ writeOfflineLastSync,
8
+ } from "./config";
9
+ import { setOfflineState } from "./state";
10
+ import { deleteImagesCaches } from "./sync/artwork-cache";
11
+ import { stopOfflineRuntime } from "./sync/runtime";
12
+
13
+ function signedInUserId(): string {
14
+ const userId = getOfflineConfig().getUserId();
15
+ if (!userId) throw new Error("No signed-in user");
16
+ return userId;
17
+ }
18
+
19
+ /**
20
+ * Turns offline mode on on this device for the signed-in user (`OfflineConfig.requireOptIn`).
21
+ * It does not start the runtime: the app starts it once `enabled` is true in the offline state.
22
+ */
23
+ export async function turnOnOffline(): Promise<void> {
24
+ const userId = signedInUserId();
25
+ setOfflineEnabledOnDevice(userId, true);
26
+ setOfflineState({ enabled: true });
27
+ try {
28
+ // Asks the browser not to evict the device copy. Best effort only.
29
+ await navigator.storage?.persist?.();
30
+ } catch {
31
+ // Ignored: the copy works without it.
32
+ }
33
+ }
34
+
35
+ /**
36
+ * Turns offline mode off on this device for the signed-in user and deletes this device's copy
37
+ * (database and cached images). Refuses, changing nothing, while the copy has changes still to
38
+ * send: the answer then carries how many.
39
+ */
40
+ export async function turnOffOffline(): Promise<{ turnedOff: true } | { turnedOff: false; pending: number }> {
41
+ const userId = signedInUserId();
42
+
43
+ if (isOfflineAvailable()) {
44
+ let pending = 0;
45
+ try {
46
+ const store = await getOfflineStore();
47
+ pending = (await store.queue()).length;
48
+ } catch {
49
+ // The copy cannot be opened here (held by another tab): the marker file written while
50
+ // changes wait still says whether deleting it would lose any. The count is then unknown: 1.
51
+ const { hasUnsentChanges } = await import("./store/lifecycle");
52
+ if (await hasUnsentChanges(userId).catch(() => false)) pending = 1;
53
+ }
54
+ if (pending > 0) return { turnedOff: false, pending };
55
+ }
56
+
57
+ // Off first, so a sync still running cannot open, and so recreate, the deleted file.
58
+ setOfflineEnabledOnDevice(userId, false);
59
+
60
+ if (isOfflineAvailable()) {
61
+ stopOfflineRuntime();
62
+ try {
63
+ const { deleteDeviceStore } = await import("./store/lifecycle");
64
+ await deleteDeviceStore(userId);
65
+ await deleteImagesCaches();
66
+ } catch (error) {
67
+ console.warn("[offline] the device copy could not be deleted", error);
68
+ }
69
+ forgetOfflineStore(userId);
70
+ }
71
+
72
+ writeOfflineLastSync(userId, null);
73
+ setOfflineState({ enabled: false, syncProgress: null, lastSyncedAt: null, rootCount: null });
74
+ return { turnedOff: true };
75
+ }