@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
@@ -1,9 +1,9 @@
1
1
  import { act, render, screen } from "@testing-library/react";
2
- import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
3
  import type { SessionSnapshot } from "../../server/ServerSession";
4
4
 
5
5
  const { useSocketMock } = vi.hoisted(() => ({
6
- useSocketMock: vi.fn((_options: { token?: string }) => ({
6
+ useSocketMock: vi.fn((_options: { token?: string; paused?: boolean }) => ({
7
7
  socket: null,
8
8
  isConnected: false,
9
9
  messages: [],
@@ -18,6 +18,9 @@ const { useSocketMock } = vi.hoisted(() => ({
18
18
 
19
19
  vi.mock("../../hooks/useSocket", () => ({ useSocket: useSocketMock }));
20
20
 
21
+ import { configureOffline, resetOfflineConfig } from "../../offline/config";
22
+ import { resetOfflineState, setOfflineState } from "../../offline/state";
23
+ import type { OfflineConfig } from "../../offline/types";
21
24
  import { SocketProvider } from "../SocketContext";
22
25
 
23
26
  function deferred<T>() {
@@ -49,7 +52,7 @@ describe("SocketProvider with a session", () => {
49
52
  </SocketProvider>,
50
53
  );
51
54
  expect(screen.getByText("child")).toBeInTheDocument();
52
- expect(useSocketMock).toHaveBeenLastCalledWith({ token: undefined });
55
+ expect(useSocketMock).toHaveBeenLastCalledWith({ token: undefined, paused: false });
53
56
  });
54
57
 
55
58
  it("connects with the token once the session resolves", async () => {
@@ -63,7 +66,7 @@ describe("SocketProvider with a session", () => {
63
66
  pending.resolve(snapshot("t1"));
64
67
  await pending.promise;
65
68
  });
66
- expect(useSocketMock).toHaveBeenLastCalledWith({ token: "t1" });
69
+ expect(useSocketMock).toHaveBeenLastCalledWith({ token: "t1", paused: false });
67
70
  });
68
71
 
69
72
  it("still accepts a plain token", () => {
@@ -72,6 +75,50 @@ describe("SocketProvider with a session", () => {
72
75
  <span>child</span>
73
76
  </SocketProvider>,
74
77
  );
75
- expect(useSocketMock.mock.calls[0][0]).toEqual({ token: "t2" });
78
+ expect(useSocketMock.mock.calls[0][0]).toEqual({ token: "t2", paused: false });
79
+ });
80
+ });
81
+
82
+ describe("SocketProvider and offline mode", () => {
83
+ const offlineConfig = {
84
+ getUserId: () => "u1",
85
+ rootModule: { name: "roots" },
86
+ types: [],
87
+ routes: [],
88
+ listSyncRoots: async () => [],
89
+ shellPath: () => "/offline-shell",
90
+ syncIntervalMs: 300000,
91
+ } as unknown as OfflineConfig;
92
+
93
+ afterEach(() => {
94
+ resetOfflineConfig();
95
+ resetOfflineState();
96
+ });
97
+
98
+ it("with offline mode configured, pauses the socket while the session is offline and resumes it when back online", () => {
99
+ configureOffline(offlineConfig);
100
+ setOfflineState({ online: true });
101
+ render(
102
+ <SocketProvider token="t3">
103
+ <span>child</span>
104
+ </SocketProvider>,
105
+ );
106
+ expect(useSocketMock).toHaveBeenLastCalledWith({ token: "t3", paused: false });
107
+
108
+ act(() => setOfflineState({ online: false }));
109
+ expect(useSocketMock).toHaveBeenLastCalledWith({ token: "t3", paused: true });
110
+
111
+ act(() => setOfflineState({ online: true }));
112
+ expect(useSocketMock).toHaveBeenLastCalledWith({ token: "t3", paused: false });
113
+ });
114
+
115
+ it("without offline mode configured, never pauses the socket", () => {
116
+ setOfflineState({ online: false });
117
+ render(
118
+ <SocketProvider token="t4">
119
+ <span>child</span>
120
+ </SocketProvider>,
121
+ );
122
+ expect(useSocketMock).toHaveBeenLastCalledWith({ token: "t4", paused: false });
76
123
  });
77
124
  });
@@ -3,6 +3,30 @@ import { ApiRequestDataTypeInterface } from "../interfaces/ApiRequestDataTypeInt
3
3
  import { JsonApiHydratedDataInterface } from "../interfaces/JsonApiHydratedDataInterface";
4
4
  import { DataClassRegistry } from "../registry/DataClassRegistry";
5
5
 
6
+ /**
7
+ * The resources being rehydrated right now, along the current chain of relationships. A resource
8
+ * met again inside its own chain (A's relationship leads to B, B's back to A) is a circle in the
9
+ * `included` data: following it would recurse until the stack overflows, so it is skipped there.
10
+ */
11
+ const inProgress = new Set<string>();
12
+
13
+ const keyOf = (data: JsonApiHydratedDataInterface): string | undefined => {
14
+ const resource = data?.jsonApi;
15
+ return resource?.type && resource?.id ? `${resource.type}:${resource.id}` : undefined;
16
+ };
17
+
18
+ function guarded<T>(data: JsonApiHydratedDataInterface, run: () => T): T {
19
+ const key = keyOf(data);
20
+ if (!key) return run();
21
+ if (inProgress.has(key)) return undefined as T;
22
+ inProgress.add(key);
23
+ try {
24
+ return run();
25
+ } finally {
26
+ inProgress.delete(key);
27
+ }
28
+ }
29
+
6
30
  export class RehydrationFactory {
7
31
  public static rehydrate<T extends ApiDataInterface>(
8
32
  classKey: ApiRequestDataTypeInterface,
@@ -10,8 +34,7 @@ export class RehydrationFactory {
10
34
  ): T {
11
35
  const factoryClass = DataClassRegistry.get(classKey);
12
36
 
13
- const instance = new factoryClass();
14
- return instance.rehydrate(data) as T;
37
+ return guarded(data, () => new factoryClass().rehydrate(data) as T);
15
38
  }
16
39
 
17
40
  public static rehydrateList<T extends ApiDataInterface>(
@@ -20,11 +43,8 @@ export class RehydrationFactory {
20
43
  ): T[] {
21
44
  const factoryClass = DataClassRegistry.get(classKey);
22
45
 
23
- const response = data.map((item: JsonApiHydratedDataInterface) => {
24
- const instance = new factoryClass();
25
- return instance.rehydrate(item) as T;
26
- });
27
-
28
- return response;
46
+ return data.map((item: JsonApiHydratedDataInterface) =>
47
+ guarded(item, () => new factoryClass().rehydrate(item) as T),
48
+ );
29
49
  }
30
50
  }
@@ -149,4 +149,37 @@ describe("RehydrationFactory", () => {
149
149
  expect(results[0]).not.toBe(results[1]);
150
150
  });
151
151
  });
152
+
153
+ describe("circular included data", () => {
154
+ // A model that reads its "other" relationship from included, as AbstractApiData models do.
155
+ class Linked implements Partial<ApiDataInterface> {
156
+ id = "";
157
+ other?: Linked;
158
+ rehydrate(data: JsonApiHydratedDataInterface) {
159
+ this.id = data.jsonApi.id;
160
+ const ref = data.jsonApi.relationships?.other?.data;
161
+ const target = ref && data.included.find((item: any) => item.type === ref.type && item.id === ref.id);
162
+ if (target)
163
+ this.other = RehydrationFactory.rehydrate(linkedModule as any, { jsonApi: target, included: data.included });
164
+ return this;
165
+ }
166
+ }
167
+ const linkedModule = { name: "linked", model: Linked };
168
+
169
+ it("a relationship that leads back to a resource already being rehydrated is skipped, not followed forever", () => {
170
+ DataClassRegistry.registerObjectClass(linkedModule as any, Linked as any);
171
+ const a = { type: "linked", id: "a", relationships: { other: { data: { type: "linked", id: "b" } } } };
172
+ const b = { type: "linked", id: "b", relationships: { other: { data: { type: "linked", id: "a" } } } };
173
+
174
+ const result = RehydrationFactory.rehydrate<any>(linkedModule as any, { jsonApi: a, included: [a, b] });
175
+
176
+ expect(result.id).toBe("a");
177
+ expect(result.other.id).toBe("b");
178
+ expect(result.other.other).toBeUndefined();
179
+ // The guard is per chain: the same resource rehydrates again in a new call.
180
+ expect(RehydrationFactory.rehydrate<any>(linkedModule as any, { jsonApi: b, included: [a, b] }).other.id).toBe(
181
+ "a",
182
+ );
183
+ });
184
+ });
152
185
  });
package/src/core/index.ts CHANGED
@@ -131,6 +131,9 @@ export * from "../features/referral/referral.module";
131
131
  export * from "../features/referral/referral-stats.module";
132
132
  export * from "../features/referral/referral-admin.modules";
133
133
  export * from "../features/referral/interfaces/referral-admin.interface";
134
+ // Offline mode models and their module bundle (opt-in): exported from core so a
135
+ // bootstrapper can spread offlineModules without loading the ./offline entry.
136
+ export * from "../offline/sync/models";
134
137
  // Administrative token-usage reporting modules. Exported from core (not only
135
138
  // from the client-marked ./tokenusage barrel) so a consuming app can register
136
139
  // them in its bootstrapper without pulling the feature's client bundle —
@@ -5,4 +5,6 @@ export type ApiData = {
5
5
  statusText: string;
6
6
  token?: string;
7
7
  refreshToken?: string;
8
+ /** Set by `directFetch` when the API could not be reached at all (the 503 it reports). */
9
+ networkError?: boolean;
8
10
  };
@@ -107,6 +107,9 @@ export interface FoundationModuleDefinitions {
107
107
  AnalyticsPageView: ModuleWithPermissions;
108
108
  // Account deletion (opt-in: registered only by apps that enable it)
109
109
  AccountDeletion: ModuleWithPermissions;
110
+ // Offline mode (opt-in: registered by apps that spread offlineModules)
111
+ OfflineChangeSet: ModuleWithPermissions;
112
+ RootSync: ModuleWithPermissions;
110
113
  }
111
114
 
112
115
  // App-specific modules - apps will augment this interface ONLY
@@ -12,13 +12,20 @@ interface LogoutProps {
12
12
  * library's own `user` key is always cleared and does not need listing.
13
13
  */
14
14
  storageKeys?: string[];
15
+ /**
16
+ * Awaited before anything is cleared (offline mode: warn about changes that
17
+ * were not sent, then drop the device copy). Returning `false` cancels the
18
+ * logout: storage, cookies and the current page are left as they are.
19
+ */
20
+ beforeLogout?: () => Promise<boolean>;
15
21
  }
16
22
 
17
- export function Logout({ storageKeys }: LogoutProps) {
23
+ export function Logout({ storageKeys, beforeLogout }: LogoutProps) {
18
24
  const generateUrl = usePageUrlGenerator();
19
25
 
20
26
  useEffect(() => {
21
27
  const logOut = async () => {
28
+ if (beforeLogout && !(await beforeLogout())) return;
22
29
  // `CurrentUserProvider` persists the user in localStorage, so clearing the
23
30
  // cookies alone leaves the client believing the session is still alive:
24
31
  // the server renders as anonymous while every `useCurrentUserContext()`
@@ -120,4 +120,66 @@ describe("Logout", () => {
120
120
  });
121
121
  });
122
122
  });
123
+
124
+ // Offline mode: the app may hold unsent changes on the device, so it gets a
125
+ // chance to warn (or cancel) before anything is cleared.
126
+ describe("Scenario: beforeLogout hook", () => {
127
+ it("awaits beforeLogout and stops when it returns false", async () => {
128
+ let resolveHook: (value: boolean) => void = () => {};
129
+ const beforeLogout = vi.fn(
130
+ () =>
131
+ new Promise<boolean>((resolve) => {
132
+ resolveHook = resolve;
133
+ }),
134
+ );
135
+
136
+ render(<Logout beforeLogout={beforeLogout} />);
137
+
138
+ await waitFor(() => {
139
+ expect(beforeLogout).toHaveBeenCalledTimes(1);
140
+ });
141
+ expect(clearClientStorage).not.toHaveBeenCalled();
142
+ expect(AuthService.logout).not.toHaveBeenCalled();
143
+
144
+ resolveHook(false);
145
+ await new Promise((resolve) => setTimeout(resolve, 0));
146
+
147
+ expect(clearClientStorage).not.toHaveBeenCalled();
148
+ expect(AuthService.logout).not.toHaveBeenCalled();
149
+ expect(windowLocationHref).toBe("");
150
+ });
151
+
152
+ it("clears storage and logs out when it returns true or is absent", async () => {
153
+ const callOrder: string[] = [];
154
+ const beforeLogout = vi.fn(async () => {
155
+ callOrder.push("beforeLogout");
156
+ return true;
157
+ });
158
+ vi.mocked(clearClientStorage).mockImplementation(() => {
159
+ callOrder.push("clearClientStorage");
160
+ });
161
+ vi.mocked(AuthService.logout).mockImplementation(async () => {
162
+ callOrder.push("AuthService.logout");
163
+ });
164
+
165
+ const { unmount } = render(<Logout beforeLogout={beforeLogout} storageKeys={["person"]} />);
166
+
167
+ await waitFor(() => {
168
+ expect(windowLocationHref).toBe("/");
169
+ });
170
+ expect(callOrder).toEqual(["beforeLogout", "clearClientStorage", "AuthService.logout"]);
171
+ expect(clearClientStorage).toHaveBeenCalledWith([CURRENT_USER_STORAGE_KEY, "person"]);
172
+ unmount();
173
+
174
+ vi.clearAllMocks();
175
+ windowLocationHref = "";
176
+ render(<Logout />);
177
+
178
+ await waitFor(() => {
179
+ expect(windowLocationHref).toBe("/");
180
+ });
181
+ expect(clearClientStorage).toHaveBeenCalledWith([CURRENT_USER_STORAGE_KEY]);
182
+ expect(AuthService.logout).toHaveBeenCalledTimes(1);
183
+ });
184
+ });
123
185
  });
@@ -0,0 +1,39 @@
1
+ import { renderHook } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+
4
+ const { io, fakeSocket } = vi.hoisted(() => {
5
+ const fakeSocket = {
6
+ id: undefined,
7
+ connected: true,
8
+ on: vi.fn(),
9
+ off: vi.fn(),
10
+ emit: vi.fn(),
11
+ connect: vi.fn(),
12
+ disconnect: vi.fn(),
13
+ removeAllListeners: vi.fn(),
14
+ };
15
+ return { fakeSocket, io: vi.fn(() => fakeSocket) };
16
+ });
17
+ vi.mock("socket.io-client", () => ({ default: io }));
18
+
19
+ import { useSocket } from "../useSocket";
20
+
21
+ describe("useSocket pause (offline mode)", () => {
22
+ it("disconnects while paused and connects the same socket again when resumed", () => {
23
+ const { rerender } = renderHook(({ paused }) => useSocket({ token: "t1", paused }), {
24
+ initialProps: { paused: false },
25
+ });
26
+ expect(io).toHaveBeenCalledTimes(1);
27
+ expect(fakeSocket.disconnect).not.toHaveBeenCalled();
28
+
29
+ rerender({ paused: true });
30
+ expect(fakeSocket.disconnect).toHaveBeenCalledTimes(1);
31
+ rerender({ paused: true });
32
+ expect(fakeSocket.disconnect).toHaveBeenCalledTimes(1);
33
+ expect(fakeSocket.connect).not.toHaveBeenCalled();
34
+
35
+ rerender({ paused: false });
36
+ expect(fakeSocket.connect).toHaveBeenCalledTimes(1);
37
+ expect(io).toHaveBeenCalledTimes(1);
38
+ });
39
+ });
@@ -10,6 +10,8 @@ type Socket = ReturnType<typeof io>;
10
10
 
11
11
  interface UseSocketOptions {
12
12
  token: string;
13
+ /** While true the socket is disconnected (no reconnect attempts); false connects it again. */
14
+ paused?: boolean;
13
15
  }
14
16
 
15
17
  interface SocketLike {
@@ -31,7 +33,7 @@ interface UseSocketReturn {
31
33
  clearSocketNotifications: () => void;
32
34
  }
33
35
 
34
- export function useSocket({ token }: UseSocketOptions): UseSocketReturn {
36
+ export function useSocket({ token, paused = false }: UseSocketOptions): UseSocketReturn {
35
37
  const _errorCount = useRef(0);
36
38
  const shouldConnect = useRef(true);
37
39
 
@@ -168,6 +170,23 @@ export function useSocket({ token }: UseSocketOptions): UseSocketReturn {
168
170
  };
169
171
  }, [token]);
170
172
 
173
+ // Paused (offline mode, the session is offline): a manual disconnect stops socket.io retrying;
174
+ // resuming connects the same socket again.
175
+ const pausedSocket = useRef<Socket | null>(null);
176
+ useEffect(() => {
177
+ const current = socketRef.current;
178
+ if (!current) return;
179
+ if (paused) {
180
+ if (pausedSocket.current !== current) {
181
+ current.disconnect();
182
+ pausedSocket.current = current;
183
+ }
184
+ } else if (pausedSocket.current === current) {
185
+ pausedSocket.current = null;
186
+ current.connect();
187
+ }
188
+ }, [paused, socket]);
189
+
171
190
  const sendMessage = (event: string, data: any) => {
172
191
  if (socketRef.current && isConnected) {
173
192
  socketRef.current.emit(event, data);
@@ -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
+ });