@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,399 @@
1
+ import { beforeEach, describe, expect, it } from "vitest";
2
+ import type { DeviceStore } from "../DeviceStore";
3
+ import { isRootLocked, openMemoryDatabase, openMemoryDeviceStore } from "../lifecycle";
4
+ import { DEVICE_STORE_SCHEMA_VERSION } from "../schema";
5
+ import { createSqlDeviceStore } from "../sql-device-store";
6
+
7
+ const npc = (id: string, name = "Mara") => ({
8
+ type: "npcs",
9
+ id,
10
+ attributes: { name, description: [{ id: "b1", type: "paragraph", content: [] }] },
11
+ relationships: { root: { data: { type: "roots", id: "C" } } },
12
+ });
13
+
14
+ describe("sql device store", () => {
15
+ let store: DeviceStore;
16
+
17
+ beforeEach(async () => {
18
+ store = await openMemoryDeviceStore();
19
+ });
20
+
21
+ it("putRecord then getRecord round-trips the resource JSON", async () => {
22
+ const updatedAt = new Date("2026-10-30T10:00:00.000Z");
23
+ await store.putRecord({ type: "npcs", id: "n1", rootId: "C", resource: npc("n1"), updatedAt });
24
+
25
+ const record = await store.getRecord("npcs", "n1");
26
+
27
+ expect(record).toEqual({
28
+ type: "npcs",
29
+ id: "n1",
30
+ rootId: "C",
31
+ resource: npc("n1"),
32
+ updatedAt,
33
+ base: null,
34
+ localOnly: false,
35
+ });
36
+ expect(record?.updatedAt).toBeInstanceOf(Date);
37
+ expect(await store.getRecord("npcs", "missing")).toBeUndefined();
38
+
39
+ await store.putRecord({
40
+ type: "npcs",
41
+ id: "n1",
42
+ rootId: "C",
43
+ resource: npc("n1", "Mara II"),
44
+ updatedAt: null,
45
+ base: npc("n1"),
46
+ localOnly: true,
47
+ });
48
+ const replaced = await store.getRecord("npcs", "n1");
49
+ expect(replaced?.resource.attributes.name).toBe("Mara II");
50
+ expect(replaced?.base).toEqual(npc("n1"));
51
+ expect(replaced?.localOnly).toBe(true);
52
+ expect(replaced?.updatedAt).toBeNull();
53
+ });
54
+
55
+ it("listRecords filters by type and rootId", async () => {
56
+ await store.putRecord({ type: "npcs", id: "n1", rootId: "C", resource: npc("n1"), updatedAt: null });
57
+ await store.putRecord({ type: "npcs", id: "n2", rootId: "C", resource: npc("n2"), updatedAt: null });
58
+ await store.putRecord({ type: "npcs", id: "n3", rootId: "D", resource: npc("n3"), updatedAt: null });
59
+ await store.putRecord({
60
+ type: "locations",
61
+ id: "l1",
62
+ rootId: "C",
63
+ resource: { type: "locations", id: "l1" },
64
+ updatedAt: null,
65
+ });
66
+
67
+ expect((await store.listRecords({ type: "npcs" })).map((r) => r.id).sort()).toEqual(["n1", "n2", "n3"]);
68
+ expect((await store.listRecords({ type: "npcs", rootId: "C" })).map((r) => r.id).sort()).toEqual(["n1", "n2"]);
69
+ expect((await store.listRecords({ type: "npcs", ids: ["n1", "n3", "l1"] })).map((r) => r.id).sort()).toEqual([
70
+ "n1",
71
+ "n3",
72
+ ]);
73
+ expect(await store.listRecords({ type: "npcs", ids: [] })).toEqual([]);
74
+ });
75
+
76
+ it("deleteRecord removes the record and every link from or to it", async () => {
77
+ await store.putRecord({ type: "npcs", id: "n1", rootId: "C", resource: npc("n1"), updatedAt: null });
78
+ await store.putRecord({ type: "npcs", id: "n2", rootId: "C", resource: npc("n2"), updatedAt: null });
79
+ await store.putLink({
80
+ fromType: "npcs",
81
+ fromId: "n1",
82
+ toType: "factions",
83
+ toId: "f1",
84
+ kind: "MEMBER_OF",
85
+ props: {},
86
+ });
87
+ await store.putLink({
88
+ fromType: "scenes",
89
+ fromId: "s1",
90
+ toType: "npcs",
91
+ toId: "n1",
92
+ kind: "FEATURES",
93
+ props: { x: 3 },
94
+ });
95
+ await store.putLink({
96
+ fromType: "npcs",
97
+ fromId: "n2",
98
+ toType: "factions",
99
+ toId: "f1",
100
+ kind: "MEMBER_OF",
101
+ props: {},
102
+ });
103
+
104
+ await store.deleteRecord("npcs", "n1");
105
+
106
+ expect(await store.getRecord("npcs", "n1")).toBeUndefined();
107
+ expect(await store.linksFrom("n1")).toEqual([]);
108
+ expect(await store.linksTo("n1")).toEqual([]);
109
+ expect(await store.linksTo("f1")).toEqual([
110
+ { fromType: "npcs", fromId: "n2", toType: "factions", toId: "f1", kind: "MEMBER_OF", props: {} },
111
+ ]);
112
+ });
113
+
114
+ it("replaceRootLinks replaces only that root record's links", async () => {
115
+ await store.replaceRootLinks("C", [
116
+ { fromType: "npcs", fromId: "n1", toType: "factions", toId: "f1", kind: "MEMBER_OF", props: {} },
117
+ { fromType: "scenes", fromId: "s1", toType: "npcs", toId: "n1", kind: "FEATURES", props: { x: 1, y: 2 } },
118
+ ]);
119
+ await store.replaceRootLinks("D", [
120
+ { fromType: "npcs", fromId: "n9", toType: "factions", toId: "f9", kind: "MEMBER_OF", props: {} },
121
+ ]);
122
+
123
+ await store.replaceRootLinks("C", [
124
+ { fromType: "npcs", fromId: "n1", toType: "locations", toId: "l1", kind: "LIVES_IN", props: {} },
125
+ ]);
126
+
127
+ expect(await store.linksFrom("n1")).toEqual([
128
+ { fromType: "npcs", fromId: "n1", toType: "locations", toId: "l1", kind: "LIVES_IN", props: {} },
129
+ ]);
130
+ expect(await store.linksTo("n1")).toEqual([]);
131
+ expect(await store.linksFrom("n9", "MEMBER_OF")).toHaveLength(1);
132
+ expect(await store.linksFrom("n9", "OTHER")).toHaveLength(0);
133
+
134
+ await store.deleteLink({ fromId: "n9", toId: "f9", kind: "MEMBER_OF" });
135
+ expect(await store.linksFrom("n9")).toEqual([]);
136
+ });
137
+
138
+ it("enqueue assigns increasing seq; queue() returns seq order; base round-trips", async () => {
139
+ const first = await store.enqueue({
140
+ rootId: "C",
141
+ method: "POST",
142
+ path: "npcs",
143
+ body: { data: npc("n1") },
144
+ recordType: "npcs",
145
+ recordId: "n1",
146
+ base: null,
147
+ });
148
+ const second = await store.enqueue({
149
+ rootId: "C",
150
+ method: "PATCH",
151
+ path: "npcs/n2",
152
+ body: { data: npc("n2", "Edited") },
153
+ recordType: "npcs",
154
+ recordId: "n2",
155
+ base: npc("n2"),
156
+ resolution: { kind: "merged" },
157
+ });
158
+
159
+ expect(second).toBeGreaterThan(first);
160
+
161
+ await store.setQueueStatus(first, "sending");
162
+ const entries = await store.queue();
163
+ expect(entries.map((e) => e.seq)).toEqual([first, second]);
164
+ expect(entries[0]).toMatchObject({ seq: first, method: "POST", path: "npcs", base: null, status: "sending" });
165
+ expect(entries[0].resolution).toBeUndefined();
166
+ expect(entries[1]).toMatchObject({
167
+ seq: second,
168
+ rootId: "C",
169
+ body: { data: npc("n2", "Edited") },
170
+ base: npc("n2"),
171
+ resolution: { kind: "merged" },
172
+ status: "pending",
173
+ });
174
+ expect(entries[1].createdAt).toBeInstanceOf(Date);
175
+ expect((await store.queue({ status: "pending" })).map((e) => e.seq)).toEqual([second]);
176
+
177
+ await store.removeQueueEntry(first);
178
+ expect((await store.queue()).map((e) => e.seq)).toEqual([second]);
179
+ const third = await store.enqueue({ rootId: "C", method: "DELETE", path: "npcs/n3", base: npc("n3") });
180
+ expect(third).toBeGreaterThan(second);
181
+ });
182
+
183
+ it("conflicts round-trip and sync state round-trips dates", async () => {
184
+ const seq = await store.enqueue({ rootId: "C", method: "PATCH", path: "npcs/n1", base: npc("n1") });
185
+ const id = await store.addConflict({
186
+ queueSeq: seq,
187
+ kind: "fields",
188
+ recordType: "npcs",
189
+ recordId: "n1",
190
+ mine: npc("n1", "Mine"),
191
+ theirs: npc("n1", "Theirs"),
192
+ base: npc("n1"),
193
+ fields: ["name"],
194
+ blocks: { description: ["b1"] },
195
+ });
196
+ expect(await store.conflicts()).toEqual([
197
+ {
198
+ id,
199
+ queueSeq: seq,
200
+ kind: "fields",
201
+ recordType: "npcs",
202
+ recordId: "n1",
203
+ mine: npc("n1", "Mine"),
204
+ theirs: npc("n1", "Theirs"),
205
+ base: npc("n1"),
206
+ fields: ["name"],
207
+ blocks: { description: ["b1"] },
208
+ },
209
+ ]);
210
+ await store.removeConflict(id);
211
+ expect(await store.conflicts()).toEqual([]);
212
+
213
+ expect(await store.getSyncState("C")).toEqual({ serverTime: null, locked: false, lockedFrom: null });
214
+ const serverTime = new Date("2026-11-01T08:00:00.000Z");
215
+ const lockedFrom = new Date("2026-11-20T00:00:00.000Z");
216
+ await store.setSyncState("C", { serverTime, locked: false, lockedFrom });
217
+ const state = await store.getSyncState("C");
218
+ expect(state).toEqual({ serverTime, locked: false, lockedFrom });
219
+ expect(state.serverTime).toBeInstanceOf(Date);
220
+ });
221
+
222
+ it("skipped records round-trip per root record, are replaced as a whole, and go with removeRoot", async () => {
223
+ const updatedAt = new Date("2026-10-31T10:00:00.000Z");
224
+ expect(await store.skippedRecords("C")).toEqual([]);
225
+
226
+ await store.setSkippedRecords("C", [{ type: "templates", id: "t1", updatedAt }]);
227
+ await store.setSkippedRecords("D", [{ type: "npcs", id: "n1", updatedAt }]);
228
+ expect(await store.skippedRecords("C")).toEqual([{ type: "templates", id: "t1", updatedAt }]);
229
+
230
+ await store.setSkippedRecords("C", [{ type: "npcs", id: "n2", updatedAt }]);
231
+ expect(await store.skippedRecords("C")).toEqual([{ type: "npcs", id: "n2", updatedAt }]);
232
+
233
+ await store.removeRoot("C");
234
+ expect(await store.skippedRecords("C")).toEqual([]);
235
+ expect(await store.skippedRecords("D")).toHaveLength(1);
236
+ });
237
+
238
+ it("removeRoot removes everything of that root record only", async () => {
239
+ for (const rootId of ["C", "D"]) {
240
+ const id = `n-${rootId}`;
241
+ await store.putRecord({ type: "npcs", id, rootId, resource: npc(id), updatedAt: null });
242
+ await store.replaceRootLinks(rootId, [
243
+ { fromType: "npcs", fromId: id, toType: "factions", toId: `f-${rootId}`, kind: "MEMBER_OF", props: {} },
244
+ ]);
245
+ await store.setSyncState(rootId, { serverTime: new Date(), locked: false, lockedFrom: null });
246
+ }
247
+ const seq = await store.enqueue({ rootId: "D", method: "PATCH", path: "npcs/n-D", base: npc("n-D") });
248
+ await store.addConflict({ queueSeq: seq, kind: "fields", mine: {}, theirs: {}, base: {}, fields: [] });
249
+
250
+ await store.removeRoot("C");
251
+
252
+ expect(await store.listRecords({ type: "npcs" })).toHaveLength(1);
253
+ expect(await store.getRecord("npcs", "n-D")).toBeDefined();
254
+ expect(await store.linksFrom("n-C")).toEqual([]);
255
+ expect(await store.linksFrom("n-D")).toHaveLength(1);
256
+ expect((await store.getSyncState("C")).serverTime).toBeNull();
257
+ expect((await store.getSyncState("D")).serverTime).not.toBeNull();
258
+ expect(await store.queue()).toHaveLength(1);
259
+ expect(await store.conflicts()).toHaveLength(1);
260
+
261
+ // A root record with waiting changes is kept whole.
262
+ await store.removeRoot("D");
263
+ expect(await store.getRecord("npcs", "n-D")).toBeDefined();
264
+ expect(await store.queue()).toHaveLength(1);
265
+
266
+ await store.removeQueueEntry(seq);
267
+ await store.removeRoot("D");
268
+ expect(await store.listRecords({ type: "npcs" })).toEqual([]);
269
+ expect(await store.linksFrom("n-D")).toEqual([]);
270
+ expect(await store.conflicts()).toEqual([]);
271
+ expect((await store.getSyncState("D")).serverTime).toBeNull();
272
+ });
273
+
274
+ it("a shared record (an artwork) used by two root records is listed for both and survives removing one of them", async () => {
275
+ const art = { type: "artworks", id: "a1", attributes: { name: "Map" } };
276
+ await store.putRecord({ type: "artworks", id: "a1", rootId: "C", resource: art, updatedAt: null });
277
+ await store.putRecord({ type: "artworks", id: "a1", rootId: "D", resource: art, updatedAt: null });
278
+ await store.putRecord({ type: "npcs", id: "n-C", rootId: "C", resource: npc("n-C"), updatedAt: null });
279
+ await store.putLink({ fromType: "npcs", fromId: "n-C", toType: "artworks", toId: "a1", kind: "HAS", props: {} });
280
+
281
+ expect((await store.listRecords({ type: "artworks", rootId: "C" })).map((r) => r.id)).toEqual(["a1"]);
282
+ expect((await store.listRecords({ type: "artworks", rootId: "D" })).map((r) => r.id)).toEqual(["a1"]);
283
+ expect((await store.getRecord("artworks", "a1"))?.rootId).toBe("C");
284
+
285
+ await store.removeRoot("C");
286
+
287
+ const kept = await store.getRecord("artworks", "a1");
288
+ expect(kept?.rootId).toBe("D");
289
+ expect((await store.listRecords({ type: "artworks", rootId: "D" })).map((r) => r.id)).toEqual(["a1"]);
290
+ expect(await store.listRecords({ type: "artworks", rootId: "C" })).toEqual([]);
291
+ expect(await store.getRecord("npcs", "n-C")).toBeUndefined();
292
+
293
+ await store.removeRoot("D");
294
+ expect(await store.getRecord("artworks", "a1")).toBeUndefined();
295
+ });
296
+
297
+ it("removeRecordFromRoot deletes the record only when no root record keeps it", async () => {
298
+ const art = { type: "artworks", id: "a1", attributes: { name: "Map" } };
299
+ await store.putRecord({ type: "artworks", id: "a1", rootId: "C", resource: art, updatedAt: null });
300
+ await store.putRecord({ type: "artworks", id: "a1", rootId: "D", resource: art, updatedAt: null });
301
+
302
+ await store.removeRecordFromRoot("artworks", "a1", "C");
303
+ expect((await store.getRecord("artworks", "a1"))?.rootId).toBe("D");
304
+ expect(await store.listRecords({ type: "artworks", rootId: "C" })).toEqual([]);
305
+
306
+ await store.removeRecordFromRoot("artworks", "a1", "D");
307
+ expect(await store.getRecord("artworks", "a1")).toBeUndefined();
308
+ });
309
+
310
+ it("setQueuePackage stores and clears the package id of queue entries", async () => {
311
+ const first = await store.enqueue({ rootId: "C", method: "PATCH", path: "npcs/n1", base: null });
312
+ const second = await store.enqueue({ rootId: "C", method: "PATCH", path: "npcs/n2", base: null });
313
+
314
+ await store.setQueuePackage([first, second], "pkg-1");
315
+ expect((await store.queue()).map((e) => e.packageId)).toEqual(["pkg-1", "pkg-1"]);
316
+
317
+ await store.setQueuePackage([first], null);
318
+ expect((await store.queue()).map((e) => e.packageId)).toEqual([undefined, "pkg-1"]);
319
+ });
320
+
321
+ it("a queue entry's resolution (kind, choices, mergedBody) round-trips", async () => {
322
+ const resolution = {
323
+ kind: "merged" as const,
324
+ choices: { name: "mine" as const, description: "merged" as const, title: "theirs" as const },
325
+ mergedBody: { data: { type: "npcs", id: "n1", attributes: { description: [{ id: "b1" }] } } },
326
+ };
327
+ await store.enqueue({ rootId: "C", method: "PATCH", path: "npcs/n1", base: null, resolution });
328
+
329
+ expect((await store.queue())[0].resolution).toEqual(resolution);
330
+ });
331
+
332
+ it("recreates a device file of an older schema version", async () => {
333
+ const database = await openMemoryDatabase();
334
+ await database.exec(`CREATE TABLE records (type TEXT, id TEXT, campaign_id TEXT, resource TEXT)`);
335
+ await database.exec(`INSERT INTO records VALUES ('npcs', 'old', 'C', '{}')`);
336
+ await database.exec(`PRAGMA user_version = 1`);
337
+
338
+ const upgraded = await createSqlDeviceStore(database.exec);
339
+
340
+ expect(await upgraded.getRecord("npcs", "old")).toBeUndefined();
341
+ await upgraded.putRecord({ type: "npcs", id: "n1", rootId: "C", resource: npc("n1"), updatedAt: null });
342
+ expect((await upgraded.listRecords({ type: "npcs", rootId: "C" })).map((r) => r.id)).toEqual(["n1"]);
343
+ expect((await database.exec(`PRAGMA user_version`))[0].user_version).toBe(DEVICE_STORE_SCHEMA_VERSION);
344
+ });
345
+
346
+ it("recreates a layout-2 device file (campaign_id columns, record_campaigns) with the root layout", async () => {
347
+ const database = await openMemoryDatabase();
348
+ await database.exec(
349
+ `CREATE TABLE records (type TEXT, id TEXT, campaign_id TEXT NOT NULL, resource TEXT, PRIMARY KEY (type, id))`,
350
+ );
351
+ await database.exec(`CREATE TABLE record_campaigns (type TEXT, id TEXT, campaign_id TEXT)`);
352
+ await database.exec(`CREATE TABLE sync_state (campaign_id TEXT PRIMARY KEY, server_time TEXT)`);
353
+ await database.exec(`INSERT INTO records VALUES ('npcs', 'old', 'C', '{}')`);
354
+ await database.exec(`PRAGMA user_version = 2`);
355
+
356
+ const upgraded = await createSqlDeviceStore(database.exec);
357
+
358
+ expect(await upgraded.getRecord("npcs", "old")).toBeUndefined();
359
+ const tables = (await database.exec(`SELECT name FROM sqlite_master WHERE type = 'table'`)).map((r) => r.name);
360
+ expect(tables).not.toContain("record_campaigns");
361
+ expect(tables).toContain("record_roots");
362
+ await upgraded.putRecord({ type: "npcs", id: "n1", rootId: "C", resource: npc("n1"), updatedAt: null });
363
+ await upgraded.setSyncState("C", { serverTime: null, locked: false, lockedFrom: null });
364
+ expect((await upgraded.listRecords({ type: "npcs", rootId: "C" })).map((r) => r.id)).toEqual(["n1"]);
365
+ expect((await database.exec(`PRAGMA user_version`))[0].user_version).toBe(DEVICE_STORE_SCHEMA_VERSION);
366
+ });
367
+
368
+ it("transaction rolls back on throw", async () => {
369
+ await store.putRecord({ type: "npcs", id: "n1", rootId: "C", resource: npc("n1"), updatedAt: null });
370
+
371
+ await expect(
372
+ store.transaction(async (tx) => {
373
+ await tx.putRecord({ type: "npcs", id: "n2", rootId: "C", resource: npc("n2"), updatedAt: null });
374
+ await tx.deleteRecord("npcs", "n1");
375
+ await tx.enqueue({ rootId: "C", method: "DELETE", path: "npcs/n1", base: npc("n1") });
376
+ throw new Error("boom");
377
+ }),
378
+ ).rejects.toThrow("boom");
379
+
380
+ expect(await store.getRecord("npcs", "n1")).toBeDefined();
381
+ expect(await store.getRecord("npcs", "n2")).toBeUndefined();
382
+ expect(await store.queue()).toEqual([]);
383
+
384
+ const result = await store.transaction(async (tx) => {
385
+ await tx.putRecord({ type: "npcs", id: "n2", rootId: "C", resource: npc("n2"), updatedAt: null });
386
+ return "done";
387
+ });
388
+ expect(result).toBe("done");
389
+ expect(await store.getRecord("npcs", "n2")).toBeDefined();
390
+ });
391
+
392
+ it("isRootLocked is true when locked, or when lockedFrom is before now", () => {
393
+ const now = new Date("2026-11-01T12:00:00.000Z");
394
+ expect(isRootLocked({ locked: true, lockedFrom: null }, now)).toBe(true);
395
+ expect(isRootLocked({ locked: false, lockedFrom: null }, now)).toBe(false);
396
+ expect(isRootLocked({ locked: false, lockedFrom: new Date("2026-11-01T11:59:59.000Z") }, now)).toBe(true);
397
+ expect(isRootLocked({ locked: false, lockedFrom: new Date("2026-11-02T00:00:00.000Z") }, now)).toBe(false);
398
+ });
399
+ });