@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,315 @@
1
+ import type { Sqlite3Static } from "@sqlite.org/sqlite-wasm";
2
+ import {
3
+ DeviceStoreUnavailableError,
4
+ forgetOfflineStore,
5
+ getDeviceWorkerUrl,
6
+ getSqliteModuleUrl,
7
+ isOfflineAvailable,
8
+ isOfflineStoreBlocked,
9
+ markOfflineUnavailable,
10
+ OfflineUnavailableError,
11
+ refreshOfflineDeviceState,
12
+ unblockOfflineStore,
13
+ } from "../config";
14
+ import { deleteImagesCaches } from "../sync/artwork-cache";
15
+ import type { DeviceStore } from "./DeviceStore";
16
+ import { createSqlDeviceStore } from "./sql-device-store";
17
+ import { canShareDatabase, openSharedDatabase, withDatabaseReleased, type SharedDatabase } from "./shared-database";
18
+ import { openWorkerDatabase, type OpenedDatabase } from "./worker-client";
19
+
20
+ /** The part of an OPFS `FileSystemDirectoryHandle` the lifecycle uses (faked in tests). */
21
+ export type OfflineDirectory = {
22
+ keys(): AsyncIterable<string>;
23
+ getFileHandle(name: string, options?: { create?: boolean }): Promise<unknown>;
24
+ removeEntry(name: string, options?: { recursive?: boolean }): Promise<void>;
25
+ };
26
+
27
+ export type DeviceStoreBackend = {
28
+ /** The OPFS root that holds every user's device file. */
29
+ getDirectory: () => Promise<OfflineDirectory>;
30
+ /** Opens `fileName` for `userId` (the worker in the browser). */
31
+ openDatabase: (userId: string, fileName: string) => Promise<OpenedDatabase>;
32
+ /** Asks the browser to keep the device copy (`navigator.storage.persist()`). */
33
+ requestPersistence: () => Promise<void>;
34
+ };
35
+
36
+ const FILE_PREFIX = "nja-offline-";
37
+ const deviceFileName = (userId: string) => `${FILE_PREFIX}${userId}.sqlite3`;
38
+ /** Present while the user's file holds unsent changes; read without opening the file. */
39
+ const pendingMarkerName = (userId: string) => `${FILE_PREFIX}${userId}.pending`;
40
+ /** Names the user whose artwork the images cache holds (the images cache is not per user). */
41
+ const IMAGES_OWNER_PREFIX = "nja-offline-images-owner-";
42
+ const imagesOwnerName = (userId: string) => `${IMAGES_OWNER_PREFIX}${userId}`;
43
+ const FILE_PATTERN = /^nja-offline-(.+)\.(?:sqlite3(?:-journal|-wal|-shm)?|pending)$/;
44
+ const POOL_PATTERN = /^\.nja-offline-(.+)$/;
45
+
46
+ /**
47
+ * Why this browser cannot keep the device copy, or null when it may: browsers expose the private
48
+ * file storage only in a secure context (https, or `localhost`). Checked before any worker starts.
49
+ */
50
+ const missingDeviceStorage = (): string | null => {
51
+ if (typeof window !== "undefined" && window.isSecureContext === false) {
52
+ return "the page is not served from a secure origin (https or localhost)";
53
+ }
54
+ if (typeof navigator === "undefined" || typeof navigator.storage?.getDirectory !== "function") {
55
+ return "the browser offers no private file storage";
56
+ }
57
+ return null;
58
+ };
59
+
60
+ const assertDeviceStorage = (): void => {
61
+ const reason = missingDeviceStorage();
62
+ if (reason) throw new OfflineUnavailableError(reason);
63
+ };
64
+
65
+ /** What the SQLite worker answers when the browser lacks the OPFS sync-access handles. */
66
+ const OPFS_MISSING = /Missing required OPFS APIs/i;
67
+
68
+ const defaultBackend: DeviceStoreBackend = {
69
+ getDirectory: async () => {
70
+ assertDeviceStorage();
71
+ try {
72
+ return (await navigator.storage.getDirectory()) as unknown as OfflineDirectory;
73
+ } catch (error) {
74
+ // Storage refused outright (some private windows).
75
+ if ((error as { name?: string })?.name === "SecurityError") {
76
+ throw new OfflineUnavailableError("the browser refused its private file storage");
77
+ }
78
+ throw error;
79
+ }
80
+ },
81
+ openDatabase: async (userId, fileName) => {
82
+ assertDeviceStorage();
83
+ const openLocal = () =>
84
+ openWorkerDatabase(userId, fileName, {
85
+ workerUrl: getDeviceWorkerUrl(),
86
+ sqliteModuleUrl: getSqliteModuleUrl(),
87
+ });
88
+ try {
89
+ // Shared by every page of the origin: only one page at a time can hold the files.
90
+ if (!canShareDatabase()) return await openLocal();
91
+ return await openSharedDatabase(userId, openLocal, () => forgetClosedStore(userId));
92
+ } catch (error) {
93
+ if (OPFS_MISSING.test(String((error as Error)?.message ?? error))) {
94
+ throw new OfflineUnavailableError("the browser lacks the file access the device copy needs (OPFS)");
95
+ }
96
+ throw error;
97
+ }
98
+ },
99
+ requestPersistence: async () => {
100
+ if (typeof navigator !== "undefined" && navigator.storage?.persist) await navigator.storage.persist();
101
+ },
102
+ };
103
+
104
+ let backend: DeviceStoreBackend = defaultBackend;
105
+
106
+ type OpenStore = { userId: string; store: Promise<DeviceStore>; database: Promise<OpenedDatabase> };
107
+ let current: OpenStore | undefined;
108
+
109
+ /** Replaces the OPFS directory / database opener (tests); `null` restores the browser one. */
110
+ export function setDeviceStoreBackend(next: Partial<DeviceStoreBackend> | null): void {
111
+ backend = next ? { ...defaultBackend, ...next } : defaultBackend;
112
+ current = undefined;
113
+ }
114
+
115
+ const entryExists = async (directory: OfflineDirectory, name: string): Promise<boolean> => {
116
+ for await (const key of directory.keys()) if (key === name) return true;
117
+ return false;
118
+ };
119
+
120
+ const removeIfPresent = async (directory: OfflineDirectory, name: string): Promise<void> => {
121
+ try {
122
+ await directory.removeEntry(name, { recursive: true });
123
+ } catch (error) {
124
+ if ((error as { name?: string })?.name !== "NotFoundError") throw error;
125
+ }
126
+ };
127
+
128
+ const entriesOf = async (directory: OfflineDirectory, userId: string): Promise<string[]> => {
129
+ const names: string[] = [];
130
+ for await (const key of directory.keys()) if (userIdOf(key) === userId) names.push(key);
131
+ return names;
132
+ };
133
+
134
+ /**
135
+ * The OPFS root, or undefined when this browser cannot keep the device copy: offline mode is then
136
+ * switched off (`markOfflineUnavailable`). Any other failure is thrown as before.
137
+ */
138
+ const deviceDirectory = async (): Promise<OfflineDirectory | undefined> => {
139
+ if (!isOfflineAvailable()) return undefined;
140
+ try {
141
+ return await backend.getDirectory();
142
+ } catch (error) {
143
+ if (!(error instanceof OfflineUnavailableError)) throw error;
144
+ markOfflineUnavailable(error);
145
+ return undefined;
146
+ }
147
+ };
148
+
149
+ const userIdOf = (name: string): string | undefined => FILE_PATTERN.exec(name)?.[1] ?? POOL_PATTERN.exec(name)?.[1];
150
+
151
+ const closeCurrent = async (): Promise<void> => {
152
+ const open = current;
153
+ current = undefined;
154
+ if (!open) return;
155
+ try {
156
+ await (await open.database).close();
157
+ } catch {
158
+ // A database that never opened has nothing to close.
159
+ }
160
+ };
161
+
162
+ /**
163
+ * Opens the device copy of `userId` (`nja-offline-<userId>.sqlite3` in the OPFS,
164
+ * through the worker). Only one user's file is open at a time: opening another
165
+ * user's file closes the previous one first.
166
+ */
167
+ export function openDeviceStore(userId: string): Promise<DeviceStore> {
168
+ if (!userId) return Promise.reject(new Error("openDeviceStore needs the signed-in user id."));
169
+ if (isOfflineStoreBlocked(userId)) {
170
+ return Promise.reject(new DeviceStoreUnavailableError("The device copy was deleted at logout"));
171
+ }
172
+ if (!isOfflineAvailable()) {
173
+ return Promise.reject(new OfflineUnavailableError("it was found unable to keep the device copy"));
174
+ }
175
+ if (current?.userId === userId) return current.store;
176
+
177
+ const previous = closeCurrent();
178
+ const database = previous
179
+ .then(() => backend.openDatabase(userId, deviceFileName(userId)))
180
+ .catch((error) => {
181
+ if (error instanceof OfflineUnavailableError) markOfflineUnavailable(error);
182
+ throw error;
183
+ });
184
+ const store = database.then(async (opened) => {
185
+ if (opened.created) await backend.requestPersistence();
186
+ return createSqlDeviceStore(opened.exec, {
187
+ exclusive: (opened as Partial<SharedDatabase>).exclusive,
188
+ beforeEnqueue: async () => {
189
+ const directory = await backend.getDirectory();
190
+ await directory.getFileHandle(pendingMarkerName(userId), { create: true });
191
+ },
192
+ afterQueueChange: async (entries) => {
193
+ if (entries > 0) return;
194
+ await removeIfPresent(await backend.getDirectory(), pendingMarkerName(userId));
195
+ },
196
+ });
197
+ });
198
+ const open: OpenStore = { userId, store, database };
199
+ current = open;
200
+ store.catch(() => {
201
+ if (current === open) current = undefined;
202
+ });
203
+ return store;
204
+ }
205
+
206
+ let sqlite3Promise: Promise<Sqlite3Static> | undefined;
207
+
208
+ /**
209
+ * An in-memory SQLite database (no worker, no OPFS), for tests in Node. The package is imported
210
+ * here at run time and hidden from app bundlers (see `OfflineConfig.sqliteModuleUrl`).
211
+ */
212
+ export async function openMemoryDatabase(): Promise<OpenedDatabase> {
213
+ sqlite3Promise ??= import(/* webpackIgnore: true */ /* turbopackIgnore: true */ "@sqlite.org/sqlite-wasm").then(
214
+ (module) => module.default(),
215
+ );
216
+ const sqlite3 = await sqlite3Promise;
217
+ const db = new sqlite3.oo1.DB(":memory:", "c");
218
+ return {
219
+ created: true,
220
+ exec: async (sql, bind) =>
221
+ db.exec({
222
+ sql,
223
+ bind: bind && bind.length ? (bind as any) : undefined,
224
+ rowMode: "object",
225
+ returnValue: "resultRows",
226
+ }) as Record<string, unknown>[],
227
+ close: async () => {
228
+ db.close();
229
+ },
230
+ };
231
+ }
232
+
233
+ /** The same store and schema over an in-memory database, for tests. */
234
+ export async function openMemoryDeviceStore(): Promise<DeviceStore> {
235
+ const database = await openMemoryDatabase();
236
+ return createSqlDeviceStore(database.exec);
237
+ }
238
+
239
+ /**
240
+ * Another page closed every page's copy of `userId` (logout, offline mode off): this page forgets
241
+ * its store, and reads the device switch again before anything opens the copy.
242
+ */
243
+ function forgetClosedStore(userId: string): void {
244
+ if (current?.userId === userId) current = undefined;
245
+ forgetOfflineStore(userId);
246
+ refreshOfflineDeviceState();
247
+ }
248
+
249
+ /**
250
+ * Closes `userId`'s device copy in every page and removes every OPFS entry of it. The files can
251
+ * only be removed once no page holds them, so the removal waits for every page to let go.
252
+ */
253
+ export async function deleteDeviceStore(userId: string): Promise<void> {
254
+ if (current?.userId === userId) await closeCurrent();
255
+ const directory = await deviceDirectory();
256
+ if (!directory) return;
257
+ const remove = async () => {
258
+ const names = await entriesOf(directory, userId);
259
+ const marker = pendingMarkerName(userId);
260
+ for (const name of names) if (name !== marker) await removeIfPresent(directory, name);
261
+ await removeIfPresent(directory, marker);
262
+ };
263
+ if (backend === defaultBackend) await withDatabaseReleased(userId, remove);
264
+ else await remove();
265
+ }
266
+
267
+ /** Whether `userId`'s device copy holds unsent changes. Never opens another user's file. */
268
+ export async function hasUnsentChanges(userId: string): Promise<boolean> {
269
+ if (current?.userId === userId) {
270
+ const store = await current.store;
271
+ if ((await store.queue()).length > 0) return true;
272
+ }
273
+ const directory = await deviceDirectory();
274
+ if (!directory) return false;
275
+ return entryExists(directory, pendingMarkerName(userId));
276
+ }
277
+
278
+ /**
279
+ * A user signed in on this browser: their device copy may be opened again (it is blocked after
280
+ * a logout), and every other user's device copy is deleted when it has nothing unsent, and kept
281
+ * untouched (never opened) when it has. When the images cache was not this user's (another user
282
+ * signed in before, or no owner is known), it is deleted: the user's sync downloads their artwork.
283
+ */
284
+ export async function onUserSignedIn(userId: string): Promise<void> {
285
+ unblockOfflineStore(userId);
286
+ if (current && current.userId !== userId) await closeCurrent();
287
+ // A browser that cannot keep the device copy: offline mode switches off here, before the runtime starts.
288
+ const directory = await deviceDirectory();
289
+ if (!directory) return;
290
+ const others = new Set<string>();
291
+ const imageOwners: string[] = [];
292
+ for await (const key of directory.keys()) {
293
+ if (key.startsWith(IMAGES_OWNER_PREFIX)) {
294
+ imageOwners.push(key);
295
+ continue;
296
+ }
297
+ const owner = userIdOf(key);
298
+ if (owner && owner !== userId) others.add(owner);
299
+ }
300
+ if (!imageOwners.includes(imagesOwnerName(userId))) {
301
+ await deleteImagesCaches();
302
+ for (const name of imageOwners) await removeIfPresent(directory, name);
303
+ await directory.getFileHandle(imagesOwnerName(userId), { create: true });
304
+ }
305
+ for (const other of others) {
306
+ if (await entryExists(directory, pendingMarkerName(other))) continue;
307
+ await deleteDeviceStore(other);
308
+ }
309
+ }
310
+
311
+ /** Locked now: the server said so, or the lock date it gave has passed. */
312
+ export function isRootLocked(s: { locked: boolean; lockedFrom: Date | null }, now: Date): boolean {
313
+ if (s.locked) return true;
314
+ return s.lockedFrom !== null && s.lockedFrom.getTime() <= now.getTime();
315
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Device-copy schema (spec § 1). Every statement is idempotent so it runs on
3
+ * every open; `user_version` records the layout. A file of an older layout is
4
+ * recreated (pre-release: no migration), see `DEVICE_STORE_RESET`.
5
+ *
6
+ * 2: `record_campaigns` (a shared record, e.g. an artwork or a template, belongs
7
+ * to every root record that uses it) and `queue.package_id`.
8
+ * `skipped_records` was added later inside layout 2: `CREATE TABLE IF NOT EXISTS` adds it to
9
+ * an existing file without a reset (a reset would drop unsent changes).
10
+ * 3: campaign columns and tables renamed to root (`campaign_id` -> `root_id`,
11
+ * `record_campaigns` -> `record_roots`).
12
+ */
13
+ export const DEVICE_STORE_SCHEMA_VERSION = 3;
14
+
15
+ /**
16
+ * Drops every table of an older layout before the schema is created again. Layout 2's
17
+ * `record_campaigns` is dropped too, so a layout-2 file is fully recreated.
18
+ */
19
+ export const DEVICE_STORE_RESET: string[] = [
20
+ `DROP TABLE IF EXISTS records`,
21
+ `DROP TABLE IF EXISTS record_campaigns`,
22
+ `DROP TABLE IF EXISTS record_roots`,
23
+ `DROP TABLE IF EXISTS links`,
24
+ `DROP TABLE IF EXISTS queue`,
25
+ `DROP TABLE IF EXISTS conflicts`,
26
+ `DROP TABLE IF EXISTS sync_state`,
27
+ `DROP TABLE IF EXISTS skipped_records`,
28
+ ];
29
+
30
+ export const DEVICE_STORE_SCHEMA: string[] = [
31
+ `CREATE TABLE IF NOT EXISTS records (
32
+ type TEXT NOT NULL,
33
+ id TEXT NOT NULL,
34
+ root_id TEXT NOT NULL,
35
+ resource TEXT NOT NULL,
36
+ updated_at TEXT,
37
+ base TEXT,
38
+ local_only INTEGER NOT NULL DEFAULT 0,
39
+ PRIMARY KEY (type, id)
40
+ )`,
41
+ `CREATE INDEX IF NOT EXISTS records_type_root ON records (type, root_id)`,
42
+ `CREATE TABLE IF NOT EXISTS record_roots (
43
+ type TEXT NOT NULL,
44
+ id TEXT NOT NULL,
45
+ root_id TEXT NOT NULL,
46
+ PRIMARY KEY (type, id, root_id)
47
+ )`,
48
+ `CREATE INDEX IF NOT EXISTS record_roots_root ON record_roots (root_id, type)`,
49
+ `CREATE TABLE IF NOT EXISTS links (
50
+ from_id TEXT NOT NULL,
51
+ from_type TEXT NOT NULL,
52
+ to_id TEXT NOT NULL,
53
+ to_type TEXT NOT NULL,
54
+ kind TEXT NOT NULL,
55
+ props TEXT NOT NULL DEFAULT '{}',
56
+ root_id TEXT,
57
+ PRIMARY KEY (from_id, to_id, kind)
58
+ )`,
59
+ `CREATE INDEX IF NOT EXISTS links_from ON links (from_id)`,
60
+ `CREATE INDEX IF NOT EXISTS links_to ON links (to_id)`,
61
+ `CREATE INDEX IF NOT EXISTS links_root ON links (root_id)`,
62
+ `CREATE TABLE IF NOT EXISTS queue (
63
+ seq INTEGER PRIMARY KEY AUTOINCREMENT,
64
+ root_id TEXT NOT NULL,
65
+ request TEXT NOT NULL,
66
+ base TEXT,
67
+ created_at TEXT NOT NULL,
68
+ status TEXT NOT NULL DEFAULT 'pending',
69
+ package_id TEXT
70
+ )`,
71
+ `CREATE INDEX IF NOT EXISTS queue_status_seq ON queue (status, seq)`,
72
+ `CREATE TABLE IF NOT EXISTS conflicts (
73
+ id TEXT PRIMARY KEY,
74
+ queue_seq INTEGER NOT NULL,
75
+ root_id TEXT,
76
+ kind TEXT NOT NULL,
77
+ record_type TEXT,
78
+ record_id TEXT,
79
+ mine TEXT,
80
+ theirs TEXT,
81
+ base TEXT,
82
+ merged TEXT,
83
+ fields TEXT NOT NULL DEFAULT '[]',
84
+ blocks TEXT,
85
+ reason TEXT
86
+ )`,
87
+ `CREATE TABLE IF NOT EXISTS sync_state (
88
+ root_id TEXT PRIMARY KEY,
89
+ server_time TEXT,
90
+ locked INTEGER NOT NULL DEFAULT 0,
91
+ locked_from TEXT
92
+ )`,
93
+ `CREATE TABLE IF NOT EXISTS skipped_records (
94
+ root_id TEXT NOT NULL,
95
+ type TEXT NOT NULL,
96
+ id TEXT NOT NULL,
97
+ updated_at TEXT NOT NULL,
98
+ PRIMARY KEY (root_id, type, id)
99
+ )`,
100
+ `PRAGMA user_version = ${DEVICE_STORE_SCHEMA_VERSION}`,
101
+ ];