@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,112 @@
1
+ import type { OfflineChangeResolution } from "../sync/models/OfflineChangeSetInterface";
2
+ import type { OfflineMethod } from "../types";
3
+
4
+ export type StoredRecord = {
5
+ type: string;
6
+ id: string;
7
+ rootId: string;
8
+ resource: any;
9
+ updatedAt: Date | null;
10
+ base: any | null;
11
+ localOnly: boolean;
12
+ };
13
+
14
+ export type StoredLink = {
15
+ fromType: string;
16
+ fromId: string;
17
+ toType: string;
18
+ toId: string;
19
+ kind: string;
20
+ props: Record<string, unknown>;
21
+ };
22
+
23
+ export type QueueStatus = "pending" | "sending" | "conflict" | "failed";
24
+
25
+ /** The `resolution` of a server-side `OfflineChange` (C1b): kind, per-field choices, merged body. */
26
+ export type QueueResolution = OfflineChangeResolution;
27
+
28
+ export type QueueEntry = {
29
+ seq: number;
30
+ rootId: string;
31
+ method: OfflineMethod;
32
+ path: string;
33
+ body?: any;
34
+ recordType?: string;
35
+ recordId?: string;
36
+ base: any | null;
37
+ resolution?: QueueResolution;
38
+ createdAt: Date;
39
+ status: QueueStatus;
40
+ /**
41
+ * The id of the package (`offline-change-sets`) the entry was last sent in, kept until the
42
+ * server answers for it: a package that never got its answer is sent again with the same id,
43
+ * so the server applies it once.
44
+ */
45
+ packageId?: string;
46
+ };
47
+
48
+ export type ConflictKind = "fields" | "deleted-on-server" | "changed-on-server" | "could-not-apply";
49
+
50
+ export type Conflict = {
51
+ id: string;
52
+ queueSeq: number;
53
+ kind: ConflictKind;
54
+ recordType?: string;
55
+ recordId?: string;
56
+ mine: any;
57
+ theirs: any;
58
+ base: any;
59
+ /** The merge the server settled, as a resource: its current resource with the merged attributes. */
60
+ merged?: any;
61
+ fields: string[];
62
+ blocks?: Record<string, string[]>;
63
+ reason?: string;
64
+ };
65
+
66
+ export type SyncState = { serverTime: Date | null; locked: boolean; lockedFrom: Date | null };
67
+
68
+ /** A record a sync could not read (403/404, or absent from the answer), with its `updatedAt` then. */
69
+ export type SkippedRecord = { type: string; id: string; updatedAt: Date };
70
+
71
+ export interface DeviceStore {
72
+ getRecord(type: string, id: string): Promise<StoredRecord | undefined>;
73
+ /** With `rootId`: every record that root record uses, shared records included. */
74
+ listRecords(filter: { type: string; rootId?: string; ids?: string[] }): Promise<StoredRecord[]>;
75
+ putRecord(
76
+ record: Omit<StoredRecord, "base" | "localOnly"> & { base?: any | null; localOnly?: boolean },
77
+ ): Promise<void>;
78
+ /** Removes the record (every root record's copy) and every link from or to it. */
79
+ deleteRecord(type: string, id: string): Promise<void>;
80
+ /**
81
+ * The record leaves `rootId`. A shared record (an artwork, a template) that another
82
+ * root record still uses is kept for that root record; it is deleted only when no root record keeps it.
83
+ */
84
+ removeRecordFromRoot(type: string, id: string, rootId: string): Promise<void>;
85
+ linksFrom(fromId: string, kind?: string): Promise<StoredLink[]>;
86
+ linksTo(toId: string, kind?: string): Promise<StoredLink[]>;
87
+ replaceRootLinks(rootId: string, links: StoredLink[]): Promise<void>;
88
+ putLink(link: StoredLink): Promise<void>;
89
+ deleteLink(link: Pick<StoredLink, "fromId" | "toId" | "kind">): Promise<void>;
90
+ enqueue(entry: Omit<QueueEntry, "seq" | "createdAt" | "status">): Promise<number>;
91
+ queue(filter?: { status?: QueueStatus }): Promise<QueueEntry[]>;
92
+ setQueueStatus(seq: number, status: QueueStatus): Promise<void>;
93
+ /** Stores (or with `null` clears) the package id of the given entries. */
94
+ setQueuePackage(seqs: number[], packageId: string | null): Promise<void>;
95
+ removeQueueEntry(seq: number): Promise<void>;
96
+ addConflict(c: Omit<Conflict, "id">): Promise<string>;
97
+ conflicts(): Promise<Conflict[]>;
98
+ removeConflict(id: string): Promise<void>;
99
+ getSyncState(rootId: string): Promise<SyncState>;
100
+ /** The store writes ISO strings and parses them back with `new Date`. */
101
+ setSyncState(rootId: string, s: SyncState): Promise<void>;
102
+ /** The records the last sync of `rootId` skipped: asked for again only once they change. */
103
+ skippedRecords(rootId: string): Promise<SkippedRecord[]>;
104
+ /** Replaces the skipped records of `rootId`. */
105
+ setSkippedRecords(rootId: string, records: SkippedRecord[]): Promise<void>;
106
+ /**
107
+ * Records, links, queue, conflicts, sync_state, skipped records — kept only when its queue is empty. A shared
108
+ * record another root record still uses is kept for that root record.
109
+ */
110
+ removeRoot(rootId: string): Promise<void>;
111
+ transaction<T>(fn: (store: DeviceStore) => Promise<T>): Promise<T>;
112
+ }
@@ -0,0 +1,343 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi, type MockInstance } from "vitest";
2
+ import {
3
+ blockOfflineStore,
4
+ configureOffline,
5
+ DeviceStoreUnavailableError,
6
+ getOfflineStore,
7
+ isOfflineConfigured,
8
+ OfflineUnavailableError,
9
+ resetOfflineConfig,
10
+ setDeviceStoreOpener,
11
+ } from "../../config";
12
+ import { getOfflineState, resetOfflineState } from "../../state";
13
+ import { NeedsConnectionError, type OfflineConfig } from "../../types";
14
+ import {
15
+ deleteDeviceStore,
16
+ hasUnsentChanges,
17
+ onUserSignedIn,
18
+ openDeviceStore,
19
+ openMemoryDatabase,
20
+ setDeviceStoreBackend,
21
+ type OfflineDirectory,
22
+ } from "../lifecycle";
23
+ import { fakeCacheStorage } from "../../sync/__tests__/sync-fixtures";
24
+
25
+ /** An in-memory stand-in for the OPFS root directory. */
26
+ const fakeDirectory = () => {
27
+ const entries = new Set<string>();
28
+ const directory: OfflineDirectory = {
29
+ async *keys() {
30
+ for (const name of [...entries]) yield name;
31
+ },
32
+ async getFileHandle(name, options) {
33
+ if (!entries.has(name)) {
34
+ if (!options?.create) throw Object.assign(new Error(name), { name: "NotFoundError" });
35
+ entries.add(name);
36
+ }
37
+ return {};
38
+ },
39
+ async removeEntry(name) {
40
+ if (!entries.delete(name)) throw Object.assign(new Error(name), { name: "NotFoundError" });
41
+ },
42
+ };
43
+ return { entries, directory };
44
+ };
45
+
46
+ describe("device store lifecycle", () => {
47
+ let entries: Set<string>;
48
+ let opened: string[];
49
+ let persisted: number;
50
+
51
+ beforeEach(() => {
52
+ const fake = fakeDirectory();
53
+ entries = fake.entries;
54
+ opened = [];
55
+ persisted = 0;
56
+ setDeviceStoreBackend({
57
+ getDirectory: async () => fake.directory,
58
+ openDatabase: async (_userId, fileName) => {
59
+ opened.push(fileName);
60
+ const created = !entries.has(fileName);
61
+ entries.add(fileName);
62
+ const database = await openMemoryDatabase();
63
+ return { ...database, created };
64
+ },
65
+ requestPersistence: vi.fn(async () => {
66
+ persisted += 1;
67
+ }),
68
+ });
69
+ });
70
+
71
+ afterEach(() => {
72
+ setDeviceStoreBackend(null);
73
+ });
74
+
75
+ it("keeps another user's database that has unsent changes", async () => {
76
+ const storeA = await openDeviceStore("user-a");
77
+ await storeA.enqueue({ rootId: "C", method: "PATCH", path: "npcs/n1", base: { type: "npcs", id: "n1" } });
78
+ // User A's sign-in expired without a logout; user B signs in on the same browser.
79
+
80
+ await onUserSignedIn("user-b");
81
+ await openDeviceStore("user-b");
82
+
83
+ expect(entries.has("nja-offline-user-a.sqlite3")).toBe(true);
84
+ expect(await hasUnsentChanges("user-a")).toBe(true);
85
+ expect(opened).toEqual(["nja-offline-user-a.sqlite3", "nja-offline-user-b.sqlite3"]);
86
+ });
87
+
88
+ it("deletes another user's database with an empty queue", async () => {
89
+ const storeA = await openDeviceStore("user-a");
90
+ const seq = await storeA.enqueue({ rootId: "C", method: "DELETE", path: "npcs/n1", base: null });
91
+ await storeA.putRecord({ type: "npcs", id: "n2", rootId: "C", resource: {}, updatedAt: null });
92
+ await storeA.removeQueueEntry(seq);
93
+ expect(await hasUnsentChanges("user-a")).toBe(false);
94
+
95
+ await onUserSignedIn("user-b");
96
+
97
+ expect([...entries].filter((name) => name.includes("user-a"))).toEqual([]);
98
+ });
99
+
100
+ describe("the artwork images cache", () => {
101
+ afterEach(() => vi.unstubAllGlobals());
102
+
103
+ it("is deleted when a different user signs in, and kept when the same user signs in again", async () => {
104
+ const cache = fakeCacheStorage(["wyrdli-offline-1", "wyrdli-images"]);
105
+ vi.stubGlobal("caches", cache.storage);
106
+
107
+ await onUserSignedIn("user-a");
108
+ // user-a's sync downloads their artwork.
109
+ await (await cache.storage.open("wyrdli-images")).put("http://api.test/artworks/a1/image", {});
110
+ await onUserSignedIn("user-a");
111
+ expect(cache.caches.get("wyrdli-images")?.has("http://api.test/artworks/a1/image")).toBe(true);
112
+
113
+ await onUserSignedIn("user-b");
114
+ expect(cache.caches.has("wyrdli-images")).toBe(false);
115
+ expect(cache.caches.has("wyrdli-offline-1")).toBe(true);
116
+ });
117
+
118
+ it("is deleted when a different user signs in while the first user's database has unsent changes", async () => {
119
+ const cache = fakeCacheStorage(["wyrdli-offline-1", "wyrdli-images"]);
120
+ vi.stubGlobal("caches", cache.storage);
121
+ await onUserSignedIn("user-a");
122
+ const storeA = await openDeviceStore("user-a");
123
+ await storeA.enqueue({ rootId: "C", method: "PATCH", path: "npcs/n1", base: null });
124
+
125
+ await onUserSignedIn("user-b");
126
+
127
+ expect(cache.caches.has("wyrdli-images")).toBe(false);
128
+ expect(entries.has("nja-offline-user-a.sqlite3")).toBe(true);
129
+ });
130
+ });
131
+
132
+ it("never opens a file for a user other than the one asked", async () => {
133
+ entries.add("nja-offline-user-a.sqlite3");
134
+ entries.add("nja-offline-user-a.pending");
135
+ entries.add(".nja-offline-user-c");
136
+
137
+ const store = await openDeviceStore("user-b");
138
+ expect(await openDeviceStore("user-b")).toBe(store);
139
+ expect(await hasUnsentChanges("user-a")).toBe(true);
140
+ await onUserSignedIn("user-b");
141
+
142
+ expect(opened).toEqual(["nja-offline-user-b.sqlite3"]);
143
+ expect(entries.has("nja-offline-user-a.sqlite3")).toBe(true);
144
+ expect(entries.has(".nja-offline-user-c")).toBe(false);
145
+ });
146
+
147
+ it("requests persistent storage only when the file is first created", async () => {
148
+ await openDeviceStore("user-a");
149
+ await deleteDeviceStore("user-a");
150
+ expect(persisted).toBe(1);
151
+ expect(entries.size).toBe(0);
152
+
153
+ entries.add("nja-offline-user-a.sqlite3");
154
+ await openDeviceStore("user-a");
155
+ expect(persisted).toBe(1);
156
+ });
157
+
158
+ it("keeps the unsent-changes mark when a transaction that emptied the queue rolls back", async () => {
159
+ const store = await openDeviceStore("user-a");
160
+ const seq = await store.enqueue({ rootId: "C", method: "PATCH", path: "npcs/n1", base: null });
161
+
162
+ await expect(
163
+ store.transaction(async (tx) => {
164
+ await tx.removeQueueEntry(seq);
165
+ throw new Error("boom");
166
+ }),
167
+ ).rejects.toThrow("boom");
168
+
169
+ expect(entries.has("nja-offline-user-a.pending")).toBe(true);
170
+ expect(await hasUnsentChanges("user-a")).toBe(true);
171
+ });
172
+
173
+ describe("getOfflineStore", () => {
174
+ const config = (userId: string): OfflineConfig => ({
175
+ getUserId: () => userId,
176
+ rootModule: { name: "roots" } as any,
177
+ types: [],
178
+ routes: [],
179
+ listSyncRoots: async () => [],
180
+ shellPath: () => "/offline-shell",
181
+ syncIntervalMs: 300000,
182
+ });
183
+
184
+ afterEach(() => resetOfflineConfig());
185
+
186
+ it("a store that cannot be opened (a second tab holds the SQLite file) is a needs-connection error", async () => {
187
+ configureOffline(config("user-a"));
188
+ setDeviceStoreOpener(async () => {
189
+ throw new Error("NoModificationAllowedError: Access Handles cannot be created");
190
+ });
191
+
192
+ const error = await getOfflineStore().catch((caught) => caught);
193
+
194
+ expect(error).toBeInstanceOf(DeviceStoreUnavailableError);
195
+ expect(error).toBeInstanceOf(NeedsConnectionError);
196
+ expect(error.status).toBe(503);
197
+ // A real failure on a browser that can keep the copy never switches offline mode off.
198
+ expect(error).not.toBeInstanceOf(OfflineUnavailableError);
199
+ expect(getOfflineState().available).toBe(true);
200
+ expect(isOfflineConfigured()).toBe(true);
201
+ });
202
+
203
+ it("a blocked user's store (logged out) is never opened again until that user signs in", async () => {
204
+ configureOffline(config("user-a"));
205
+ const opener = vi.fn(async () => openDeviceStore("user-a"));
206
+ setDeviceStoreOpener(opener);
207
+
208
+ blockOfflineStore("user-a");
209
+ await expect(getOfflineStore()).rejects.toBeInstanceOf(DeviceStoreUnavailableError);
210
+ await expect(openDeviceStore("user-a")).rejects.toBeInstanceOf(DeviceStoreUnavailableError);
211
+ expect(opener).not.toHaveBeenCalled();
212
+ expect(opened).toEqual([]);
213
+
214
+ await onUserSignedIn("user-a");
215
+ await getOfflineStore();
216
+ expect(opener).toHaveBeenCalledTimes(1);
217
+ expect(opened).toEqual(["nja-offline-user-a.sqlite3"]);
218
+ });
219
+ });
220
+
221
+ describe("a browser that cannot keep the device copy", () => {
222
+ const config: OfflineConfig = {
223
+ getUserId: () => "user-a",
224
+ rootModule: { name: "roots", model: class {} },
225
+ types: [],
226
+ routes: [],
227
+ listSyncRoots: async () => [],
228
+ shellPath: () => "/offline-shell",
229
+ syncIntervalMs: 300000,
230
+ };
231
+
232
+ /** A device worker whose "open" fails the way SQLite does without the OPFS sync-access handles. */
233
+ const workers: string[] = [];
234
+ class OpfsMissingWorker {
235
+ onmessage: ((event: { data: unknown }) => void) | null = null;
236
+ onerror: ((event: unknown) => void) | null = null;
237
+ constructor(url: string) {
238
+ workers.push(url);
239
+ }
240
+ postMessage(message: { id: number }) {
241
+ queueMicrotask(() =>
242
+ this.onmessage?.({ data: { id: message.id, ok: false, error: "Missing required OPFS APIs." } }),
243
+ );
244
+ }
245
+ terminate() {}
246
+ }
247
+
248
+ let warn: MockInstance<typeof console.warn>;
249
+
250
+ beforeEach(() => {
251
+ workers.length = 0;
252
+ resetOfflineState();
253
+ configureOffline(config);
254
+ setDeviceStoreOpener((userId) => openDeviceStore(userId));
255
+ warn = vi.spyOn(console, "warn").mockImplementation(() => {});
256
+ vi.stubGlobal("Worker", OpfsMissingWorker);
257
+ });
258
+
259
+ afterEach(() => {
260
+ warn.mockRestore();
261
+ vi.unstubAllGlobals();
262
+ resetOfflineConfig();
263
+ resetOfflineState();
264
+ });
265
+
266
+ it("not a secure context (a custom http dev host): the sign-in switches offline mode off, once, without starting a worker", async () => {
267
+ setDeviceStoreBackend(null);
268
+ vi.stubGlobal("isSecureContext", false);
269
+
270
+ await expect(onUserSignedIn("user-a")).resolves.toBeUndefined();
271
+
272
+ expect(getOfflineState().available).toBe(false);
273
+ expect(isOfflineConfigured()).toBe(false);
274
+ expect(warn).toHaveBeenCalledTimes(1);
275
+
276
+ // Later uses never try again and never warn again.
277
+ await expect(getOfflineStore()).rejects.toBeInstanceOf(OfflineUnavailableError);
278
+ await expect(openDeviceStore("user-a")).rejects.toBeInstanceOf(OfflineUnavailableError);
279
+ await expect(hasUnsentChanges("user-a")).resolves.toBe(false);
280
+ await expect(deleteDeviceStore("user-a")).resolves.toBeUndefined();
281
+ await onUserSignedIn("user-a");
282
+ expect(workers).toEqual([]);
283
+ expect(warn).toHaveBeenCalledTimes(1);
284
+ });
285
+
286
+ it("no private file storage (navigator.storage.getDirectory missing): offline mode is off", async () => {
287
+ setDeviceStoreBackend(null);
288
+ vi.stubGlobal("isSecureContext", true);
289
+ vi.stubGlobal("navigator", { onLine: true });
290
+
291
+ await onUserSignedIn("user-a");
292
+
293
+ expect(getOfflineState().available).toBe(false);
294
+ expect(workers).toEqual([]);
295
+ expect(warn).toHaveBeenCalledTimes(1);
296
+ });
297
+
298
+ it("the worker reports the OPFS APIs missing: the open is an unavailable browser, warned once, and no worker starts again", async () => {
299
+ const fake = fakeDirectory();
300
+ vi.stubGlobal("isSecureContext", true);
301
+ vi.stubGlobal("navigator", { onLine: true, storage: { getDirectory: async () => fake.directory } });
302
+ // The browser's database opener (the worker), over a fake directory.
303
+ setDeviceStoreBackend({ getDirectory: async () => fake.directory });
304
+
305
+ await onUserSignedIn("user-a");
306
+ expect(getOfflineState().available).toBe(true);
307
+
308
+ const [first, second] = await Promise.all([
309
+ getOfflineStore().catch((caught) => caught),
310
+ getOfflineStore().catch((caught) => caught),
311
+ ]);
312
+
313
+ expect(first).toBeInstanceOf(OfflineUnavailableError);
314
+ expect(second).toBe(first);
315
+ expect(workers).toHaveLength(1);
316
+ expect(getOfflineState().available).toBe(false);
317
+ expect(isOfflineConfigured()).toBe(false);
318
+ expect(warn).toHaveBeenCalledTimes(1);
319
+
320
+ await expect(getOfflineStore()).rejects.toBeInstanceOf(OfflineUnavailableError);
321
+ expect(workers).toHaveLength(1);
322
+ expect(warn).toHaveBeenCalledTimes(1);
323
+ });
324
+
325
+ it("a storage SecurityError (some private windows) is an unavailable browser", async () => {
326
+ setDeviceStoreBackend(null);
327
+ vi.stubGlobal("isSecureContext", true);
328
+ vi.stubGlobal("navigator", {
329
+ onLine: true,
330
+ storage: {
331
+ getDirectory: async () => {
332
+ throw Object.assign(new Error("Security error when calling GetDirectory"), { name: "SecurityError" });
333
+ },
334
+ },
335
+ });
336
+
337
+ await onUserSignedIn("user-a");
338
+
339
+ expect(getOfflineState().available).toBe(false);
340
+ expect(warn).toHaveBeenCalledTimes(1);
341
+ });
342
+ });
343
+ });
@@ -0,0 +1,151 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { openSharedDatabase, withDatabaseReleased } from "../shared-database";
3
+ import type { OpenedDatabase } from "../worker-client";
4
+
5
+ /** A minimal Web Locks manager shared by every simulated page (exclusive locks, a FIFO queue). */
6
+ function fakeLocks() {
7
+ const held = new Set<string>();
8
+ const queues = new Map<string, (() => void)[]>();
9
+ const next = (name: string) => {
10
+ const waiting = queues.get(name);
11
+ const run = waiting?.shift();
12
+ if (run) run();
13
+ else held.delete(name);
14
+ };
15
+ return {
16
+ request(name: string, ...args: any[]): Promise<unknown> {
17
+ const callback = args[args.length - 1] as (lock: unknown) => Promise<unknown>;
18
+ const options = (args.length > 1 ? args[0] : {}) as { ifAvailable?: boolean; signal?: AbortSignal };
19
+ return new Promise((resolve, reject) => {
20
+ const grant = () => {
21
+ Promise.resolve(callback({ name }))
22
+ .then(resolve, reject)
23
+ .finally(() => next(name));
24
+ };
25
+ if (!held.has(name)) {
26
+ held.add(name);
27
+ grant();
28
+ return;
29
+ }
30
+ if (options.ifAvailable) {
31
+ Promise.resolve(callback(null)).then(resolve, reject);
32
+ return;
33
+ }
34
+ const queue = queues.get(name) ?? [];
35
+ queue.push(grant);
36
+ queues.set(name, queue);
37
+ options.signal?.addEventListener("abort", () => {
38
+ const index = queue.indexOf(grant);
39
+ if (index >= 0) queue.splice(index, 1);
40
+ reject(Object.assign(new Error("aborted"), { name: "AbortError" }));
41
+ });
42
+ });
43
+ },
44
+ query: async () => ({ held: [...held].map((name) => ({ name })) }),
45
+ };
46
+ }
47
+
48
+ /** A fake database recording every statement, in order, with the page that sent it. */
49
+ function fakeDatabase(log: string[], label: string): OpenedDatabase {
50
+ return {
51
+ created: true,
52
+ exec: async (sql) => {
53
+ log.push(`${label}:${sql}`);
54
+ await new Promise((resolve) => setTimeout(resolve, 1));
55
+ return [{ sql }];
56
+ },
57
+ close: vi.fn(async () => undefined),
58
+ };
59
+ }
60
+
61
+ const settle = () => new Promise((resolve) => setTimeout(resolve, 20));
62
+
63
+ describe("shared device database", () => {
64
+ beforeEach(() => {
65
+ vi.stubGlobal("navigator", { ...navigator, locks: fakeLocks() });
66
+ });
67
+
68
+ afterEach(() => {
69
+ vi.unstubAllGlobals();
70
+ });
71
+
72
+ it("the first page holds the database; a second page sends its statements to it and gets the answers", async () => {
73
+ const log: string[] = [];
74
+ const openA = vi.fn(async () => fakeDatabase(log, "files"));
75
+ const openB = vi.fn(async () => fakeDatabase(log, "never"));
76
+
77
+ const a = await openSharedDatabase("u1", openA);
78
+ const b = await openSharedDatabase("u1", openB);
79
+
80
+ expect(openA).toHaveBeenCalledTimes(1);
81
+ expect(openB).not.toHaveBeenCalled();
82
+ expect(await b.exclusive(() => b.exec("SELECT 1"))).toEqual([{ sql: "SELECT 1" }]);
83
+ expect(log).toEqual(["files:SELECT 1"]);
84
+
85
+ await b.close();
86
+ await a.close();
87
+ });
88
+
89
+ it("a transaction of one page never interleaves with another page's statements", async () => {
90
+ const log: string[] = [];
91
+ const a = await openSharedDatabase("u2", async () => fakeDatabase(log, "db"));
92
+ const b = await openSharedDatabase("u2", async () => fakeDatabase(log, "never"));
93
+
94
+ await Promise.all([
95
+ b.exclusive(async () => {
96
+ await b.exec("BEGIN");
97
+ await b.exec("B1");
98
+ await b.exec("COMMIT");
99
+ }),
100
+ a.exclusive(async () => {
101
+ await a.exec("BEGIN");
102
+ await a.exec("A1");
103
+ await a.exec("COMMIT");
104
+ }),
105
+ ]);
106
+
107
+ const statements = log.map((line) => line.split(":")[1]);
108
+ const firstCommit = statements.indexOf("COMMIT");
109
+ expect(statements.slice(0, firstCommit + 1)).toHaveLength(3);
110
+ expect(statements.slice(firstCommit + 1)).toHaveLength(3);
111
+ expect(new Set(statements.slice(0, 3))).not.toEqual(new Set(["BEGIN", "A1", "B1"]));
112
+
113
+ await b.close();
114
+ await a.close();
115
+ });
116
+
117
+ it("when the holder closes, the waiting page takes over, opening the files itself (retrying while they are released)", async () => {
118
+ const log: string[] = [];
119
+ const a = await openSharedDatabase("u3", async () => fakeDatabase(log, "a"));
120
+ let attempts = 0;
121
+ const openB = vi.fn(async () => {
122
+ attempts++;
123
+ if (attempts === 1) throw new Error("NoModificationAllowedError: Access Handles cannot be created");
124
+ return fakeDatabase(log, "b");
125
+ });
126
+ const b = await openSharedDatabase("u3", openB);
127
+
128
+ await a.close();
129
+ await new Promise((resolve) => setTimeout(resolve, 300));
130
+
131
+ expect(openB).toHaveBeenCalledTimes(2);
132
+ expect(await b.exclusive(() => b.exec("SELECT 2"))).toEqual([{ sql: "SELECT 2" }]);
133
+ expect(log).toEqual(["b:SELECT 2"]);
134
+ await b.close();
135
+ });
136
+
137
+ it("withDatabaseReleased closes the copy in every page before running the removal", async () => {
138
+ const log: string[] = [];
139
+ const database = fakeDatabase(log, "a");
140
+ const onShutdown = vi.fn();
141
+ await openSharedDatabase("u4", async () => database, onShutdown);
142
+
143
+ const removal = vi.fn(async () => "removed");
144
+ expect(await withDatabaseReleased("u4", removal)).toBe("removed");
145
+ await settle();
146
+
147
+ expect(database.close).toHaveBeenCalled();
148
+ expect(onShutdown).toHaveBeenCalled();
149
+ expect(removal).toHaveBeenCalledTimes(1);
150
+ });
151
+ });