@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,185 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { setOfflineState } from "../../state";
3
+ import type { ConflictKind, DeviceStore } from "../../store/DeviceStore";
4
+ import { npc, setUpSync, tearDownSync } from "./sync-fixtures";
5
+
6
+ vi.mock("../../../unified/JsonApiRequest", () => ({
7
+ JsonApiGet: vi.fn(),
8
+ JsonApiPost: vi.fn(),
9
+ getApiUrl: () => "http://api.test/",
10
+ buildUrl: (endpoint: string) => `http://api.test/${endpoint}`,
11
+ }));
12
+
13
+ import { resolveConflict } from "../conflicts";
14
+
15
+ describe("resolveConflict", () => {
16
+ let store: DeviceStore;
17
+ const mineBody = { data: npc("n1", "Mine") };
18
+ const base = npc("n1", "Base");
19
+ const theirs = npc("n1", "Theirs");
20
+
21
+ beforeEach(async () => {
22
+ store = await setUpSync();
23
+ // Offline: resolving never triggers a drain in these tests.
24
+ setOfflineState({ online: false });
25
+ await store.putRecord({
26
+ type: "npcs",
27
+ id: "n1",
28
+ rootId: "C",
29
+ resource: npc("n1", "Mine"),
30
+ updatedAt: null,
31
+ base,
32
+ });
33
+ });
34
+
35
+ afterEach(() => tearDownSync());
36
+
37
+ async function conflictOf(kind: ConflictKind, method: "PATCH" | "DELETE" = "PATCH", theirsValue: any = theirs) {
38
+ const seq = await store.enqueue({
39
+ rootId: "C",
40
+ method,
41
+ path: "npcs/n1",
42
+ body: method === "DELETE" ? undefined : mineBody,
43
+ recordType: "npcs",
44
+ recordId: "n1",
45
+ base,
46
+ });
47
+ await store.setQueueStatus(seq, "conflict");
48
+ const id = await store.addConflict({
49
+ queueSeq: seq,
50
+ kind,
51
+ recordType: "npcs",
52
+ recordId: "n1",
53
+ mine: method === "DELETE" ? null : mineBody,
54
+ theirs: theirsValue,
55
+ base,
56
+ fields: kind === "fields" ? ["name"] : [],
57
+ reason: kind === "could-not-apply" ? "bad" : undefined,
58
+ });
59
+ return { seq, id };
60
+ }
61
+
62
+ it("keep mine re-queues the original change (original body and base) with resolution {kind:'mine'}", async () => {
63
+ const { seq, id } = await conflictOf("fields");
64
+
65
+ await resolveConflict(id, { kind: "mine" });
66
+
67
+ const queue = await store.queue();
68
+ expect(queue).toHaveLength(1);
69
+ expect(queue[0]).toMatchObject({
70
+ method: "PATCH",
71
+ path: "npcs/n1",
72
+ body: mineBody,
73
+ recordType: "npcs",
74
+ recordId: "n1",
75
+ base,
76
+ resolution: { kind: "mine" },
77
+ status: "pending",
78
+ });
79
+ expect(queue[0].seq).not.toBe(seq);
80
+ expect((await store.getRecord("npcs", "n1"))?.base).toEqual(base);
81
+ expect(await store.conflicts()).toEqual([]);
82
+ });
83
+
84
+ it("per-field choices re-queue the original change with resolution {kind:'merged', choices, mergedBody}", async () => {
85
+ const { id } = await conflictOf("fields");
86
+ const mergedBody = { data: { type: "npcs", id: "n1", attributes: { description: "Hand merged" } } };
87
+ const choices = { name: "theirs", description: "merged" } as const;
88
+
89
+ await resolveConflict(id, { kind: "fields", choices, mergedBody });
90
+
91
+ const queue = await store.queue();
92
+ expect(queue).toHaveLength(1);
93
+ expect(queue[0]).toMatchObject({
94
+ method: "PATCH",
95
+ path: "npcs/n1",
96
+ body: mineBody,
97
+ base,
98
+ resolution: { kind: "merged", choices, mergedBody },
99
+ status: "pending",
100
+ });
101
+ const record = await store.getRecord("npcs", "n1");
102
+ expect(record?.resource.attributes.name).toBe("Theirs");
103
+ expect(record?.resource.attributes.description).toBe("Hand merged");
104
+ expect(await store.conflicts()).toEqual([]);
105
+ });
106
+
107
+ it("per-field choices that are all 'theirs' behave as keep theirs", async () => {
108
+ const { id } = await conflictOf("fields");
109
+
110
+ await resolveConflict(id, { kind: "fields", choices: { name: "theirs" } });
111
+
112
+ expect(await store.queue()).toEqual([]);
113
+ expect((await store.getRecord("npcs", "n1"))?.resource).toEqual(theirs);
114
+ expect(await store.conflicts()).toEqual([]);
115
+ });
116
+
117
+ it("keep theirs stores theirs and drops the entry", async () => {
118
+ const { id } = await conflictOf("fields");
119
+
120
+ await resolveConflict(id, { kind: "theirs" });
121
+
122
+ expect(await store.queue()).toEqual([]);
123
+ expect(await store.conflicts()).toEqual([]);
124
+ const record = await store.getRecord("npcs", "n1");
125
+ expect(record?.resource).toEqual(theirs);
126
+ expect(record?.base).toBeNull();
127
+ });
128
+
129
+ it("restore enqueues a POST with my body", async () => {
130
+ const { id } = await conflictOf("deleted-on-server", "PATCH", null);
131
+
132
+ await resolveConflict(id, { kind: "restore" });
133
+
134
+ const [entry] = await store.queue();
135
+ expect(entry).toMatchObject({ method: "POST", path: "npcs", body: mineBody, base: null, status: "pending" });
136
+ expect(await store.conflicts()).toEqual([]);
137
+ });
138
+
139
+ it("accept-deletion removes the local record", async () => {
140
+ const { id } = await conflictOf("deleted-on-server", "PATCH", null);
141
+
142
+ await resolveConflict(id, { kind: "accept-deletion" });
143
+
144
+ expect(await store.getRecord("npcs", "n1")).toBeUndefined();
145
+ expect(await store.queue()).toEqual([]);
146
+ expect(await store.conflicts()).toEqual([]);
147
+ });
148
+
149
+ it("delete-anyway re-queues the original DELETE carrying a resolution, so the server skips the changed-on-server check", async () => {
150
+ const { seq, id } = await conflictOf("changed-on-server", "DELETE");
151
+
152
+ await resolveConflict(id, { kind: "delete-anyway" });
153
+
154
+ const [entry] = await store.queue();
155
+ expect(entry).toMatchObject({
156
+ method: "DELETE",
157
+ path: "npcs/n1",
158
+ base,
159
+ resolution: { kind: "mine" },
160
+ status: "pending",
161
+ });
162
+ expect(entry.seq).not.toBe(seq);
163
+ expect(await store.conflicts()).toEqual([]);
164
+ });
165
+
166
+ it("discard removes the entry", async () => {
167
+ const { id } = await conflictOf("could-not-apply");
168
+
169
+ await resolveConflict(id, { kind: "discard" });
170
+
171
+ expect(await store.queue()).toEqual([]);
172
+ expect(await store.conflicts()).toEqual([]);
173
+ });
174
+
175
+ it("retry sets it pending", async () => {
176
+ const { seq, id } = await conflictOf("could-not-apply");
177
+
178
+ await resolveConflict(id, { kind: "retry" });
179
+
180
+ const [entry] = await store.queue();
181
+ expect(entry.seq).toBe(seq);
182
+ expect(entry.status).toBe("pending");
183
+ expect(await store.conflicts()).toEqual([]);
184
+ });
185
+ });
@@ -0,0 +1,364 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { getOfflineState, setOfflineState } from "../../state";
3
+ import type { DeviceStore } from "../../store/DeviceStore";
4
+ import { OfflineChangeSet } from "../models/OfflineChangeSet";
5
+ import { npc, setUpSync, tearDownSync } from "./sync-fixtures";
6
+
7
+ const { JsonApiPost } = vi.hoisted(() => ({ JsonApiPost: vi.fn() }));
8
+ vi.mock("../../../unified/JsonApiRequest", () => ({
9
+ JsonApiGet: vi.fn(),
10
+ JsonApiPost,
11
+ getApiUrl: () => "http://api.test/",
12
+ buildUrl: (endpoint: string) => `http://api.test/${endpoint}`,
13
+ }));
14
+
15
+ import { drainQueue } from "../drain-queue";
16
+
17
+ const answer = (results: any[]) => ({
18
+ ok: true,
19
+ response: 200,
20
+ data: new OfflineChangeSet().rehydrate({
21
+ jsonApi: { type: "offline-change-sets", id: "pkg", attributes: { results } },
22
+ included: [],
23
+ } as any),
24
+ });
25
+
26
+ function clearCookies() {
27
+ for (const cookie of document.cookie.split(";")) {
28
+ const name = cookie.split("=")[0].trim();
29
+ if (name) document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`;
30
+ }
31
+ }
32
+
33
+ describe("drainQueue", () => {
34
+ let store: DeviceStore;
35
+ const fetchMock = vi.fn();
36
+
37
+ beforeEach(async () => {
38
+ store = await setUpSync();
39
+ JsonApiPost.mockReset();
40
+ fetchMock.mockReset();
41
+ fetchMock.mockImplementation(async () => {
42
+ document.cookie = "token=fresh; path=/";
43
+ return { ok: true, status: 200 };
44
+ });
45
+ vi.stubGlobal("fetch", fetchMock);
46
+ clearCookies();
47
+ });
48
+
49
+ afterEach(() => {
50
+ tearDownSync();
51
+ vi.unstubAllGlobals();
52
+ clearCookies();
53
+ });
54
+
55
+ async function seedTwoEdits() {
56
+ await store.putRecord({
57
+ type: "npcs",
58
+ id: "n1",
59
+ rootId: "C",
60
+ resource: npc("n1", "Mine"),
61
+ updatedAt: null,
62
+ base: npc("n1", "Base"),
63
+ });
64
+ const first = await store.enqueue({
65
+ rootId: "C",
66
+ method: "PATCH",
67
+ path: "npcs/n1",
68
+ body: { data: npc("n1", "Mine") },
69
+ recordType: "npcs",
70
+ recordId: "n1",
71
+ base: npc("n1", "Base"),
72
+ });
73
+ const second = await store.enqueue({
74
+ rootId: "C",
75
+ method: "POST",
76
+ path: "npcs",
77
+ body: { data: npc("n2", "New") },
78
+ recordType: "npcs",
79
+ recordId: "n2",
80
+ base: null,
81
+ });
82
+ return { first, second };
83
+ }
84
+
85
+ it("refreshes the session first", async () => {
86
+ const order: string[] = [];
87
+ fetchMock.mockImplementation(async (url: string, init: RequestInit) => {
88
+ order.push(`fetch ${url} ${init?.cache}`);
89
+ document.cookie = "token=fresh; path=/";
90
+ return { ok: true, status: 200 };
91
+ });
92
+ JsonApiPost.mockImplementation(async () => {
93
+ order.push("post");
94
+ return answer([]);
95
+ });
96
+ await seedTwoEdits();
97
+
98
+ await drainQueue();
99
+
100
+ expect(order).toEqual(["fetch /offline-shell no-store", "post"]);
101
+ });
102
+
103
+ it("sends every pending entry of unlocked root records in ONE request, in seq order, through JsonApiPost with the offline-change-sets module", async () => {
104
+ const { first, second } = await seedTwoEdits();
105
+ // 30 more entries: still one request, never split.
106
+ for (let i = 0; i < 30; i++) {
107
+ await store.enqueue({
108
+ rootId: "C",
109
+ method: "PATCH",
110
+ path: `npcs/x${i}`,
111
+ body: { data: npc(`x${i}`, "X") },
112
+ recordType: "npcs",
113
+ recordId: `x${i}`,
114
+ base: null,
115
+ });
116
+ }
117
+ await store.enqueue({
118
+ rootId: "L",
119
+ method: "PATCH",
120
+ path: "npcs/l1",
121
+ body: { data: npc("l1", "L", "L") },
122
+ recordType: "npcs",
123
+ recordId: "l1",
124
+ base: null,
125
+ });
126
+ await store.setSyncState("L", { serverTime: null, locked: true, lockedFrom: null });
127
+ JsonApiPost.mockResolvedValue(answer([]));
128
+
129
+ await drainQueue();
130
+
131
+ expect(JsonApiPost).toHaveBeenCalledTimes(1);
132
+ const params = JsonApiPost.mock.calls[0][0];
133
+ expect(params.classKey.name).toBe("offline-change-sets");
134
+ expect(params.endpoint).toBe("offline-change-sets");
135
+ expect(typeof params.body.id).toBe("string");
136
+ const changes = params.body.changes;
137
+ expect(changes).toHaveLength(32);
138
+ expect(changes.map((c: any) => c.seq)).toEqual(
139
+ [...changes.map((c: any) => c.seq)].sort((a: number, b: number) => a - b),
140
+ );
141
+ expect(changes[0]).toEqual({
142
+ seq: first,
143
+ method: "PATCH",
144
+ path: "npcs/n1",
145
+ body: { data: npc("n1", "Mine") },
146
+ recordType: "npcs",
147
+ recordId: "n1",
148
+ base: npc("n1", "Base"),
149
+ });
150
+ expect(changes[1].seq).toBe(second);
151
+ expect(changes.some((c: any) => c.path === "npcs/l1")).toBe(false);
152
+ });
153
+
154
+ it("applied → entry removed and response written through to the store", async () => {
155
+ const { first } = await seedTwoEdits();
156
+ const saved = { ...npc("n1", "Merged"), attributes: { name: "Merged", updatedAt: "2026-11-01T10:00:00.000Z" } };
157
+ JsonApiPost.mockResolvedValue(answer([{ seq: first, status: "applied", response: { data: saved } }]));
158
+
159
+ const result = await drainQueue();
160
+
161
+ expect(result.applied).toBe(1);
162
+ const remaining = await store.queue();
163
+ expect(remaining.map((e) => e.seq)).not.toContain(first);
164
+ const record = await store.getRecord("npcs", "n1");
165
+ expect(record?.resource.attributes.name).toBe("Merged");
166
+ expect(record?.base).toBeNull();
167
+ });
168
+
169
+ it("conflict → a Conflict row with mine/theirs/base/merged/fields/blocks, entry status conflict", async () => {
170
+ const { first } = await seedTwoEdits();
171
+ const current = npc("n1", "Theirs");
172
+ JsonApiPost.mockResolvedValue(
173
+ answer([
174
+ {
175
+ seq: first,
176
+ status: "conflict",
177
+ conflict: "fields",
178
+ current,
179
+ merged: { name: "?" },
180
+ conflictFields: ["name"],
181
+ conflictBlocks: { description: ["b1"] },
182
+ },
183
+ ]),
184
+ );
185
+
186
+ const result = await drainQueue();
187
+
188
+ expect(result.conflicts).toBe(1);
189
+ const [conflict] = await store.conflicts();
190
+ expect(conflict).toMatchObject({
191
+ queueSeq: first,
192
+ kind: "fields",
193
+ recordType: "npcs",
194
+ recordId: "n1",
195
+ mine: { data: npc("n1", "Mine") },
196
+ theirs: current,
197
+ base: npc("n1", "Base"),
198
+ // The server's current resource with the merged attributes laid over it.
199
+ merged: { ...current, attributes: { ...current.attributes, name: "?" } },
200
+ fields: ["name"],
201
+ blocks: { description: ["b1"] },
202
+ });
203
+ expect((await store.queue()).find((e) => e.seq === first)?.status).toBe("conflict");
204
+ expect(getOfflineState().conflicts).toBe(1);
205
+ });
206
+
207
+ it("could-not-apply → a could-not-apply conflict with the detail", async () => {
208
+ const { first } = await seedTwoEdits();
209
+ JsonApiPost.mockResolvedValue(
210
+ answer([{ seq: first, status: "could-not-apply", detail: "name must not be empty" }]),
211
+ );
212
+
213
+ await drainQueue();
214
+
215
+ const [conflict] = await store.conflicts();
216
+ expect(conflict.kind).toBe("could-not-apply");
217
+ expect(conflict.reason).toBe("name must not be empty");
218
+ expect((await store.queue()).find((e) => e.seq === first)?.status).toBe("conflict");
219
+ });
220
+
221
+ it("waiting → entry stays pending", async () => {
222
+ const { first, second } = await seedTwoEdits();
223
+ JsonApiPost.mockResolvedValue(
224
+ answer([
225
+ { seq: first, status: "applied" },
226
+ { seq: second, status: "waiting" },
227
+ ]),
228
+ );
229
+
230
+ await drainQueue();
231
+
232
+ const remaining = await store.queue();
233
+ expect(remaining.map((e) => [e.seq, e.status])).toEqual([[second, "pending"]]);
234
+ });
235
+
236
+ it("a network error stops the drain and leaves every entry pending", async () => {
237
+ await seedTwoEdits();
238
+ JsonApiPost.mockImplementation(async () => {
239
+ // directFetch marks the session offline and answers locally (needs a connection).
240
+ setOfflineState({ online: false });
241
+ return { ok: false, response: 503, data: [], error: "Needs a connection" };
242
+ });
243
+
244
+ const result = await drainQueue();
245
+
246
+ expect(result.applied).toBe(0);
247
+ expect((await store.queue()).map((e) => e.status)).toEqual(["pending", "pending"]);
248
+ expect(await store.conflicts()).toEqual([]);
249
+ });
250
+
251
+ it("a package that got no answer is sent again with the same id; a new package after an answer gets a fresh id", async () => {
252
+ const { first, second } = await seedTwoEdits();
253
+ JsonApiPost.mockRejectedValueOnce(new TypeError("Failed to fetch"));
254
+ const error = vi.spyOn(console, "error").mockImplementation(() => {});
255
+
256
+ await drainQueue();
257
+ error.mockRestore();
258
+ const firstId = JsonApiPost.mock.calls[0][0].body.id;
259
+ expect((await store.queue()).map((e) => [e.status, e.packageId])).toEqual([
260
+ ["pending", firstId],
261
+ ["pending", firstId],
262
+ ]);
263
+
264
+ JsonApiPost.mockResolvedValueOnce(
265
+ answer([
266
+ { seq: first, status: "applied" },
267
+ { seq: second, status: "waiting" },
268
+ ]),
269
+ );
270
+ await drainQueue();
271
+ expect(JsonApiPost.mock.calls[1][0].body.id).toBe(firstId);
272
+ expect((await store.queue()).map((e) => [e.seq, e.status, e.packageId])).toEqual([[second, "pending", undefined]]);
273
+
274
+ JsonApiPost.mockResolvedValueOnce(answer([]));
275
+ await drainQueue();
276
+ expect(JsonApiPost).toHaveBeenCalledTimes(3);
277
+ expect(JsonApiPost.mock.calls[2][0].body.id).not.toBe(firstId);
278
+ });
279
+
280
+ it("strips each type's serverOwnedFields from the queued body before sending, never from the stored record", async () => {
281
+ tearDownSync();
282
+ store = await setUpSync({
283
+ types: [
284
+ {
285
+ module: { name: "npcs", model: class {} } as any,
286
+ fetch: "root-list",
287
+ readOnly: false,
288
+ serverOwnedFields: ["tldr", "aiStatus"],
289
+ },
290
+ ],
291
+ });
292
+ const body = { data: { ...npc("n1", "Mine"), attributes: { name: "Mine", tldr: "AI", aiStatus: "done" } } };
293
+ await store.putRecord({ type: "npcs", id: "n1", rootId: "C", resource: body.data, updatedAt: null });
294
+ await store.enqueue({
295
+ rootId: "C",
296
+ method: "PATCH",
297
+ path: "npcs/n1",
298
+ body,
299
+ recordType: "npcs",
300
+ recordId: "n1",
301
+ base: null,
302
+ });
303
+ JsonApiPost.mockResolvedValue(answer([]));
304
+
305
+ await drainQueue();
306
+
307
+ const [change] = JsonApiPost.mock.calls[0][0].body.changes;
308
+ expect(change.body.data.attributes).toEqual({ name: "Mine" });
309
+ expect((await store.queue())[0].body.data.attributes.tldr).toBe("AI");
310
+ expect((await store.getRecord("npcs", "n1"))?.resource.attributes.tldr).toBe("AI");
311
+ });
312
+
313
+ it("an applied entry never overwrites the record while another change of it still waits", async () => {
314
+ await store.putRecord({
315
+ type: "npcs",
316
+ id: "n1",
317
+ rootId: "C",
318
+ resource: npc("n1", "Latest"),
319
+ updatedAt: null,
320
+ base: npc("n1", "Base"),
321
+ });
322
+ const first = await store.enqueue({
323
+ rootId: "C",
324
+ method: "PATCH",
325
+ path: "npcs/n1",
326
+ body: { data: npc("n1", "First") },
327
+ recordType: "npcs",
328
+ recordId: "n1",
329
+ base: npc("n1", "Base"),
330
+ });
331
+ const second = await store.enqueue({
332
+ rootId: "C",
333
+ method: "PATCH",
334
+ path: "npcs/n1",
335
+ body: { data: npc("n1", "Latest") },
336
+ recordType: "npcs",
337
+ recordId: "n1",
338
+ base: npc("n1", "Base"),
339
+ });
340
+ JsonApiPost.mockResolvedValue(
341
+ answer([
342
+ { seq: first, status: "applied", response: { data: npc("n1", "First") } },
343
+ { seq: second, status: "waiting" },
344
+ ]),
345
+ );
346
+
347
+ await drainQueue();
348
+
349
+ const record = await store.getRecord("npcs", "n1");
350
+ expect(record?.resource.attributes.name).toBe("Latest");
351
+ expect(record?.base).toEqual(npc("n1", "Base"));
352
+ });
353
+
354
+ it("a sign-in that expired stops the drain and keeps every entry", async () => {
355
+ await seedTwoEdits();
356
+ fetchMock.mockImplementation(async () => ({ ok: true, status: 200 }));
357
+
358
+ const result = await drainQueue();
359
+
360
+ expect(result.stoppedForLogin).toBe(true);
361
+ expect(JsonApiPost).not.toHaveBeenCalled();
362
+ expect((await store.queue()).map((e) => e.status)).toEqual(["pending", "pending"]);
363
+ });
364
+ });