@carlonicora/nextjs-jsonapi 6.12.2 → 6.13.1

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 (256) 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-7M5QW2YQ.js → BlockNoteEditor-UBVEUFNR.js} +34 -22
  4. package/dist/BlockNoteEditor-UBVEUFNR.js.map +1 -0
  5. package/dist/{BlockNoteEditor-75ED2CN2.mjs → BlockNoteEditor-VXMDSQAA.mjs} +20 -8
  6. package/dist/BlockNoteEditor-VXMDSQAA.mjs.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-KSYQEKJX.js → chunk-2SV4SS6N.js} +14 -14
  21. package/dist/{chunk-KSYQEKJX.js.map → chunk-2SV4SS6N.js.map} +1 -1
  22. package/dist/{chunk-4FNGWALA.mjs → chunk-47BBY5KA.mjs} +6 -4
  23. package/dist/{chunk-4FNGWALA.mjs.map → chunk-47BBY5KA.mjs.map} +1 -1
  24. package/dist/chunk-6JUCIWDC.mjs +570 -0
  25. package/dist/chunk-6JUCIWDC.mjs.map +1 -0
  26. package/dist/chunk-7DBJTNSU.mjs +1049 -0
  27. package/dist/chunk-7DBJTNSU.mjs.map +1 -0
  28. package/dist/chunk-7JZHPMGW.mjs +704 -0
  29. package/dist/chunk-7JZHPMGW.mjs.map +1 -0
  30. package/dist/{chunk-L6PYFQTO.js → chunk-7ZHTPFFT.js} +16 -14
  31. package/dist/chunk-7ZHTPFFT.js.map +1 -0
  32. package/dist/chunk-B6JGKDP2.js +214 -0
  33. package/dist/chunk-B6JGKDP2.js.map +1 -0
  34. package/dist/{chunk-3YW35YHQ.mjs → chunk-BH6JWIKI.mjs} +5 -5
  35. package/dist/chunk-BZ5J2FIV.js +570 -0
  36. package/dist/chunk-BZ5J2FIV.js.map +1 -0
  37. package/dist/{chunk-V5MI7RNB.mjs → chunk-DGWOVYEC.mjs} +2 -2
  38. package/dist/chunk-DOCIFE6A.mjs +212 -0
  39. package/dist/chunk-DOCIFE6A.mjs.map +1 -0
  40. package/dist/chunk-GB6DBFYY.js +25 -0
  41. package/dist/chunk-GB6DBFYY.js.map +1 -0
  42. package/dist/{chunk-EWCY6QJA.mjs → chunk-JJMMT24D.mjs} +37 -3
  43. package/dist/chunk-JJMMT24D.mjs.map +1 -0
  44. package/dist/{chunk-JTPRJNT2.js → chunk-KMW7UNL2.js} +38 -3
  45. package/dist/chunk-KMW7UNL2.js.map +1 -0
  46. package/dist/chunk-MHB36MYE.js +317 -0
  47. package/dist/chunk-MHB36MYE.js.map +1 -0
  48. package/dist/{chunk-FF4EIQ6H.js → chunk-MOGEGWXW.js} +1039 -1680
  49. package/dist/chunk-MOGEGWXW.js.map +1 -0
  50. package/dist/{chunk-RXQYEEKZ.mjs → chunk-N4RZZKL5.mjs} +58 -25
  51. package/dist/chunk-N4RZZKL5.mjs.map +1 -0
  52. package/dist/{chunk-AA52ORW2.mjs → chunk-P445QGOC.mjs} +61 -702
  53. package/dist/chunk-P445QGOC.mjs.map +1 -0
  54. package/dist/chunk-PV4Z5W6V.js +212 -0
  55. package/dist/chunk-PV4Z5W6V.js.map +1 -0
  56. package/dist/chunk-PXDUJ47N.js +1049 -0
  57. package/dist/chunk-PXDUJ47N.js.map +1 -0
  58. package/dist/{chunk-V3YDVJ63.js → chunk-PYRZCKC2.js} +1209 -1176
  59. package/dist/chunk-PYRZCKC2.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/features/onboarding/contexts/OnboardingContext.spec.tsx +42 -1
  176. package/src/features/onboarding/contexts/OnboardingContext.tsx +11 -1
  177. package/src/hooks/__tests__/useSocket.pause.spec.ts +39 -0
  178. package/src/hooks/useSocket.ts +20 -1
  179. package/src/offline/__tests__/answer-builders.spec.ts +176 -0
  180. package/src/offline/__tests__/device-switch.spec.ts +179 -0
  181. package/src/offline/__tests__/generic-answers.spec.ts +380 -0
  182. package/src/offline/__tests__/local-transport.spec.ts +150 -0
  183. package/src/offline/__tests__/logout.spec.ts +122 -0
  184. package/src/offline/__tests__/offline-fixtures.ts +89 -0
  185. package/src/offline/__tests__/router.spec.ts +56 -0
  186. package/src/offline/__tests__/state.spec.ts +62 -0
  187. package/src/offline/__tests__/write-through.spec.ts +137 -0
  188. package/src/offline/config.ts +229 -0
  189. package/src/offline/configure.ts +14 -0
  190. package/src/offline/device-switch.ts +75 -0
  191. package/src/offline/generic-answers.ts +706 -0
  192. package/src/offline/index.ts +67 -0
  193. package/src/offline/local-transport.ts +189 -0
  194. package/src/offline/logout.ts +41 -0
  195. package/src/offline/router.ts +41 -0
  196. package/src/offline/server/__tests__/build-offline-manifest.spec.ts +76 -0
  197. package/src/offline/server/build-offline-manifest.ts +49 -0
  198. package/src/offline/state-hooks.ts +10 -0
  199. package/src/offline/state.ts +143 -0
  200. package/src/offline/store/DeviceStore.ts +112 -0
  201. package/src/offline/store/__tests__/device-store-lifecycle.spec.ts +343 -0
  202. package/src/offline/store/__tests__/shared-database.spec.ts +151 -0
  203. package/src/offline/store/__tests__/sql-device-store.spec.ts +399 -0
  204. package/src/offline/store/lifecycle.ts +315 -0
  205. package/src/offline/store/schema.ts +101 -0
  206. package/src/offline/store/shared-database.ts +321 -0
  207. package/src/offline/store/sql-device-store.ts +469 -0
  208. package/src/offline/store/worker-client.ts +71 -0
  209. package/src/offline/store/worker.ts +87 -0
  210. package/src/offline/sync/__tests__/conflicts.spec.ts +185 -0
  211. package/src/offline/sync/__tests__/queue-drain.spec.ts +364 -0
  212. package/src/offline/sync/__tests__/runtime.spec.ts +337 -0
  213. package/src/offline/sync/__tests__/sync-fixtures.ts +103 -0
  214. package/src/offline/sync/__tests__/sync-root.spec.ts +727 -0
  215. package/src/offline/sync/artwork-cache.ts +67 -0
  216. package/src/offline/sync/conflicts.ts +211 -0
  217. package/src/offline/sync/counts.ts +39 -0
  218. package/src/offline/sync/drain-queue.ts +246 -0
  219. package/src/offline/sync/known-roots.ts +37 -0
  220. package/src/offline/sync/models/OfflineChangeSet.ts +34 -0
  221. package/src/offline/sync/models/OfflineChangeSetInterface.ts +54 -0
  222. package/src/offline/sync/models/RootSync.ts +68 -0
  223. package/src/offline/sync/models/RootSyncInterface.ts +20 -0
  224. package/src/offline/sync/models/__tests__/OfflineChangeSet.spec.ts +47 -0
  225. package/src/offline/sync/models/__tests__/RootSync.spec.ts +54 -0
  226. package/src/offline/sync/models/index.ts +5 -0
  227. package/src/offline/sync/models/offline.modules.ts +14 -0
  228. package/src/offline/sync/runtime.ts +226 -0
  229. package/src/offline/sync/session-refresh.ts +32 -0
  230. package/src/offline/sync/sync-root.ts +336 -0
  231. package/src/offline/sync/waiting.ts +40 -0
  232. package/src/offline/types.ts +115 -0
  233. package/src/offline/write-through.ts +189 -0
  234. package/src/pwa/__tests__/sw.spec.ts +358 -1
  235. package/src/server/index.ts +5 -0
  236. package/src/unified/__tests__/JsonApiRequest.offline.spec.ts +243 -0
  237. package/dist/BlockNoteEditor-75ED2CN2.mjs.map +0 -1
  238. package/dist/BlockNoteEditor-7M5QW2YQ.js.map +0 -1
  239. package/dist/BlockNoteViewer-WGCDB7IJ.js.map +0 -1
  240. package/dist/JsonApiRequest-FZGPLNUF.js +0 -26
  241. package/dist/chunk-AA52ORW2.mjs.map +0 -1
  242. package/dist/chunk-EWCY6QJA.mjs.map +0 -1
  243. package/dist/chunk-FF4EIQ6H.js.map +0 -1
  244. package/dist/chunk-JTPRJNT2.js.map +0 -1
  245. package/dist/chunk-L6PYFQTO.js.map +0 -1
  246. package/dist/chunk-O3PDL2CV.js.map +0 -1
  247. package/dist/chunk-PI56A4DG.mjs.map +0 -1
  248. package/dist/chunk-RXQYEEKZ.mjs.map +0 -1
  249. package/dist/chunk-V3YDVJ63.js.map +0 -1
  250. package/dist/request-5RLK2AM3.mjs +0 -9
  251. package/dist/request-EUGTI3BE.js +0 -9
  252. package/dist/request-EUGTI3BE.js.map +0 -1
  253. /package/dist/{JsonApiRequest-A66XK5WT.mjs.map → JsonApiRequest-F6TMJ42G.mjs.map} +0 -0
  254. /package/dist/{chunk-3YW35YHQ.mjs.map → chunk-BH6JWIKI.mjs.map} +0 -0
  255. /package/dist/{chunk-V5MI7RNB.mjs.map → chunk-DGWOVYEC.mjs.map} +0 -0
  256. /package/dist/{request-5RLK2AM3.mjs.map → lifecycle-LKGPRBS7.mjs.map} +0 -0
@@ -0,0 +1,54 @@
1
+ import { ApiDataInterface } from "../../../core/interfaces/ApiDataInterface";
2
+
3
+ /** One waiting change as the server reads it (C1b, `attributes.changes[]`). */
4
+ export type OfflineChange = {
5
+ seq: number;
6
+ method: "POST" | "PUT" | "PATCH" | "DELETE";
7
+ /** Relative to the API base, as EndpointCreator built it. */
8
+ path: string;
9
+ /** The JSON:API document the model built when the GM saved. */
10
+ body?: Record<string, unknown>;
11
+ /** Set for an edit or delete of one record. */
12
+ recordType?: string;
13
+ recordId?: string;
14
+ /** The record's JSON:API resource as of the last sync. */
15
+ base?: Record<string, unknown> | null;
16
+ /**
17
+ * Set on a re-sent change after the GM resolved its conflict. The change keeps its original
18
+ * `body` and `base`; the server runs the normal three-way merge and settles each conflicting
19
+ * field by `choices` (kind "mine": every conflicting field is "mine").
20
+ */
21
+ resolution?: OfflineChangeResolution;
22
+ };
23
+
24
+ export type OfflineChangeResolution = {
25
+ kind: "mine" | "merged";
26
+ /** Per conflicting field: whose value wins. */
27
+ choices?: Record<string, "mine" | "theirs" | "merged">;
28
+ /** The model-built JSON:API document holding only the fields chosen "merged", with their hand-merged values. */
29
+ mergedBody?: Record<string, unknown>;
30
+ };
31
+
32
+ /** The server's answer for one change (C1b, `attributes.results[]`). */
33
+ export type OfflineChangeResult = {
34
+ seq: number;
35
+ status: "applied" | "conflict" | "could-not-apply" | "waiting";
36
+ conflict?: "fields" | "deleted-on-server" | "changed-on-server";
37
+ /** JSON:API resource as it is now on the server. */
38
+ current?: Record<string, unknown> | null;
39
+ /** Attributes the merge settled (for the conflict screen). */
40
+ merged?: Record<string, unknown>;
41
+ conflictFields?: string[];
42
+ /** Rich-text field → conflicting block ids. */
43
+ conflictBlocks?: Record<string, string[]>;
44
+ /** The server's reason for could-not-apply. */
45
+ detail?: string;
46
+ /** JSON:API document the route answered, when applied. */
47
+ response?: Record<string, unknown>;
48
+ };
49
+
50
+ export type OfflineChangeSetInput = { id: string; changes: OfflineChange[] };
51
+
52
+ export interface OfflineChangeSetInterface extends ApiDataInterface {
53
+ get results(): OfflineChangeResult[];
54
+ }
@@ -0,0 +1,68 @@
1
+ import { AbstractApiData } from "../../../core/abstracts/AbstractApiData";
2
+ import { JsonApiHydratedDataInterface } from "../../../core/interfaces/JsonApiHydratedDataInterface";
3
+ import { RootSyncEntry, RootSyncInterface, RootSyncLink } from "./RootSyncInterface";
4
+
5
+ /**
6
+ * Child path of the sync answer under the root module record
7
+ * (`GET /<root endpoint>/:id/sync`), the same value as the app API's sync meta endpoint
8
+ * (in Wyrdli, `campaignSyncMeta.endpoint`).
9
+ */
10
+ export const ROOT_SYNC_ENDPOINT = "sync";
11
+
12
+ /** The sync answer for one root record (`root-syncs`, id = root record id). Read-only. */
13
+ export class RootSync extends AbstractApiData implements RootSyncInterface {
14
+ private _serverTime?: Date;
15
+ private _locked: boolean = false;
16
+ private _lockedFrom?: Date;
17
+ private _entries?: RootSyncEntry[];
18
+ private _links?: RootSyncLink[];
19
+
20
+ get serverTime(): Date {
21
+ if (!this._serverTime) throw new Error("serverTime is not set.");
22
+ return this._serverTime;
23
+ }
24
+
25
+ get locked(): boolean {
26
+ return this._locked;
27
+ }
28
+
29
+ get lockedFrom(): Date | undefined {
30
+ return this._lockedFrom;
31
+ }
32
+
33
+ get entries(): RootSyncEntry[] {
34
+ return this._entries ?? [];
35
+ }
36
+
37
+ get links(): RootSyncLink[] {
38
+ return this._links ?? [];
39
+ }
40
+
41
+ rehydrate(data: JsonApiHydratedDataInterface): this {
42
+ super.rehydrate(data);
43
+
44
+ const attributes = data.jsonApi.attributes ?? {};
45
+ this._serverTime = attributes.serverTime ? new Date(attributes.serverTime) : undefined;
46
+ this._locked = Boolean(attributes.locked);
47
+ this._lockedFrom = attributes.lockedFrom ? new Date(attributes.lockedFrom) : undefined;
48
+ this._entries = (attributes.entries ?? []).map((entry: { type: string; id: string; updatedAt: string }) => ({
49
+ type: entry.type,
50
+ id: entry.id,
51
+ updatedAt: new Date(entry.updatedAt),
52
+ }));
53
+ this._links = (attributes.links ?? []).map((link: RootSyncLink) => ({
54
+ fromType: link.fromType,
55
+ fromId: link.fromId,
56
+ toType: link.toType,
57
+ toId: link.toId,
58
+ kind: link.kind,
59
+ props: link.props ?? {},
60
+ }));
61
+
62
+ return this;
63
+ }
64
+
65
+ createJsonApi(_data?: any): any {
66
+ throw new Error("RootSync is read-only and cannot be created");
67
+ }
68
+ }
@@ -0,0 +1,20 @@
1
+ import { ApiDataInterface } from "../../../core/interfaces/ApiDataInterface";
2
+
3
+ export type RootSyncEntry = { type: string; id: string; updatedAt: Date };
4
+
5
+ export type RootSyncLink = {
6
+ fromType: string;
7
+ fromId: string;
8
+ toType: string;
9
+ toId: string;
10
+ kind: string;
11
+ props: Record<string, unknown>;
12
+ };
13
+
14
+ export interface RootSyncInterface extends ApiDataInterface {
15
+ get serverTime(): Date;
16
+ get locked(): boolean;
17
+ get lockedFrom(): Date | undefined;
18
+ get entries(): RootSyncEntry[];
19
+ get links(): RootSyncLink[];
20
+ }
@@ -0,0 +1,47 @@
1
+ import { beforeAll, describe, expect, it } from "vitest";
2
+ import { ApiRequestDataTypeInterface } from "../../../../core/interfaces/ApiRequestDataTypeInterface";
3
+ import { ModuleRegistry } from "../../../../core/registry/ModuleRegistry";
4
+ import { ModuleFactory, ModuleWithPermissions } from "../../../../permissions/types";
5
+ import { OfflineChangeSet } from "../OfflineChangeSet";
6
+ import { offlineModules } from "../offline.modules";
7
+
8
+ const moduleFactory: ModuleFactory = (params) =>
9
+ ({ pageUrl: params.pageUrl, name: params.name, model: params.model }) as ModuleWithPermissions;
10
+
11
+ const registerIfAbsent = (key: string, module: ApiRequestDataTypeInterface) => {
12
+ try {
13
+ ModuleRegistry.get(key as any);
14
+ } catch {
15
+ ModuleRegistry.register(key, module);
16
+ }
17
+ };
18
+
19
+ beforeAll(() => {
20
+ for (const [key, module] of Object.entries(offlineModules(moduleFactory))) registerIfAbsent(key, module);
21
+ });
22
+
23
+ describe("OfflineChangeSet", () => {
24
+ it("createJsonApi emits type offline-change-sets, the id and attributes.changes", () => {
25
+ const changes = [
26
+ { seq: 1, method: "POST" as const, path: "npcs", body: { data: { type: "npcs", id: "n1" } } },
27
+ { seq: 2, method: "DELETE" as const, path: "npcs/n2", recordType: "npcs", recordId: "n2", base: { id: "n2" } },
28
+ ];
29
+ const doc = new OfflineChangeSet().createJsonApi({ id: "pkg-1", changes });
30
+ expect(doc.data.type).toBe("offline-change-sets");
31
+ expect(doc.data.id).toBe("pkg-1");
32
+ expect(doc.data.attributes.changes).toEqual(changes);
33
+ });
34
+
35
+ it("rehydrate reads results", () => {
36
+ const results = [
37
+ { seq: 1, status: "applied" },
38
+ { seq: 2, status: "conflict", conflict: "fields", conflictFields: ["name"] },
39
+ ];
40
+ const set = new OfflineChangeSet().rehydrate({
41
+ jsonApi: { type: "offline-change-sets", id: "pkg-1", attributes: { results } },
42
+ included: [],
43
+ } as any);
44
+ expect(set.id).toBe("pkg-1");
45
+ expect(set.results).toEqual(results);
46
+ });
47
+ });
@@ -0,0 +1,54 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { RootSync } from "../RootSync";
3
+
4
+ describe("RootSync", () => {
5
+ it("rehydrate parses serverTime, lockedFrom and every entries[].updatedAt with new Date", () => {
6
+ const sync = new RootSync().rehydrate({
7
+ jsonApi: {
8
+ type: "root-syncs",
9
+ id: "c1",
10
+ attributes: {
11
+ serverTime: "2026-11-01T10:00:00.000Z",
12
+ locked: false,
13
+ lockedFrom: "2026-11-20T00:00:00.000Z",
14
+ entries: [
15
+ { type: "npcs", id: "n1", updatedAt: "2026-10-30T08:00:00.000Z" },
16
+ { type: "scenes", id: "s1", updatedAt: "2026-10-31T09:30:00.000Z" },
17
+ ],
18
+ links: [
19
+ { fromType: "npcs", fromId: "n1", toType: "scenes", toId: "s1", kind: "APPEARS_IN", props: { x: 1 } },
20
+ ],
21
+ },
22
+ },
23
+ included: [],
24
+ } as any);
25
+
26
+ expect(sync.id).toBe("c1");
27
+ expect(sync.serverTime).toBeInstanceOf(Date);
28
+ expect(sync.serverTime.toISOString()).toBe("2026-11-01T10:00:00.000Z");
29
+ expect(sync.locked).toBe(false);
30
+ expect(sync.lockedFrom).toBeInstanceOf(Date);
31
+ expect(sync.lockedFrom?.toISOString()).toBe("2026-11-20T00:00:00.000Z");
32
+ expect(sync.entries.map((e) => e.updatedAt)).toEqual([
33
+ new Date("2026-10-30T08:00:00.000Z"),
34
+ new Date("2026-10-31T09:30:00.000Z"),
35
+ ]);
36
+ expect(sync.entries.every((e) => e.updatedAt instanceof Date)).toBe(true);
37
+ expect(sync.links).toEqual([
38
+ { fromType: "npcs", fromId: "n1", toType: "scenes", toId: "s1", kind: "APPEARS_IN", props: { x: 1 } },
39
+ ]);
40
+ });
41
+
42
+ it("a null lockedFrom rehydrates as undefined", () => {
43
+ const sync = new RootSync().rehydrate({
44
+ jsonApi: {
45
+ type: "root-syncs",
46
+ id: "c1",
47
+ attributes: { serverTime: "2026-11-01T10:00:00.000Z", locked: true, lockedFrom: null, entries: [], links: [] },
48
+ },
49
+ included: [],
50
+ } as any);
51
+ expect(sync.locked).toBe(true);
52
+ expect(sync.lockedFrom).toBeUndefined();
53
+ });
54
+ });
@@ -0,0 +1,5 @@
1
+ export * from "./RootSync";
2
+ export * from "./RootSyncInterface";
3
+ export * from "./OfflineChangeSet";
4
+ export * from "./OfflineChangeSetInterface";
5
+ export * from "./offline.modules";
@@ -0,0 +1,14 @@
1
+ import { ModuleFactory, ModuleWithPermissions } from "../../../permissions/types";
2
+ import { RootSync } from "./RootSync";
3
+ import { OfflineChangeSet } from "./OfflineChangeSet";
4
+
5
+ /**
6
+ * The offline resources, as one object an app that opts into offline mode
7
+ * spreads into its `allModules` (same rationale as `analyticsModules`).
8
+ * Neither has a pageUrl: these are not navigable resources.
9
+ */
10
+ export const offlineModules = (factory: ModuleFactory) =>
11
+ ({
12
+ OfflineChangeSet: factory({ name: "offline-change-sets", model: OfflineChangeSet }),
13
+ RootSync: factory({ name: "root-syncs", model: RootSync }),
14
+ }) satisfies Record<string, ModuleWithPermissions>;
@@ -0,0 +1,226 @@
1
+ import { getCookie } from "cookies-next";
2
+ import { getClientToken } from "../../client/token";
3
+ import { directFetch } from "../../client/request";
4
+ import { EndpointCreator } from "../../core/endpoint/EndpointCreator";
5
+ import { buildUrl } from "../../unified/JsonApiRequest";
6
+ import {
7
+ getOfflineConfig,
8
+ getOfflineStore,
9
+ isOfflineConfigured,
10
+ OfflineUnavailableError,
11
+ refreshOfflineDeviceState,
12
+ } from "../config";
13
+ import { getOfflineState, isReconnecting, setOfflineState, setReconnecting, subscribeOfflineState } from "../state";
14
+ import { OFFLINE_REPLAY_HEADER } from "../types";
15
+ import { refreshOfflineCounts } from "./counts";
16
+ import { drainQueue } from "./drain-queue";
17
+ import { knownRootIds, refreshLockedRoots } from "./known-roots";
18
+ import { ROOT_SYNC_ENDPOINT } from "./models/RootSync";
19
+ import { currentLocale } from "./session-refresh";
20
+ import { syncAll } from "./sync-root";
21
+
22
+ /** How often the stored lock dates are checked again (a lock date can pass while offline). */
23
+ const LOCK_CHECK_INTERVAL_MS = 60_000;
24
+ /** How often the API is probed while the session is offline but the browser says it is online. */
25
+ const PROBE_INTERVAL_MS = 30_000;
26
+
27
+ /**
28
+ * Asks the API whether it can be reached again. The request carries the replay header, so
29
+ * `directFetch` sends it to the network instead of answering it from the device copy. Any
30
+ * answer (even a 401) means the API is reachable; only a network error means it is not.
31
+ */
32
+ async function probeApi(): Promise<boolean> {
33
+ const config = getOfflineConfig();
34
+ const store = await getOfflineStore();
35
+ const [rootId] = await knownRootIds(store);
36
+ const endpoint = rootId
37
+ ? new EndpointCreator({ endpoint: config.rootModule, id: rootId, childEndpoint: ROOT_SYNC_ENDPOINT })
38
+ : new EndpointCreator({ endpoint: config.rootModule }).addAdditionalParam("page[size]", "1");
39
+
40
+ const response = await directFetch({
41
+ method: "GET",
42
+ url: buildUrl(endpoint.generate()),
43
+ token: await getClientToken(),
44
+ language: currentLocale(),
45
+ additionalHeaders: { [OFFLINE_REPLAY_HEADER]: "1" },
46
+ });
47
+ return !response.networkError;
48
+ }
49
+
50
+ /** Remembers (for the tab) the sign-in a reload for the login was already tried for. */
51
+ const LOGIN_RELOAD_KEY = "nja-offline-login-reload";
52
+ let _reloadedForSignIn: string | undefined;
53
+
54
+ /** A short fingerprint of the sign-in (the `token` cookie), never the token itself. */
55
+ async function signInFingerprint(): Promise<string> {
56
+ const token = await getCookie("token");
57
+ if (!token) return "none";
58
+ let hash = 5381;
59
+ for (const char of String(token)) hash = ((hash * 33) ^ char.charCodeAt(0)) >>> 0;
60
+ return hash.toString(36);
61
+ }
62
+
63
+ const readReloadMarker = (): string | null => {
64
+ try {
65
+ return window.sessionStorage.getItem(LOGIN_RELOAD_KEY);
66
+ } catch {
67
+ return null;
68
+ }
69
+ };
70
+
71
+ const writeReloadMarker = (value: string | null): void => {
72
+ try {
73
+ if (value === null) window.sessionStorage.removeItem(LOGIN_RELOAD_KEY);
74
+ else window.sessionStorage.setItem(LOGIN_RELOAD_KEY, value);
75
+ } catch {
76
+ // Storage unavailable: the module flag still stops a loop within this page.
77
+ }
78
+ };
79
+
80
+ /**
81
+ * The sign-in expired: reloading sends the GM through the proxy to the login page. The queue
82
+ * stays. Tried once per sign-in: when the API still refuses the same sign-in after the reload
83
+ * (a 401 while the cookie exists), the GM stays on the page and the queue waits for a new
84
+ * sign-in, instead of reloading in a loop.
85
+ */
86
+ async function signInExpired(): Promise<void> {
87
+ const signIn = await signInFingerprint();
88
+ if (_reloadedForSignIn === signIn || readReloadMarker() === signIn) {
89
+ console.warn("[offline] the sign-in expired; log in again to send the waiting changes");
90
+ return;
91
+ }
92
+ _reloadedForSignIn = signIn;
93
+ writeReloadMarker(signIn);
94
+ try {
95
+ window.location.reload();
96
+ } catch (error) {
97
+ console.warn("[offline] the sign-in expired; log in again to send the waiting changes", error);
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Logs a failure of the runtime, except the browser being found unable to keep the device copy:
103
+ * that is warned once (`markOfflineUnavailable`) and the runtime stops.
104
+ */
105
+ const warnUnlessUnavailable =
106
+ (message: string) =>
107
+ (error: unknown): void => {
108
+ if (!(error instanceof OfflineUnavailableError)) console.warn(message, error);
109
+ };
110
+
111
+ const _runningRuntimes = new Set<() => void>();
112
+
113
+ /** Stops every running offline runtime (timers, listeners). Called on logout. */
114
+ export function stopOfflineRuntime(): void {
115
+ for (const stop of [..._runningRuntimes]) stop();
116
+ }
117
+
118
+ /**
119
+ * Starts the offline runtime: drain-then-sync on open (online) and on every reconnect, syncAll
120
+ * every `syncIntervalMs` while online, a probe of the API while the session is offline but the
121
+ * browser is online (a probe that reaches the API runs the drain and the sync, and the session
122
+ * goes online once they succeeded), and the lock dates checked every minute. Returns the stop
123
+ * function.
124
+ */
125
+ export function startOfflineRuntime(): () => void {
126
+ refreshOfflineDeviceState();
127
+ if (!isOfflineConfigured()) return () => {};
128
+ const config = getOfflineConfig();
129
+
130
+ let running: Promise<void> | undefined;
131
+ let goingOnlineAfterSync = false;
132
+ /**
133
+ * Drain then sync. `reconnect`: a probe reached the API while the session is offline; the
134
+ * session stays offline during the drain and the sync and goes online only when both ran
135
+ * without a network failure (spec § 4: offline "until a sync succeeds").
136
+ */
137
+ const run = (reconnect: boolean): Promise<void> => {
138
+ if (!running) {
139
+ running = (async () => {
140
+ let succeeded = false;
141
+ if (reconnect) setReconnecting(true);
142
+ try {
143
+ const drained = await drainQueue();
144
+ if (drained.stoppedForLogin) {
145
+ await signInExpired();
146
+ return;
147
+ }
148
+ writeReloadMarker(null);
149
+ if (reconnect ? !isReconnecting() : !getOfflineState().online) return;
150
+ await syncAll();
151
+ succeeded = reconnect && isReconnecting();
152
+ } catch (error) {
153
+ warnUnlessUnavailable("[offline] background sync failed")(error);
154
+ } finally {
155
+ if (reconnect) setReconnecting(false);
156
+ running = undefined;
157
+ if (succeeded) {
158
+ goingOnlineAfterSync = true;
159
+ setOfflineState({ online: true });
160
+ goingOnlineAfterSync = false;
161
+ // Changes queued while the attempt ran (an edit, a resolved conflict) waited because
162
+ // the session was still offline: they are sent now, not at the next sync interval.
163
+ if (getOfflineState().pending > 0) {
164
+ drainQueue().catch(warnUnlessUnavailable("[offline] sending the waiting changes failed"));
165
+ }
166
+ }
167
+ }
168
+ })();
169
+ }
170
+ return running;
171
+ };
172
+ const runOnline = () => run(false);
173
+
174
+ let probing = false;
175
+ const probe = async () => {
176
+ if (probing || running || getOfflineState().online) return;
177
+ if (typeof navigator !== "undefined" && navigator.onLine === false) return;
178
+ probing = true;
179
+ try {
180
+ if (await probeApi()) await run(true);
181
+ } catch (error) {
182
+ warnUnlessUnavailable("[offline] probing the API failed")(error);
183
+ } finally {
184
+ probing = false;
185
+ }
186
+ };
187
+
188
+ // The browser says the network is back: probe the API now (the state stays offline until then).
189
+ const onBrowserOnline = () => void probe();
190
+ if (typeof window !== "undefined") window.addEventListener("online", onBrowserOnline);
191
+
192
+ let wasOnline = getOfflineState().online;
193
+ const unsubscribe = subscribeOfflineState((state) => {
194
+ // The browser cannot keep the device copy: offline mode is off for the page.
195
+ if (!state.available) {
196
+ stop();
197
+ return;
198
+ }
199
+ // Online after a reconnect attempt: its drain and sync already ran.
200
+ if (state.online && !wasOnline && !goingOnlineAfterSync) void runOnline();
201
+ wasOnline = state.online;
202
+ });
203
+
204
+ void refreshLockedRoots().catch(warnUnlessUnavailable("[offline] lock check failed"));
205
+ void refreshOfflineCounts().catch(warnUnlessUnavailable("[offline] reading the queue failed"));
206
+ if (getOfflineState().online) void runOnline();
207
+
208
+ const syncTimer = setInterval(() => {
209
+ if (getOfflineState().online) void runOnline();
210
+ }, config.syncIntervalMs);
211
+ const probeTimer = setInterval(() => void probe(), PROBE_INTERVAL_MS);
212
+ const lockTimer = setInterval(() => {
213
+ void refreshLockedRoots().catch(warnUnlessUnavailable("[offline] lock check failed"));
214
+ }, LOCK_CHECK_INTERVAL_MS);
215
+
216
+ const stop = () => {
217
+ _runningRuntimes.delete(stop);
218
+ clearInterval(syncTimer);
219
+ clearInterval(probeTimer);
220
+ clearInterval(lockTimer);
221
+ if (typeof window !== "undefined") window.removeEventListener("online", onBrowserOnline);
222
+ unsubscribe();
223
+ };
224
+ _runningRuntimes.add(stop);
225
+ return stop;
226
+ }
@@ -0,0 +1,32 @@
1
+ import { getCookie } from "cookies-next";
2
+ import { getI18nDefaultLocale, getI18nSupportedLocales } from "../../i18n/config";
3
+ import { getOfflineConfig } from "../config";
4
+
5
+ /** The locale of the page the GM is on (first path segment), or the app's default locale. */
6
+ export function currentLocale(): string {
7
+ if (typeof window === "undefined") return getI18nDefaultLocale();
8
+ const first = window.location.pathname.split("/").filter(Boolean)[0];
9
+ return first && getI18nSupportedLocales().includes(first) ? first : getI18nDefaultLocale();
10
+ }
11
+
12
+ export type SessionRefreshResult = "ok" | "expired" | "unreachable";
13
+
14
+ /**
15
+ * Renews the sign-in before the waiting changes are sent: one same-origin GET of the offline
16
+ * shell (`cache: "no-store"`). It passes through the app's proxy, which renews the `token`
17
+ * cookie; the shell path is public, so the proxy never redirects it. The sign-in has expired
18
+ * when the `token` cookie is absent after the request.
19
+ *
20
+ * This is the one `fetch` of the offline layer: a request to the web app itself, not the API
21
+ * (Carlo's decision, 2026-11-01).
22
+ */
23
+ export async function refreshSession(): Promise<SessionRefreshResult> {
24
+ try {
25
+ await fetch(getOfflineConfig().shellPath(currentLocale()), { cache: "no-store", credentials: "same-origin" });
26
+ } catch {
27
+ return "unreachable";
28
+ }
29
+
30
+ const token = await getCookie("token");
31
+ return token ? "ok" : "expired";
32
+ }