@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.
- package/dist/{ApiData-DPKNfY-9.d.mts → ApiData-Be8yxCOX.d.mts} +2 -0
- package/dist/{ApiData-DPKNfY-9.d.ts → ApiData-Be8yxCOX.d.ts} +2 -0
- package/dist/{BlockNoteEditor-7M5QW2YQ.js → BlockNoteEditor-UBVEUFNR.js} +34 -22
- package/dist/BlockNoteEditor-UBVEUFNR.js.map +1 -0
- package/dist/{BlockNoteEditor-75ED2CN2.mjs → BlockNoteEditor-VXMDSQAA.mjs} +20 -8
- package/dist/BlockNoteEditor-VXMDSQAA.mjs.map +1 -0
- package/dist/{BlockNoteViewer-WGCDB7IJ.js → BlockNoteViewer-2XYASA5Y.js} +8 -5
- package/dist/BlockNoteViewer-2XYASA5Y.js.map +1 -0
- package/dist/{BlockNoteViewer-MTGS6YJV.mjs → BlockNoteViewer-NBW2H6LC.mjs} +7 -4
- package/dist/{BlockNoteViewer-MTGS6YJV.mjs.map → BlockNoteViewer-NBW2H6LC.mjs.map} +1 -1
- package/dist/{JsonApiRequest-A66XK5WT.mjs → JsonApiRequest-F6TMJ42G.mjs} +2 -2
- package/dist/JsonApiRequest-WISJZSDE.js +26 -0
- package/dist/{JsonApiRequest-FZGPLNUF.js.map → JsonApiRequest-WISJZSDE.js.map} +1 -1
- package/dist/billing/index.js +499 -494
- package/dist/billing/index.js.map +1 -1
- package/dist/billing/index.mjs +13 -8
- package/dist/billing/index.mjs.map +1 -1
- package/dist/chunk-27LQDGNP.mjs +317 -0
- package/dist/chunk-27LQDGNP.mjs.map +1 -0
- package/dist/{chunk-KSYQEKJX.js → chunk-2SV4SS6N.js} +14 -14
- package/dist/{chunk-KSYQEKJX.js.map → chunk-2SV4SS6N.js.map} +1 -1
- package/dist/{chunk-4FNGWALA.mjs → chunk-47BBY5KA.mjs} +6 -4
- package/dist/{chunk-4FNGWALA.mjs.map → chunk-47BBY5KA.mjs.map} +1 -1
- package/dist/chunk-6JUCIWDC.mjs +570 -0
- package/dist/chunk-6JUCIWDC.mjs.map +1 -0
- package/dist/chunk-7DBJTNSU.mjs +1049 -0
- package/dist/chunk-7DBJTNSU.mjs.map +1 -0
- package/dist/chunk-7JZHPMGW.mjs +704 -0
- package/dist/chunk-7JZHPMGW.mjs.map +1 -0
- package/dist/{chunk-L6PYFQTO.js → chunk-7ZHTPFFT.js} +16 -14
- package/dist/chunk-7ZHTPFFT.js.map +1 -0
- package/dist/chunk-B6JGKDP2.js +214 -0
- package/dist/chunk-B6JGKDP2.js.map +1 -0
- package/dist/{chunk-3YW35YHQ.mjs → chunk-BH6JWIKI.mjs} +5 -5
- package/dist/chunk-BZ5J2FIV.js +570 -0
- package/dist/chunk-BZ5J2FIV.js.map +1 -0
- package/dist/{chunk-V5MI7RNB.mjs → chunk-DGWOVYEC.mjs} +2 -2
- package/dist/chunk-DOCIFE6A.mjs +212 -0
- package/dist/chunk-DOCIFE6A.mjs.map +1 -0
- package/dist/chunk-GB6DBFYY.js +25 -0
- package/dist/chunk-GB6DBFYY.js.map +1 -0
- package/dist/{chunk-EWCY6QJA.mjs → chunk-JJMMT24D.mjs} +37 -3
- package/dist/chunk-JJMMT24D.mjs.map +1 -0
- package/dist/{chunk-JTPRJNT2.js → chunk-KMW7UNL2.js} +38 -3
- package/dist/chunk-KMW7UNL2.js.map +1 -0
- package/dist/chunk-MHB36MYE.js +317 -0
- package/dist/chunk-MHB36MYE.js.map +1 -0
- package/dist/{chunk-FF4EIQ6H.js → chunk-MOGEGWXW.js} +1039 -1680
- package/dist/chunk-MOGEGWXW.js.map +1 -0
- package/dist/{chunk-RXQYEEKZ.mjs → chunk-N4RZZKL5.mjs} +58 -25
- package/dist/chunk-N4RZZKL5.mjs.map +1 -0
- package/dist/{chunk-AA52ORW2.mjs → chunk-P445QGOC.mjs} +61 -702
- package/dist/chunk-P445QGOC.mjs.map +1 -0
- package/dist/chunk-PV4Z5W6V.js +212 -0
- package/dist/chunk-PV4Z5W6V.js.map +1 -0
- package/dist/chunk-PXDUJ47N.js +1049 -0
- package/dist/chunk-PXDUJ47N.js.map +1 -0
- package/dist/{chunk-V3YDVJ63.js → chunk-PYRZCKC2.js} +1209 -1176
- package/dist/chunk-PYRZCKC2.js.map +1 -0
- package/dist/{chunk-GHE4VT2R.js → chunk-QKNGZDSQ.js} +2 -2
- package/dist/{chunk-GHE4VT2R.js.map → chunk-QKNGZDSQ.js.map} +1 -1
- package/dist/chunk-W62MHDWM.js +704 -0
- package/dist/chunk-W62MHDWM.js.map +1 -0
- package/dist/chunk-XXP3TQHF.mjs +25 -0
- package/dist/chunk-XXP3TQHF.mjs.map +1 -0
- package/dist/chunk-YRKIFYOE.mjs +214 -0
- package/dist/chunk-YRKIFYOE.mjs.map +1 -0
- package/dist/{chunk-O3PDL2CV.js → chunk-Z4POQWFG.js} +40 -6
- package/dist/chunk-Z4POQWFG.js.map +1 -0
- package/dist/{chunk-PI56A4DG.mjs → chunk-ZLTOUZN2.mjs} +37 -2
- package/dist/chunk-ZLTOUZN2.mjs.map +1 -0
- package/dist/client/index.d.mts +2 -2
- package/dist/client/index.d.ts +2 -2
- package/dist/client/index.js +14 -9
- package/dist/client/index.js.map +1 -1
- package/dist/client/index.mjs +24 -19
- package/dist/components/index.d.mts +7 -1
- package/dist/components/index.d.ts +7 -1
- package/dist/components/index.js +11 -7
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +10 -6
- package/dist/contexts/index.d.mts +1 -1
- package/dist/contexts/index.d.ts +1 -1
- package/dist/contexts/index.js +11 -7
- package/dist/contexts/index.js.map +1 -1
- package/dist/contexts/index.mjs +10 -6
- package/dist/core/index.d.mts +128 -2
- package/dist/core/index.d.ts +128 -2
- package/dist/core/index.js +17 -4
- package/dist/core/index.js.map +1 -1
- package/dist/core/index.mjs +22 -9
- package/dist/email-automations/index.js +101 -96
- package/dist/email-automations/index.js.map +1 -1
- package/dist/email-automations/index.mjs +13 -8
- package/dist/email-automations/index.mjs.map +1 -1
- package/dist/features/analytics/index.js +128 -123
- package/dist/features/analytics/index.js.map +1 -1
- package/dist/features/analytics/index.mjs +13 -8
- package/dist/features/analytics/index.mjs.map +1 -1
- package/dist/features/analytics/server.js +11 -8
- package/dist/features/analytics/server.js.map +1 -1
- package/dist/features/analytics/server.mjs +9 -6
- package/dist/features/analytics/server.mjs.map +1 -1
- package/dist/features/blog/index.js +158 -153
- package/dist/features/blog/index.js.map +1 -1
- package/dist/features/blog/index.mjs +14 -9
- package/dist/features/blog/index.mjs.map +1 -1
- package/dist/features/blog/server.js +30 -26
- package/dist/features/blog/server.js.map +1 -1
- package/dist/features/blog/server.mjs +10 -6
- package/dist/features/blog/server.mjs.map +1 -1
- package/dist/features/help/index.js +44 -40
- package/dist/features/help/index.js.map +1 -1
- package/dist/features/help/index.mjs +10 -6
- package/dist/features/help/index.mjs.map +1 -1
- package/dist/features/tokenusage/index.js +134 -129
- package/dist/features/tokenusage/index.js.map +1 -1
- package/dist/features/tokenusage/index.mjs +13 -8
- package/dist/features/tokenusage/index.mjs.map +1 -1
- package/dist/index.d.mts +2 -2
- package/dist/index.d.ts +2 -2
- package/dist/index.js +18 -5
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +24 -11
- package/dist/lifecycle-4GUOTB46.js +23 -0
- package/dist/lifecycle-4GUOTB46.js.map +1 -0
- package/dist/lifecycle-LKGPRBS7.mjs +23 -0
- package/dist/local-transport-7H3SPZSA.js +15 -0
- package/dist/local-transport-7H3SPZSA.js.map +1 -0
- package/dist/local-transport-QYFKA6AN.mjs +15 -0
- package/dist/local-transport-QYFKA6AN.mjs.map +1 -0
- package/dist/offline/index.d.mts +607 -0
- package/dist/offline/index.d.ts +607 -0
- package/dist/offline/index.js +1052 -0
- package/dist/offline/index.js.map +1 -0
- package/dist/offline/index.mjs +1052 -0
- package/dist/offline/index.mjs.map +1 -0
- package/dist/offline/sqlite/device-worker.mjs +62 -0
- package/dist/offline/sqlite/index.mjs +17392 -0
- package/dist/offline/sqlite/sqlite3-opfs-async-proxy.js +881 -0
- package/dist/offline/sqlite/sqlite3.wasm +0 -0
- package/dist/request-KTBV6HHH.js +10 -0
- package/dist/request-KTBV6HHH.js.map +1 -0
- package/dist/request-SQOUNWZ4.mjs +10 -0
- package/dist/request-SQOUNWZ4.mjs.map +1 -0
- package/dist/server/cachedGet.d.mts +1 -1
- package/dist/server/cachedGet.d.ts +1 -1
- package/dist/server/cachedGet.stub.d.mts +1 -1
- package/dist/server/cachedGet.stub.d.ts +1 -1
- package/dist/server/index.d.mts +28 -2
- package/dist/server/index.d.ts +28 -2
- package/dist/server/index.js +18 -8
- package/dist/server/index.js.map +1 -1
- package/dist/server/index.mjs +18 -8
- package/dist/{useSocket-IRHtk2xc.d.ts → useSocket-Bo9M0t0J.d.ts} +3 -1
- package/dist/{useSocket-C8fqU4od.d.mts → useSocket-J5qanQPN.d.mts} +3 -1
- package/dist/write-through-FDQ7N4YI.js +15 -0
- package/dist/write-through-FDQ7N4YI.js.map +1 -0
- package/dist/write-through-R25VQMH6.mjs +15 -0
- package/dist/write-through-R25VQMH6.mjs.map +1 -0
- package/package.json +7 -1
- package/pwa/sw.js +277 -26
- package/src/client/request.ts +37 -0
- package/src/components/editors/BlockNoteEditor.tsx +14 -0
- package/src/components/editors/__tests__/BlockNoteEditor.empty-change.spec.tsx +205 -0
- package/src/contexts/SocketContext.tsx +7 -1
- package/src/contexts/__tests__/SocketContext.session.spec.tsx +52 -5
- package/src/core/factories/RehydrationFactory.ts +28 -8
- package/src/core/factories/__tests__/RehydrationFactory.spec.ts +33 -0
- package/src/core/index.ts +3 -0
- package/src/core/interfaces/ApiData.ts +2 -0
- package/src/core/registry/ModuleRegistry.ts +3 -0
- package/src/features/auth/components/forms/Logout.tsx +8 -1
- package/src/features/auth/components/forms/__tests__/Logout.spec.tsx +62 -0
- package/src/features/onboarding/contexts/OnboardingContext.spec.tsx +42 -1
- package/src/features/onboarding/contexts/OnboardingContext.tsx +11 -1
- package/src/hooks/__tests__/useSocket.pause.spec.ts +39 -0
- package/src/hooks/useSocket.ts +20 -1
- package/src/offline/__tests__/answer-builders.spec.ts +176 -0
- package/src/offline/__tests__/device-switch.spec.ts +179 -0
- package/src/offline/__tests__/generic-answers.spec.ts +380 -0
- package/src/offline/__tests__/local-transport.spec.ts +150 -0
- package/src/offline/__tests__/logout.spec.ts +122 -0
- package/src/offline/__tests__/offline-fixtures.ts +89 -0
- package/src/offline/__tests__/router.spec.ts +56 -0
- package/src/offline/__tests__/state.spec.ts +62 -0
- package/src/offline/__tests__/write-through.spec.ts +137 -0
- package/src/offline/config.ts +229 -0
- package/src/offline/configure.ts +14 -0
- package/src/offline/device-switch.ts +75 -0
- package/src/offline/generic-answers.ts +706 -0
- package/src/offline/index.ts +67 -0
- package/src/offline/local-transport.ts +189 -0
- package/src/offline/logout.ts +41 -0
- package/src/offline/router.ts +41 -0
- package/src/offline/server/__tests__/build-offline-manifest.spec.ts +76 -0
- package/src/offline/server/build-offline-manifest.ts +49 -0
- package/src/offline/state-hooks.ts +10 -0
- package/src/offline/state.ts +143 -0
- package/src/offline/store/DeviceStore.ts +112 -0
- package/src/offline/store/__tests__/device-store-lifecycle.spec.ts +343 -0
- package/src/offline/store/__tests__/shared-database.spec.ts +151 -0
- package/src/offline/store/__tests__/sql-device-store.spec.ts +399 -0
- package/src/offline/store/lifecycle.ts +315 -0
- package/src/offline/store/schema.ts +101 -0
- package/src/offline/store/shared-database.ts +321 -0
- package/src/offline/store/sql-device-store.ts +469 -0
- package/src/offline/store/worker-client.ts +71 -0
- package/src/offline/store/worker.ts +87 -0
- package/src/offline/sync/__tests__/conflicts.spec.ts +185 -0
- package/src/offline/sync/__tests__/queue-drain.spec.ts +364 -0
- package/src/offline/sync/__tests__/runtime.spec.ts +337 -0
- package/src/offline/sync/__tests__/sync-fixtures.ts +103 -0
- package/src/offline/sync/__tests__/sync-root.spec.ts +727 -0
- package/src/offline/sync/artwork-cache.ts +67 -0
- package/src/offline/sync/conflicts.ts +211 -0
- package/src/offline/sync/counts.ts +39 -0
- package/src/offline/sync/drain-queue.ts +246 -0
- package/src/offline/sync/known-roots.ts +37 -0
- package/src/offline/sync/models/OfflineChangeSet.ts +34 -0
- package/src/offline/sync/models/OfflineChangeSetInterface.ts +54 -0
- package/src/offline/sync/models/RootSync.ts +68 -0
- package/src/offline/sync/models/RootSyncInterface.ts +20 -0
- package/src/offline/sync/models/__tests__/OfflineChangeSet.spec.ts +47 -0
- package/src/offline/sync/models/__tests__/RootSync.spec.ts +54 -0
- package/src/offline/sync/models/index.ts +5 -0
- package/src/offline/sync/models/offline.modules.ts +14 -0
- package/src/offline/sync/runtime.ts +226 -0
- package/src/offline/sync/session-refresh.ts +32 -0
- package/src/offline/sync/sync-root.ts +336 -0
- package/src/offline/sync/waiting.ts +40 -0
- package/src/offline/types.ts +115 -0
- package/src/offline/write-through.ts +189 -0
- package/src/pwa/__tests__/sw.spec.ts +358 -1
- package/src/server/index.ts +5 -0
- package/src/unified/__tests__/JsonApiRequest.offline.spec.ts +243 -0
- package/dist/BlockNoteEditor-75ED2CN2.mjs.map +0 -1
- package/dist/BlockNoteEditor-7M5QW2YQ.js.map +0 -1
- package/dist/BlockNoteViewer-WGCDB7IJ.js.map +0 -1
- package/dist/JsonApiRequest-FZGPLNUF.js +0 -26
- package/dist/chunk-AA52ORW2.mjs.map +0 -1
- package/dist/chunk-EWCY6QJA.mjs.map +0 -1
- package/dist/chunk-FF4EIQ6H.js.map +0 -1
- package/dist/chunk-JTPRJNT2.js.map +0 -1
- package/dist/chunk-L6PYFQTO.js.map +0 -1
- package/dist/chunk-O3PDL2CV.js.map +0 -1
- package/dist/chunk-PI56A4DG.mjs.map +0 -1
- package/dist/chunk-RXQYEEKZ.mjs.map +0 -1
- package/dist/chunk-V3YDVJ63.js.map +0 -1
- package/dist/request-5RLK2AM3.mjs +0 -9
- package/dist/request-EUGTI3BE.js +0 -9
- package/dist/request-EUGTI3BE.js.map +0 -1
- /package/dist/{JsonApiRequest-A66XK5WT.mjs.map → JsonApiRequest-F6TMJ42G.mjs.map} +0 -0
- /package/dist/{chunk-3YW35YHQ.mjs.map → chunk-BH6JWIKI.mjs.map} +0 -0
- /package/dist/{chunk-V5MI7RNB.mjs.map → chunk-DGWOVYEC.mjs.map} +0 -0
- /package/dist/{request-5RLK2AM3.mjs.map → lifecycle-LKGPRBS7.mjs.map} +0 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["/home/runner/work/nextjs-jsonapi/nextjs-jsonapi/dist/chunk-PXDUJ47N.js","../src/offline/sync/artwork-cache.ts","../src/offline/store/schema.ts","../src/offline/store/sql-device-store.ts","../src/offline/store/shared-database.ts","../src/offline/store/worker-client.ts","../src/offline/store/lifecycle.ts"],"names":["newId"],"mappings":"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","file":"/home/runner/work/nextjs-jsonapi/nextjs-jsonapi/dist/chunk-PXDUJ47N.js","sourcesContent":[null,"// Artwork images for the device copy, kept in Cache Storage under the exact URL the stored\n// artwork record holds (`attributes.imageUrl`). The service worker answers those URLs from the\n// same cache when the network fails. The image downloads are plain GETs of the URLs the API\n// already returned (decision recorded in the plan).\n\n// The service worker names its caches `${appId}-images` and `${appId}-offline-${revision}`\n// (pwa/sw.js). The page does not load sw-config.js, so it finds the app id from those names;\n// Cache Storage is per origin, so only this app's caches are listed.\nconst IMAGES_CACHE_SUFFIX = \"-images\";\nconst OFFLINE_CACHE_MARKER = \"-offline-\";\n\nasync function imagesCacheName(): Promise<string | undefined> {\n if (typeof caches === \"undefined\") return undefined;\n const keys = await caches.keys();\n const existing = keys.find((key) => key.endsWith(IMAGES_CACHE_SUFFIX));\n if (existing) return existing;\n\n const offline = keys.find((key) => key.includes(OFFLINE_CACHE_MARKER));\n if (!offline) return undefined;\n return `${offline.slice(0, offline.indexOf(OFFLINE_CACHE_MARKER))}${IMAGES_CACHE_SUFFIX}`;\n}\n\n/** The image URL a stored resource carries, if any. */\nexport function imageUrlOf(resource: any): string | undefined {\n const url = resource?.attributes?.imageUrl;\n return typeof url === \"string\" && url.length > 0 ? url : undefined;\n}\n\n/**\n * Makes sure `url` is in the images cache: downloaded when missing. When `previousUrl` is a\n * different URL of the same artwork, its entry is removed. A failed download is logged and\n * retried at the next sync.\n */\nexport async function cacheArtworkImage(url: string, previousUrl?: string): Promise<void> {\n const name = await imagesCacheName();\n if (!name) return;\n const cache = await caches.open(name);\n\n if (previousUrl && previousUrl !== url) await cache.delete(previousUrl);\n if (await cache.match(url)) return;\n\n try {\n const response = await fetch(url);\n if (!response.ok) {\n console.warn(\"[offline] could not download an artwork image\", url, response.status);\n return;\n }\n await cache.put(url, response);\n } catch (error) {\n console.warn(\"[offline] could not download an artwork image\", url, error);\n }\n}\n\n/** Removes the cached copy of `url`. */\nexport async function removeArtworkImage(url: string): Promise<void> {\n const name = await imagesCacheName();\n if (!name) return;\n const cache = await caches.open(name);\n await cache.delete(url);\n}\n\n/** Deletes this app's whole images cache (logout, or another user signing in). */\nexport async function deleteImagesCaches(): Promise<void> {\n if (typeof caches === \"undefined\") return;\n const keys = await caches.keys();\n await Promise.all(keys.filter((key) => key.endsWith(IMAGES_CACHE_SUFFIX)).map((key) => caches.delete(key)));\n}\n","/**\n * Device-copy schema (spec § 1). Every statement is idempotent so it runs on\n * every open; `user_version` records the layout. A file of an older layout is\n * recreated (pre-release: no migration), see `DEVICE_STORE_RESET`.\n *\n * 2: `record_campaigns` (a shared record, e.g. an artwork or a template, belongs\n * to every root record that uses it) and `queue.package_id`.\n * `skipped_records` was added later inside layout 2: `CREATE TABLE IF NOT EXISTS` adds it to\n * an existing file without a reset (a reset would drop unsent changes).\n * 3: campaign columns and tables renamed to root (`campaign_id` -> `root_id`,\n * `record_campaigns` -> `record_roots`).\n */\nexport const DEVICE_STORE_SCHEMA_VERSION = 3;\n\n/**\n * Drops every table of an older layout before the schema is created again. Layout 2's\n * `record_campaigns` is dropped too, so a layout-2 file is fully recreated.\n */\nexport const DEVICE_STORE_RESET: string[] = [\n `DROP TABLE IF EXISTS records`,\n `DROP TABLE IF EXISTS record_campaigns`,\n `DROP TABLE IF EXISTS record_roots`,\n `DROP TABLE IF EXISTS links`,\n `DROP TABLE IF EXISTS queue`,\n `DROP TABLE IF EXISTS conflicts`,\n `DROP TABLE IF EXISTS sync_state`,\n `DROP TABLE IF EXISTS skipped_records`,\n];\n\nexport const DEVICE_STORE_SCHEMA: string[] = [\n `CREATE TABLE IF NOT EXISTS records (\n type TEXT NOT NULL,\n id TEXT NOT NULL,\n root_id TEXT NOT NULL,\n resource TEXT NOT NULL,\n updated_at TEXT,\n base TEXT,\n local_only INTEGER NOT NULL DEFAULT 0,\n PRIMARY KEY (type, id)\n )`,\n `CREATE INDEX IF NOT EXISTS records_type_root ON records (type, root_id)`,\n `CREATE TABLE IF NOT EXISTS record_roots (\n type TEXT NOT NULL,\n id TEXT NOT NULL,\n root_id TEXT NOT NULL,\n PRIMARY KEY (type, id, root_id)\n )`,\n `CREATE INDEX IF NOT EXISTS record_roots_root ON record_roots (root_id, type)`,\n `CREATE TABLE IF NOT EXISTS links (\n from_id TEXT NOT NULL,\n from_type TEXT NOT NULL,\n to_id TEXT NOT NULL,\n to_type TEXT NOT NULL,\n kind TEXT NOT NULL,\n props TEXT NOT NULL DEFAULT '{}',\n root_id TEXT,\n PRIMARY KEY (from_id, to_id, kind)\n )`,\n `CREATE INDEX IF NOT EXISTS links_from ON links (from_id)`,\n `CREATE INDEX IF NOT EXISTS links_to ON links (to_id)`,\n `CREATE INDEX IF NOT EXISTS links_root ON links (root_id)`,\n `CREATE TABLE IF NOT EXISTS queue (\n seq INTEGER PRIMARY KEY AUTOINCREMENT,\n root_id TEXT NOT NULL,\n request TEXT NOT NULL,\n base TEXT,\n created_at TEXT NOT NULL,\n status TEXT NOT NULL DEFAULT 'pending',\n package_id TEXT\n )`,\n `CREATE INDEX IF NOT EXISTS queue_status_seq ON queue (status, seq)`,\n `CREATE TABLE IF NOT EXISTS conflicts (\n id TEXT PRIMARY KEY,\n queue_seq INTEGER NOT NULL,\n root_id TEXT,\n kind TEXT NOT NULL,\n record_type TEXT,\n record_id TEXT,\n mine TEXT,\n theirs TEXT,\n base TEXT,\n merged TEXT,\n fields TEXT NOT NULL DEFAULT '[]',\n blocks TEXT,\n reason TEXT\n )`,\n `CREATE TABLE IF NOT EXISTS sync_state (\n root_id TEXT PRIMARY KEY,\n server_time TEXT,\n locked INTEGER NOT NULL DEFAULT 0,\n locked_from TEXT\n )`,\n `CREATE TABLE IF NOT EXISTS skipped_records (\n root_id TEXT NOT NULL,\n type TEXT NOT NULL,\n id TEXT NOT NULL,\n updated_at TEXT NOT NULL,\n PRIMARY KEY (root_id, type, id)\n )`,\n `PRAGMA user_version = ${DEVICE_STORE_SCHEMA_VERSION}`,\n];\n","import type {\n Conflict,\n DeviceStore,\n QueueEntry,\n QueueStatus,\n SkippedRecord,\n StoredLink,\n StoredRecord,\n SyncState,\n} from \"./DeviceStore\";\nimport { DEVICE_STORE_RESET, DEVICE_STORE_SCHEMA, DEVICE_STORE_SCHEMA_VERSION } from \"./schema\";\n\n/** Runs one SQL statement with positional `?` binds and answers its rows as objects. */\nexport type ExecFn = (sql: string, bind?: unknown[]) => Promise<Record<string, unknown>[]>;\n\nexport type SqlDeviceStoreHooks = {\n /** Runs before a queue entry is written (the lifecycle marks the file as holding unsent changes). */\n beforeEnqueue?: () => Promise<void>;\n /** Runs after a committed change that can shrink the queue, with the number of entries left. */\n afterQueueChange?: (entries: number) => Promise<void>;\n /**\n * Runs each operation with exclusive use of the database across pages (the shared device copy:\n * several pages send statements to one database, and a transaction must not interleave).\n */\n exclusive?: <T>(fn: () => Promise<T>) => Promise<T>;\n};\n\nconst toJson = (value: unknown): string | null =>\n value === undefined || value === null ? null : JSON.stringify(value);\nconst fromJson = (value: unknown): any => (value === null || value === undefined ? null : JSON.parse(String(value)));\nconst toIso = (value: Date | null | undefined): string | null => (value ? value.toISOString() : null);\nconst fromIso = (value: unknown): Date | null =>\n value === null || value === undefined ? null : new Date(String(value));\n\nconst newId = (): string =>\n typeof globalThis.crypto?.randomUUID === \"function\"\n ? globalThis.crypto.randomUUID()\n : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;\n\nconst rowToRecord = (row: Record<string, unknown>): StoredRecord => ({\n type: String(row.type),\n id: String(row.id),\n rootId: String(row.root_id),\n resource: fromJson(row.resource),\n updatedAt: fromIso(row.updated_at),\n base: fromJson(row.base),\n localOnly: Number(row.local_only) === 1,\n});\n\nconst rowToLink = (row: Record<string, unknown>): StoredLink => ({\n fromType: String(row.from_type),\n fromId: String(row.from_id),\n toType: String(row.to_type),\n toId: String(row.to_id),\n kind: String(row.kind),\n props: fromJson(row.props) ?? {},\n});\n\nconst rowToQueueEntry = (row: Record<string, unknown>): QueueEntry => {\n const request = fromJson(row.request) ?? {};\n const entry: QueueEntry = {\n seq: Number(row.seq),\n rootId: String(row.root_id),\n method: request.method,\n path: request.path,\n base: fromJson(row.base),\n createdAt: new Date(String(row.created_at)),\n status: String(row.status) as QueueStatus,\n };\n if (request.body !== undefined) entry.body = request.body;\n if (request.recordType !== undefined) entry.recordType = request.recordType;\n if (request.recordId !== undefined) entry.recordId = request.recordId;\n if (request.resolution !== undefined) entry.resolution = request.resolution;\n if (row.package_id !== null && row.package_id !== undefined) entry.packageId = String(row.package_id);\n return entry;\n};\n\nconst rowToConflict = (row: Record<string, unknown>): Conflict => {\n const conflict: Conflict = {\n id: String(row.id),\n queueSeq: Number(row.queue_seq),\n kind: String(row.kind) as Conflict[\"kind\"],\n mine: fromJson(row.mine),\n theirs: fromJson(row.theirs),\n base: fromJson(row.base),\n fields: fromJson(row.fields) ?? [],\n };\n if (row.record_type !== null && row.record_type !== undefined) conflict.recordType = String(row.record_type);\n if (row.record_id !== null && row.record_id !== undefined) conflict.recordId = String(row.record_id);\n const merged = fromJson(row.merged);\n if (merged !== null) conflict.merged = merged;\n const blocks = fromJson(row.blocks);\n if (blocks !== null) conflict.blocks = blocks;\n if (row.reason !== null && row.reason !== undefined) conflict.reason = String(row.reason);\n return conflict;\n};\n\n/** The statements of every store operation, run over an exec that is already serialised. */\nconst createOperations = (exec: ExecFn, hooks: SqlDeviceStoreHooks) => {\n const insertLink = async (link: StoredLink, rootId: string | null) => {\n await exec(\n `INSERT OR REPLACE INTO links (from_id, from_type, to_id, to_type, kind, props, root_id) VALUES (?, ?, ?, ?, ?, ?, ?)`,\n [link.fromId, link.fromType, link.toId, link.toType, link.kind, JSON.stringify(link.props ?? {}), rootId],\n );\n };\n\n const deleteRecordEverywhere = async (type: string, id: string) => {\n await exec(`DELETE FROM records WHERE type = ? AND id = ?`, [type, id]);\n await exec(`DELETE FROM record_roots WHERE type = ? AND id = ?`, [type, id]);\n await exec(`DELETE FROM links WHERE from_id = ? OR to_id = ?`, [id, id]);\n };\n\n return {\n async getRecord(type: string, id: string): Promise<StoredRecord | undefined> {\n const rows = await exec(`SELECT * FROM records WHERE type = ? AND id = ?`, [type, id]);\n return rows[0] ? rowToRecord(rows[0]) : undefined;\n },\n\n async listRecords(filter: { type: string; rootId?: string; ids?: string[] }): Promise<StoredRecord[]> {\n if (filter.ids && filter.ids.length === 0) return [];\n const where = [\"type = ?\"];\n const bind: unknown[] = [filter.type];\n if (filter.rootId !== undefined) {\n where.push(\n \"EXISTS (SELECT 1 FROM record_roots rc WHERE rc.type = records.type AND rc.id = records.id AND rc.root_id = ?)\",\n );\n bind.push(filter.rootId);\n }\n if (filter.ids) {\n where.push(`id IN (${filter.ids.map(() => \"?\").join(\", \")})`);\n bind.push(...filter.ids);\n }\n const rows = await exec(`SELECT * FROM records WHERE ${where.join(\" AND \")} ORDER BY rowid`, bind);\n return rows.map(rowToRecord);\n },\n\n async putRecord(\n record: Omit<StoredRecord, \"base\" | \"localOnly\"> & { base?: any | null; localOnly?: boolean },\n ): Promise<void> {\n // A record already stored keeps its first root record; the new one is added to its memberships.\n await exec(\n `INSERT INTO records (type, id, root_id, resource, updated_at, base, local_only) VALUES (?, ?, ?, ?, ?, ?, ?)\n ON CONFLICT (type, id) DO UPDATE SET resource = excluded.resource, updated_at = excluded.updated_at,\n base = excluded.base, local_only = excluded.local_only`,\n [\n record.type,\n record.id,\n record.rootId,\n JSON.stringify(record.resource),\n toIso(record.updatedAt),\n toJson(record.base),\n record.localOnly ? 1 : 0,\n ],\n );\n await exec(`INSERT OR IGNORE INTO record_roots (type, id, root_id) VALUES (?, ?, ?)`, [\n record.type,\n record.id,\n record.rootId,\n ]);\n },\n\n async deleteRecord(type: string, id: string): Promise<void> {\n await deleteRecordEverywhere(type, id);\n },\n\n async removeRecordFromRoot(type: string, id: string, rootId: string): Promise<void> {\n await exec(`DELETE FROM record_roots WHERE type = ? AND id = ? AND root_id = ?`, [type, id, rootId]);\n const left = await exec(`SELECT root_id FROM record_roots WHERE type = ? AND id = ? ORDER BY root_id`, [\n type,\n id,\n ]);\n if (left.length === 0) {\n await deleteRecordEverywhere(type, id);\n return;\n }\n await exec(`UPDATE records SET root_id = ? WHERE type = ? AND id = ? AND root_id = ?`, [\n String(left[0].root_id),\n type,\n id,\n rootId,\n ]);\n },\n\n async linksFrom(fromId: string, kind?: string): Promise<StoredLink[]> {\n const rows =\n kind === undefined\n ? await exec(`SELECT * FROM links WHERE from_id = ? ORDER BY rowid`, [fromId])\n : await exec(`SELECT * FROM links WHERE from_id = ? AND kind = ? ORDER BY rowid`, [fromId, kind]);\n return rows.map(rowToLink);\n },\n\n async linksTo(toId: string, kind?: string): Promise<StoredLink[]> {\n const rows =\n kind === undefined\n ? await exec(`SELECT * FROM links WHERE to_id = ? ORDER BY rowid`, [toId])\n : await exec(`SELECT * FROM links WHERE to_id = ? AND kind = ? ORDER BY rowid`, [toId, kind]);\n return rows.map(rowToLink);\n },\n\n async replaceRootLinks(rootId: string, links: StoredLink[]): Promise<void> {\n await exec(`DELETE FROM links WHERE root_id = ?`, [rootId]);\n for (const link of links) await insertLink(link, rootId);\n },\n\n async putLink(link: StoredLink): Promise<void> {\n // A link belongs to the root record of the record at either end, so a later\n // replaceRootLinks / removeRoot of that root record reaches it.\n const owner = await exec(`SELECT root_id FROM records WHERE id IN (?, ?) ORDER BY id = ? DESC LIMIT 1`, [\n link.fromId,\n link.toId,\n link.fromId,\n ]);\n await insertLink(link, owner[0] ? String(owner[0].root_id) : null);\n },\n\n async deleteLink(link: Pick<StoredLink, \"fromId\" | \"toId\" | \"kind\">): Promise<void> {\n await exec(`DELETE FROM links WHERE from_id = ? AND to_id = ? AND kind = ?`, [link.fromId, link.toId, link.kind]);\n },\n\n async enqueue(entry: Omit<QueueEntry, \"seq\" | \"createdAt\" | \"status\">): Promise<number> {\n if (hooks.beforeEnqueue) await hooks.beforeEnqueue();\n const request = {\n method: entry.method,\n path: entry.path,\n body: entry.body,\n recordType: entry.recordType,\n recordId: entry.recordId,\n resolution: entry.resolution,\n };\n const rows = await exec(\n `INSERT INTO queue (root_id, request, base, created_at, status) VALUES (?, ?, ?, ?, 'pending') RETURNING seq`,\n [entry.rootId, JSON.stringify(request), toJson(entry.base), new Date().toISOString()],\n );\n return Number(rows[0].seq);\n },\n\n async queue(filter?: { status?: QueueStatus }): Promise<QueueEntry[]> {\n const rows = filter?.status\n ? await exec(`SELECT * FROM queue WHERE status = ? ORDER BY seq`, [filter.status])\n : await exec(`SELECT * FROM queue ORDER BY seq`);\n return rows.map(rowToQueueEntry);\n },\n\n async setQueueStatus(seq: number, status: QueueStatus): Promise<void> {\n await exec(`UPDATE queue SET status = ? WHERE seq = ?`, [status, seq]);\n },\n\n async setQueuePackage(seqs: number[], packageId: string | null): Promise<void> {\n if (seqs.length === 0) return;\n await exec(`UPDATE queue SET package_id = ? WHERE seq IN (${seqs.map(() => \"?\").join(\", \")})`, [\n packageId,\n ...seqs,\n ]);\n },\n\n async removeQueueEntry(seq: number): Promise<void> {\n await exec(`DELETE FROM queue WHERE seq = ?`, [seq]);\n },\n\n async addConflict(c: Omit<Conflict, \"id\">): Promise<string> {\n const id = newId();\n const owner = await exec(`SELECT root_id FROM queue WHERE seq = ?`, [c.queueSeq]);\n await exec(\n `INSERT INTO conflicts (id, queue_seq, root_id, kind, record_type, record_id, mine, theirs, base, merged, fields, blocks, reason)\n VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,\n [\n id,\n c.queueSeq,\n owner[0] ? String(owner[0].root_id) : null,\n c.kind,\n c.recordType ?? null,\n c.recordId ?? null,\n toJson(c.mine),\n toJson(c.theirs),\n toJson(c.base),\n toJson(c.merged),\n JSON.stringify(c.fields ?? []),\n toJson(c.blocks),\n c.reason ?? null,\n ],\n );\n return id;\n },\n\n async conflicts(): Promise<Conflict[]> {\n const rows = await exec(`SELECT * FROM conflicts ORDER BY queue_seq, rowid`);\n return rows.map(rowToConflict);\n },\n\n async removeConflict(id: string): Promise<void> {\n await exec(`DELETE FROM conflicts WHERE id = ?`, [id]);\n },\n\n async getSyncState(rootId: string): Promise<SyncState> {\n const rows = await exec(`SELECT * FROM sync_state WHERE root_id = ?`, [rootId]);\n if (!rows[0]) return { serverTime: null, locked: false, lockedFrom: null };\n return {\n serverTime: fromIso(rows[0].server_time),\n locked: Number(rows[0].locked) === 1,\n lockedFrom: fromIso(rows[0].locked_from),\n };\n },\n\n async setSyncState(rootId: string, s: SyncState): Promise<void> {\n await exec(`INSERT OR REPLACE INTO sync_state (root_id, server_time, locked, locked_from) VALUES (?, ?, ?, ?)`, [\n rootId,\n toIso(s.serverTime),\n s.locked ? 1 : 0,\n toIso(s.lockedFrom),\n ]);\n },\n\n async skippedRecords(rootId: string): Promise<SkippedRecord[]> {\n const rows = await exec(`SELECT * FROM skipped_records WHERE root_id = ? ORDER BY type, id`, [rootId]);\n return rows.map((row) => ({\n type: String(row.type),\n id: String(row.id),\n updatedAt: new Date(String(row.updated_at)),\n }));\n },\n\n async setSkippedRecords(rootId: string, records: SkippedRecord[]): Promise<void> {\n await exec(`DELETE FROM skipped_records WHERE root_id = ?`, [rootId]);\n for (const record of records) {\n await exec(`INSERT OR REPLACE INTO skipped_records (root_id, type, id, updated_at) VALUES (?, ?, ?, ?)`, [\n rootId,\n record.type,\n record.id,\n record.updatedAt.toISOString(),\n ]);\n }\n },\n\n async removeRoot(rootId: string): Promise<void> {\n // Waiting changes are never dropped: a root record with queue entries is kept whole.\n const waiting = await exec(`SELECT COUNT(*) AS n FROM queue WHERE root_id = ?`, [rootId]);\n if (Number(waiting[0]?.n ?? 0) > 0) return;\n // A shared record another root record still uses stays for that root record; every other\n // record of this root record goes, with its links.\n await exec(`DELETE FROM links WHERE root_id = ?`, [rootId]);\n await exec(`DELETE FROM record_roots WHERE root_id = ?`, [rootId]);\n const orphans = `SELECT id FROM records r WHERE NOT EXISTS (SELECT 1 FROM record_roots rc WHERE rc.type = r.type AND rc.id = r.id)`;\n await exec(`DELETE FROM links WHERE from_id IN (${orphans}) OR to_id IN (${orphans})`);\n await exec(\n `DELETE FROM records WHERE NOT EXISTS (SELECT 1 FROM record_roots rc WHERE rc.type = records.type AND rc.id = records.id)`,\n );\n await exec(\n `UPDATE records SET root_id = (SELECT MIN(rc.root_id) FROM record_roots rc WHERE rc.type = records.type AND rc.id = records.id) WHERE root_id = ?`,\n [rootId],\n );\n await exec(`DELETE FROM conflicts WHERE root_id = ?`, [rootId]);\n await exec(`DELETE FROM sync_state WHERE root_id = ?`, [rootId]);\n await exec(`DELETE FROM skipped_records WHERE root_id = ?`, [rootId]);\n },\n\n async queueSize(): Promise<number> {\n const rows = await exec(`SELECT COUNT(*) AS n FROM queue`);\n return Number(rows[0]?.n ?? 0);\n },\n };\n};\n\ntype Operations = ReturnType<typeof createOperations>;\n\n/** A store whose `transaction` runs `fn` directly: used inside an open transaction. */\nconst nestedStore = (ops: Operations): DeviceStore => {\n const { queueSize: _queueSize, ...rest } = ops;\n const store: DeviceStore = { ...rest, transaction: (fn) => fn(store) };\n return store;\n};\n\n/**\n * The device store over any SQLite `exec` (the worker's OPFS file, or an\n * in-memory database in tests). Every operation is serialised, so a\n * transaction never interleaves with another caller's statements.\n */\nexport async function createSqlDeviceStore(exec: ExecFn, hooks: SqlDeviceStoreHooks = {}): Promise<DeviceStore> {\n const exclusive = hooks.exclusive ?? (<T>(fn: () => Promise<T>) => fn());\n\n // A file of an older layout is recreated (pre-release: no migration of device files).\n await exclusive(async () => {\n const version = Number((await exec(`PRAGMA user_version`))[0]?.user_version ?? 0);\n if (version > 0 && version < DEVICE_STORE_SCHEMA_VERSION) {\n for (const statement of DEVICE_STORE_RESET) await exec(statement);\n }\n for (const statement of DEVICE_STORE_SCHEMA) await exec(statement);\n });\n\n const ops = createOperations(exec, hooks);\n let tail: Promise<unknown> = Promise.resolve();\n const serialised = <T>(fn: () => Promise<T>): Promise<T> => {\n const run = tail.then(\n () => exclusive(fn),\n () => exclusive(fn),\n );\n tail = run.catch(() => undefined);\n return run;\n };\n const settleQueue = async () => {\n if (hooks.afterQueueChange) await hooks.afterQueueChange(await ops.queueSize());\n };\n\n const store: DeviceStore = {\n getRecord: (type, id) => serialised(() => ops.getRecord(type, id)),\n listRecords: (filter) => serialised(() => ops.listRecords(filter)),\n putRecord: (record) => serialised(() => ops.putRecord(record)),\n deleteRecord: (type, id) => serialised(() => ops.deleteRecord(type, id)),\n removeRecordFromRoot: (type, id, rootId) => serialised(() => ops.removeRecordFromRoot(type, id, rootId)),\n linksFrom: (fromId, kind) => serialised(() => ops.linksFrom(fromId, kind)),\n linksTo: (toId, kind) => serialised(() => ops.linksTo(toId, kind)),\n replaceRootLinks: (rootId, links) =>\n serialised(async () => {\n await exec(\"BEGIN\");\n try {\n await ops.replaceRootLinks(rootId, links);\n await exec(\"COMMIT\");\n } catch (error) {\n await exec(\"ROLLBACK\");\n throw error;\n }\n }),\n putLink: (link) => serialised(() => ops.putLink(link)),\n deleteLink: (link) => serialised(() => ops.deleteLink(link)),\n enqueue: (entry) => serialised(() => ops.enqueue(entry)),\n queue: (filter) => serialised(() => ops.queue(filter)),\n setQueueStatus: (seq, status) => serialised(() => ops.setQueueStatus(seq, status)),\n setQueuePackage: (seqs, packageId) => serialised(() => ops.setQueuePackage(seqs, packageId)),\n removeQueueEntry: (seq) =>\n serialised(async () => {\n await ops.removeQueueEntry(seq);\n await settleQueue();\n }),\n addConflict: (c) => serialised(() => ops.addConflict(c)),\n conflicts: () => serialised(() => ops.conflicts()),\n removeConflict: (id) => serialised(() => ops.removeConflict(id)),\n getSyncState: (rootId) => serialised(() => ops.getSyncState(rootId)),\n setSyncState: (rootId, s) => serialised(() => ops.setSyncState(rootId, s)),\n skippedRecords: (rootId) => serialised(() => ops.skippedRecords(rootId)),\n setSkippedRecords: (rootId, records) => serialised(() => ops.setSkippedRecords(rootId, records)),\n removeRoot: (rootId) =>\n serialised(async () => {\n await exec(\"BEGIN\");\n try {\n await ops.removeRoot(rootId);\n await exec(\"COMMIT\");\n } catch (error) {\n await exec(\"ROLLBACK\");\n throw error;\n }\n await settleQueue();\n }),\n transaction: <T>(fn: (tx: DeviceStore) => Promise<T>) =>\n serialised(async () => {\n await exec(\"BEGIN\");\n let result: T;\n try {\n result = await fn(nestedStore(ops));\n await exec(\"COMMIT\");\n } catch (error) {\n await exec(\"ROLLBACK\");\n await settleQueue();\n throw error;\n }\n await settleQueue();\n return result;\n }),\n };\n return store;\n}\n","import type { ExecFn } from \"./sql-device-store\";\nimport type { OpenedDatabase } from \"./worker-client\";\n\n/**\n * One device copy shared by every page of the origin (tabs, the installed app, and the page that\n * is still unloading while the next one loads in the same tab).\n *\n * The browser lets only one page at a time hold the copy's files (opfs-sahpool: one set of access\n * handles per origin). So one page, the holder, opens the database; the others send their\n * statements to it over a BroadcastChannel. The holder is whoever holds the Web Lock\n * `nja-offline-db:<userId>`; every other page waits in that lock's queue and takes over when the\n * holder closes or goes away, retrying the open while the previous holder's files are released.\n *\n * Each store operation runs with exclusive use of the database across all pages (`exclusive`), so\n * a transaction of one page never interleaves with another page's statements.\n */\nexport type SharedDatabase = OpenedDatabase & {\n /** Runs `fn` with exclusive use of the database across every page. */\n exclusive: <T>(fn: () => Promise<T>) => Promise<T>;\n};\n\ntype Message =\n | { kind: \"holder\" }\n | { kind: \"lease\"; id: string }\n | { kind: \"granted\"; id: string }\n | { kind: \"release\"; id: string }\n | { kind: \"exec\"; id: string; lease: string; sql: string; bind?: unknown[] }\n | { kind: \"result\"; id: string; ok: true; rows: Record<string, unknown>[] }\n | { kind: \"result\"; id: string; ok: false; error: string }\n | { kind: \"shutdown\" };\n\n/** How long the holder keeps a lease for a page that never releases it (the page closed). */\nconst LEASE_TIMEOUT_MS = 30_000;\n/** How long a page waits for an answer before giving up on a statement. */\nconst EXEC_TIMEOUT_MS = 30_000;\n/** How long the next holder keeps retrying the open while the previous holder's files are released. */\nconst OPEN_RETRY_MS = 10_000;\n\nconst lockName = (userId: string) => `nja-offline-db:${userId}`;\nconst channelName = (userId: string) => `nja-offline-db:${userId}`;\n\nconst newId = (): string => globalThis.crypto.randomUUID();\n\n/** Whether this browser offers what sharing needs; without it the page opens the copy directly. */\nexport function canShareDatabase(): boolean {\n return (\n typeof BroadcastChannel !== \"undefined\" &&\n typeof navigator !== \"undefined\" &&\n typeof navigator.locks?.request === \"function\"\n );\n}\n\n/** The files are still held by the page that had them (it is closing): worth retrying. */\nconst isHeldElsewhere = (error: unknown): boolean =>\n /NoModificationAllowedError|Access Handles cannot be created|createSyncAccessHandle/i.test(\n String((error as Error)?.message ?? error),\n );\n\nasync function openWithRetry(openLocal: () => Promise<OpenedDatabase>): Promise<OpenedDatabase> {\n const deadline = Date.now() + OPEN_RETRY_MS;\n for (let wait = 100; ; wait = Math.min(wait * 2, 1000)) {\n try {\n return await openLocal();\n } catch (error) {\n if (!isHeldElsewhere(error) || Date.now() >= deadline) throw error;\n await new Promise((resolve) => setTimeout(resolve, wait));\n }\n }\n}\n\n/** A promise chain: one `fn` at a time. */\nfunction createMutex() {\n let tail: Promise<unknown> = Promise.resolve();\n return <T>(fn: () => Promise<T>): Promise<T> => {\n const run = tail.then(fn, fn);\n tail = run.catch(() => undefined);\n return run;\n };\n}\n\n/**\n * Opens the shared device copy of `userId`. `openLocal` opens the database files in this page\n * (the worker); it runs only in the page that holds the lock.\n */\nexport async function openSharedDatabase(\n userId: string,\n openLocal: () => Promise<OpenedDatabase>,\n /** Called when another page asks every page to close the copy (logout, offline mode off). */\n onShutdown?: () => void,\n): Promise<SharedDatabase> {\n const channel = new BroadcastChannel(channelName(userId));\n const post = (message: Message) => channel.postMessage(message);\n const mutex = createMutex();\n\n let local: OpenedDatabase | undefined;\n let created = false;\n let closed = false;\n let releaseLock: (() => void) | undefined;\n const lockAbort = new AbortController();\n\n // Holder side: leases granted to other pages, released by them (or after a timeout).\n const leases = new Map<string, () => void>();\n // Page side: leases asked for and not yet granted, and statements waiting for an answer.\n const waitingLeases = new Map<\n string,\n { run: () => Promise<unknown>; resolve: (v: unknown) => void; reject: (e: Error) => void }\n >();\n const waitingResults = new Map<\n string,\n { resolve: (rows: Record<string, unknown>[]) => void; reject: (e: Error) => void }\n >();\n let currentLease: string | undefined;\n\n const isHolder = () => local !== undefined;\n\n /** Runs a lease granted by the holder: `run` sends its statements under that lease. */\n const runGranted = async (id: string) => {\n const waiting = waitingLeases.get(id);\n if (!waiting) {\n post({ kind: \"release\", id });\n return;\n }\n waitingLeases.delete(id);\n currentLease = id;\n try {\n waiting.resolve(await waiting.run());\n } catch (error) {\n waiting.reject(error as Error);\n } finally {\n currentLease = undefined;\n post({ kind: \"release\", id });\n }\n };\n\n channel.onmessage = (event: MessageEvent<Message>) => {\n const message = event.data;\n if (closed) return;\n switch (message.kind) {\n case \"holder\":\n // A new holder: the lease requests the old one never answered are asked again.\n if (!isHolder()) for (const id of waitingLeases.keys()) post({ kind: \"lease\", id });\n return;\n case \"lease\":\n if (!isHolder() || leases.has(message.id)) return;\n void mutex(\n () =>\n new Promise<void>((done) => {\n const timer = setTimeout(() => finish(), LEASE_TIMEOUT_MS);\n const finish = () => {\n clearTimeout(timer);\n leases.delete(message.id);\n done();\n };\n leases.set(message.id, finish);\n post({ kind: \"granted\", id: message.id });\n }),\n );\n return;\n case \"granted\":\n if (!isHolder()) void runGranted(message.id);\n return;\n case \"release\":\n leases.get(message.id)?.();\n return;\n case \"exec\":\n if (!isHolder() || !leases.has(message.lease)) return;\n local!\n .exec(message.sql, message.bind)\n .then((rows) => post({ kind: \"result\", id: message.id, ok: true, rows }))\n .catch((error) =>\n post({ kind: \"result\", id: message.id, ok: false, error: String(error?.message ?? error) }),\n );\n return;\n case \"result\": {\n const waiting = waitingResults.get(message.id);\n if (!waiting) return;\n waitingResults.delete(message.id);\n if (message.ok) waiting.resolve(message.rows);\n else waiting.reject(new Error(message.error));\n return;\n }\n case \"shutdown\":\n void close().finally(() => onShutdown?.());\n return;\n }\n };\n\n const becomeHolder = async () => {\n local = await openWithRetry(openLocal);\n created = local.created;\n // Leases this page asked for and nobody granted: they run here now.\n for (const [id, waiting] of waitingLeases) {\n waitingLeases.delete(id);\n mutex(waiting.run).then(waiting.resolve, waiting.reject);\n }\n post({ kind: \"holder\" });\n };\n\n /** Holds the lock as the holder until `close`; the lock passes to the next page when this ends. */\n const holdLock = async (): Promise<void> => {\n if (closed) return;\n await becomeHolder();\n await new Promise<void>((resolve) => (releaseLock = resolve));\n };\n\n // Free right now: this page is the holder. Held by another page: this page starts as its client\n // and queues for the lock, to take over when the holder leaves.\n let opened: Promise<void> | undefined;\n const isFree = await new Promise<boolean>((answer) => {\n navigator.locks\n .request(lockName(userId), { ifAvailable: true }, async (lock) => {\n if (!lock) {\n answer(false);\n return;\n }\n answer(true);\n opened = becomeHolder();\n try {\n await opened;\n } catch {\n return; // Could not open: the lock is released for the next page.\n }\n await new Promise<void>((resolve) => (releaseLock = resolve));\n })\n .catch(() => answer(false));\n });\n if (isFree) {\n try {\n await opened;\n } catch (error) {\n closed = true;\n channel.close();\n throw error;\n }\n } else {\n navigator.locks.request(lockName(userId), { signal: lockAbort.signal }, holdLock).catch((error) => {\n if ((error as { name?: string })?.name !== \"AbortError\")\n console.warn(\"[offline] taking over the device copy failed\", error);\n });\n }\n\n const remoteExec: ExecFn = (sql, bind) => {\n if (closed) return Promise.reject(new Error(\"The device database is closed.\"));\n const lease = currentLease;\n if (!lease) return exclusive(() => remoteExec(sql, bind));\n const id = newId();\n return new Promise<Record<string, unknown>[]>((resolve, reject) => {\n const timer = setTimeout(() => {\n waitingResults.delete(id);\n reject(new Error(\"The page holding the device copy did not answer.\"));\n }, EXEC_TIMEOUT_MS);\n waitingResults.set(id, {\n resolve: (rows) => {\n clearTimeout(timer);\n resolve(rows);\n },\n reject: (error) => {\n clearTimeout(timer);\n reject(error);\n },\n });\n post({ kind: \"exec\", id, lease, sql, bind });\n });\n };\n\n const exclusive = <T>(fn: () => Promise<T>): Promise<T> => {\n if (closed) return Promise.reject(new Error(\"The device database is closed.\"));\n if (isHolder()) return mutex(fn);\n const id = newId();\n return new Promise<T>((resolve, reject) => {\n waitingLeases.set(id, {\n run: fn as () => Promise<unknown>,\n resolve: resolve as (v: unknown) => void,\n reject,\n });\n post({ kind: \"lease\", id });\n });\n };\n\n const exec: ExecFn = (sql, bind) => (isHolder() ? local!.exec(sql, bind) : remoteExec(sql, bind));\n\n async function close(): Promise<void> {\n if (closed) return;\n closed = true;\n lockAbort.abort();\n const error = new Error(\"The device database is closed.\");\n for (const waiting of waitingLeases.values()) waiting.reject(error);\n for (const waiting of waitingResults.values()) waiting.reject(error);\n waitingLeases.clear();\n waitingResults.clear();\n for (const finish of [...leases.values()]) finish();\n try {\n if (local) await local.close();\n } finally {\n local = undefined;\n releaseLock?.();\n channel.close();\n }\n }\n\n return {\n get created() {\n return created;\n },\n exec,\n exclusive,\n close,\n };\n}\n\n/**\n * Asks every page to close the shared copy of `userId`, then runs `fn` once no page holds it\n * (logout, offline mode turned off: the files can only be deleted when nobody has them open).\n */\nexport async function withDatabaseReleased<T>(userId: string, fn: () => Promise<T>): Promise<T> {\n if (!canShareDatabase()) return fn();\n const channel = new BroadcastChannel(channelName(userId));\n channel.postMessage({ kind: \"shutdown\" } satisfies Message);\n channel.close();\n return navigator.locks.request(lockName(userId), () => fn()) as Promise<T>;\n}\n","import type { ExecFn } from \"./sql-device-store\";\nimport type { WorkerRequest, WorkerResponse } from \"./worker\";\n\nexport type OpenedDatabase = {\n exec: ExecFn;\n close: () => Promise<void>;\n /** True when the file did not exist before this open. */\n created: boolean;\n};\n\ntype Pending = { resolve: (value: unknown) => void; reject: (error: Error) => void };\n\n/** Starts the device-copy worker and opens `fileName` in it for `userId`. */\nexport async function openWorkerDatabase(\n userId: string,\n fileName: string,\n urls: { workerUrl: string; sqliteModuleUrl: string },\n): Promise<OpenedDatabase> {\n // A plain URL, so the app's bundler leaves it alone: the worker is a standalone file the app\n // serves (OfflineConfig.deviceWorkerUrl), precached by the service worker for offline use.\n const worker = new Worker(urls.workerUrl, { type: \"module\" });\n const { sqliteModuleUrl } = urls;\n const pending = new Map<number, Pending>();\n let nextId = 1;\n let closed = false;\n\n worker.onmessage = (event: MessageEvent<WorkerResponse>) => {\n const response = event.data;\n const waiting = pending.get(response.id);\n if (!waiting) return;\n pending.delete(response.id);\n if (response.ok) waiting.resolve(response.result);\n else waiting.reject(new Error(response.error));\n };\n worker.onerror = (event: ErrorEvent) => {\n const error = new Error(event.message || \"The device database worker failed.\");\n for (const waiting of pending.values()) waiting.reject(error);\n pending.clear();\n };\n\n const send = <T>(request: Omit<WorkerRequest, \"id\"> & { op: WorkerRequest[\"op\"] }): Promise<T> => {\n if (closed) return Promise.reject(new Error(\"The device database is closed.\"));\n const id = nextId++;\n return new Promise<T>((resolve, reject) => {\n pending.set(id, { resolve: resolve as (value: unknown) => void, reject });\n worker.postMessage({ ...request, id } as WorkerRequest);\n });\n };\n\n let opened: { created: boolean };\n try {\n opened = await send<{ created: boolean }>({ op: \"open\", userId, fileName, sqliteModuleUrl } as WorkerRequest);\n } catch (error) {\n worker.terminate();\n throw error;\n }\n\n return {\n created: opened.created,\n exec: (sql, bind) => send<Record<string, unknown>[]>({ op: \"exec\", sql, bind } as WorkerRequest),\n close: async () => {\n if (closed) return;\n try {\n await send({ op: \"close\" } as WorkerRequest);\n } finally {\n closed = true;\n worker.terminate();\n }\n },\n };\n}\n","import type { Sqlite3Static } from \"@sqlite.org/sqlite-wasm\";\nimport {\n DeviceStoreUnavailableError,\n forgetOfflineStore,\n getDeviceWorkerUrl,\n getSqliteModuleUrl,\n isOfflineAvailable,\n isOfflineStoreBlocked,\n markOfflineUnavailable,\n OfflineUnavailableError,\n refreshOfflineDeviceState,\n unblockOfflineStore,\n} from \"../config\";\nimport { deleteImagesCaches } from \"../sync/artwork-cache\";\nimport type { DeviceStore } from \"./DeviceStore\";\nimport { createSqlDeviceStore } from \"./sql-device-store\";\nimport { canShareDatabase, openSharedDatabase, withDatabaseReleased, type SharedDatabase } from \"./shared-database\";\nimport { openWorkerDatabase, type OpenedDatabase } from \"./worker-client\";\n\n/** The part of an OPFS `FileSystemDirectoryHandle` the lifecycle uses (faked in tests). */\nexport type OfflineDirectory = {\n keys(): AsyncIterable<string>;\n getFileHandle(name: string, options?: { create?: boolean }): Promise<unknown>;\n removeEntry(name: string, options?: { recursive?: boolean }): Promise<void>;\n};\n\nexport type DeviceStoreBackend = {\n /** The OPFS root that holds every user's device file. */\n getDirectory: () => Promise<OfflineDirectory>;\n /** Opens `fileName` for `userId` (the worker in the browser). */\n openDatabase: (userId: string, fileName: string) => Promise<OpenedDatabase>;\n /** Asks the browser to keep the device copy (`navigator.storage.persist()`). */\n requestPersistence: () => Promise<void>;\n};\n\nconst FILE_PREFIX = \"nja-offline-\";\nconst deviceFileName = (userId: string) => `${FILE_PREFIX}${userId}.sqlite3`;\n/** Present while the user's file holds unsent changes; read without opening the file. */\nconst pendingMarkerName = (userId: string) => `${FILE_PREFIX}${userId}.pending`;\n/** Names the user whose artwork the images cache holds (the images cache is not per user). */\nconst IMAGES_OWNER_PREFIX = \"nja-offline-images-owner-\";\nconst imagesOwnerName = (userId: string) => `${IMAGES_OWNER_PREFIX}${userId}`;\nconst FILE_PATTERN = /^nja-offline-(.+)\\.(?:sqlite3(?:-journal|-wal|-shm)?|pending)$/;\nconst POOL_PATTERN = /^\\.nja-offline-(.+)$/;\n\n/**\n * Why this browser cannot keep the device copy, or null when it may: browsers expose the private\n * file storage only in a secure context (https, or `localhost`). Checked before any worker starts.\n */\nconst missingDeviceStorage = (): string | null => {\n if (typeof window !== \"undefined\" && window.isSecureContext === false) {\n return \"the page is not served from a secure origin (https or localhost)\";\n }\n if (typeof navigator === \"undefined\" || typeof navigator.storage?.getDirectory !== \"function\") {\n return \"the browser offers no private file storage\";\n }\n return null;\n};\n\nconst assertDeviceStorage = (): void => {\n const reason = missingDeviceStorage();\n if (reason) throw new OfflineUnavailableError(reason);\n};\n\n/** What the SQLite worker answers when the browser lacks the OPFS sync-access handles. */\nconst OPFS_MISSING = /Missing required OPFS APIs/i;\n\nconst defaultBackend: DeviceStoreBackend = {\n getDirectory: async () => {\n assertDeviceStorage();\n try {\n return (await navigator.storage.getDirectory()) as unknown as OfflineDirectory;\n } catch (error) {\n // Storage refused outright (some private windows).\n if ((error as { name?: string })?.name === \"SecurityError\") {\n throw new OfflineUnavailableError(\"the browser refused its private file storage\");\n }\n throw error;\n }\n },\n openDatabase: async (userId, fileName) => {\n assertDeviceStorage();\n const openLocal = () =>\n openWorkerDatabase(userId, fileName, {\n workerUrl: getDeviceWorkerUrl(),\n sqliteModuleUrl: getSqliteModuleUrl(),\n });\n try {\n // Shared by every page of the origin: only one page at a time can hold the files.\n if (!canShareDatabase()) return await openLocal();\n return await openSharedDatabase(userId, openLocal, () => forgetClosedStore(userId));\n } catch (error) {\n if (OPFS_MISSING.test(String((error as Error)?.message ?? error))) {\n throw new OfflineUnavailableError(\"the browser lacks the file access the device copy needs (OPFS)\");\n }\n throw error;\n }\n },\n requestPersistence: async () => {\n if (typeof navigator !== \"undefined\" && navigator.storage?.persist) await navigator.storage.persist();\n },\n};\n\nlet backend: DeviceStoreBackend = defaultBackend;\n\ntype OpenStore = { userId: string; store: Promise<DeviceStore>; database: Promise<OpenedDatabase> };\nlet current: OpenStore | undefined;\n\n/** Replaces the OPFS directory / database opener (tests); `null` restores the browser one. */\nexport function setDeviceStoreBackend(next: Partial<DeviceStoreBackend> | null): void {\n backend = next ? { ...defaultBackend, ...next } : defaultBackend;\n current = undefined;\n}\n\nconst entryExists = async (directory: OfflineDirectory, name: string): Promise<boolean> => {\n for await (const key of directory.keys()) if (key === name) return true;\n return false;\n};\n\nconst removeIfPresent = async (directory: OfflineDirectory, name: string): Promise<void> => {\n try {\n await directory.removeEntry(name, { recursive: true });\n } catch (error) {\n if ((error as { name?: string })?.name !== \"NotFoundError\") throw error;\n }\n};\n\nconst entriesOf = async (directory: OfflineDirectory, userId: string): Promise<string[]> => {\n const names: string[] = [];\n for await (const key of directory.keys()) if (userIdOf(key) === userId) names.push(key);\n return names;\n};\n\n/**\n * The OPFS root, or undefined when this browser cannot keep the device copy: offline mode is then\n * switched off (`markOfflineUnavailable`). Any other failure is thrown as before.\n */\nconst deviceDirectory = async (): Promise<OfflineDirectory | undefined> => {\n if (!isOfflineAvailable()) return undefined;\n try {\n return await backend.getDirectory();\n } catch (error) {\n if (!(error instanceof OfflineUnavailableError)) throw error;\n markOfflineUnavailable(error);\n return undefined;\n }\n};\n\nconst userIdOf = (name: string): string | undefined => FILE_PATTERN.exec(name)?.[1] ?? POOL_PATTERN.exec(name)?.[1];\n\nconst closeCurrent = async (): Promise<void> => {\n const open = current;\n current = undefined;\n if (!open) return;\n try {\n await (await open.database).close();\n } catch {\n // A database that never opened has nothing to close.\n }\n};\n\n/**\n * Opens the device copy of `userId` (`nja-offline-<userId>.sqlite3` in the OPFS,\n * through the worker). Only one user's file is open at a time: opening another\n * user's file closes the previous one first.\n */\nexport function openDeviceStore(userId: string): Promise<DeviceStore> {\n if (!userId) return Promise.reject(new Error(\"openDeviceStore needs the signed-in user id.\"));\n if (isOfflineStoreBlocked(userId)) {\n return Promise.reject(new DeviceStoreUnavailableError(\"The device copy was deleted at logout\"));\n }\n if (!isOfflineAvailable()) {\n return Promise.reject(new OfflineUnavailableError(\"it was found unable to keep the device copy\"));\n }\n if (current?.userId === userId) return current.store;\n\n const previous = closeCurrent();\n const database = previous\n .then(() => backend.openDatabase(userId, deviceFileName(userId)))\n .catch((error) => {\n if (error instanceof OfflineUnavailableError) markOfflineUnavailable(error);\n throw error;\n });\n const store = database.then(async (opened) => {\n if (opened.created) await backend.requestPersistence();\n return createSqlDeviceStore(opened.exec, {\n exclusive: (opened as Partial<SharedDatabase>).exclusive,\n beforeEnqueue: async () => {\n const directory = await backend.getDirectory();\n await directory.getFileHandle(pendingMarkerName(userId), { create: true });\n },\n afterQueueChange: async (entries) => {\n if (entries > 0) return;\n await removeIfPresent(await backend.getDirectory(), pendingMarkerName(userId));\n },\n });\n });\n const open: OpenStore = { userId, store, database };\n current = open;\n store.catch(() => {\n if (current === open) current = undefined;\n });\n return store;\n}\n\nlet sqlite3Promise: Promise<Sqlite3Static> | undefined;\n\n/**\n * An in-memory SQLite database (no worker, no OPFS), for tests in Node. The package is imported\n * here at run time and hidden from app bundlers (see `OfflineConfig.sqliteModuleUrl`).\n */\nexport async function openMemoryDatabase(): Promise<OpenedDatabase> {\n sqlite3Promise ??= import(/* webpackIgnore: true */ /* turbopackIgnore: true */ \"@sqlite.org/sqlite-wasm\").then(\n (module) => module.default(),\n );\n const sqlite3 = await sqlite3Promise;\n const db = new sqlite3.oo1.DB(\":memory:\", \"c\");\n return {\n created: true,\n exec: async (sql, bind) =>\n db.exec({\n sql,\n bind: bind && bind.length ? (bind as any) : undefined,\n rowMode: \"object\",\n returnValue: \"resultRows\",\n }) as Record<string, unknown>[],\n close: async () => {\n db.close();\n },\n };\n}\n\n/** The same store and schema over an in-memory database, for tests. */\nexport async function openMemoryDeviceStore(): Promise<DeviceStore> {\n const database = await openMemoryDatabase();\n return createSqlDeviceStore(database.exec);\n}\n\n/**\n * Another page closed every page's copy of `userId` (logout, offline mode off): this page forgets\n * its store, and reads the device switch again before anything opens the copy.\n */\nfunction forgetClosedStore(userId: string): void {\n if (current?.userId === userId) current = undefined;\n forgetOfflineStore(userId);\n refreshOfflineDeviceState();\n}\n\n/**\n * Closes `userId`'s device copy in every page and removes every OPFS entry of it. The files can\n * only be removed once no page holds them, so the removal waits for every page to let go.\n */\nexport async function deleteDeviceStore(userId: string): Promise<void> {\n if (current?.userId === userId) await closeCurrent();\n const directory = await deviceDirectory();\n if (!directory) return;\n const remove = async () => {\n const names = await entriesOf(directory, userId);\n const marker = pendingMarkerName(userId);\n for (const name of names) if (name !== marker) await removeIfPresent(directory, name);\n await removeIfPresent(directory, marker);\n };\n if (backend === defaultBackend) await withDatabaseReleased(userId, remove);\n else await remove();\n}\n\n/** Whether `userId`'s device copy holds unsent changes. Never opens another user's file. */\nexport async function hasUnsentChanges(userId: string): Promise<boolean> {\n if (current?.userId === userId) {\n const store = await current.store;\n if ((await store.queue()).length > 0) return true;\n }\n const directory = await deviceDirectory();\n if (!directory) return false;\n return entryExists(directory, pendingMarkerName(userId));\n}\n\n/**\n * A user signed in on this browser: their device copy may be opened again (it is blocked after\n * a logout), and every other user's device copy is deleted when it has nothing unsent, and kept\n * untouched (never opened) when it has. When the images cache was not this user's (another user\n * signed in before, or no owner is known), it is deleted: the user's sync downloads their artwork.\n */\nexport async function onUserSignedIn(userId: string): Promise<void> {\n unblockOfflineStore(userId);\n if (current && current.userId !== userId) await closeCurrent();\n // A browser that cannot keep the device copy: offline mode switches off here, before the runtime starts.\n const directory = await deviceDirectory();\n if (!directory) return;\n const others = new Set<string>();\n const imageOwners: string[] = [];\n for await (const key of directory.keys()) {\n if (key.startsWith(IMAGES_OWNER_PREFIX)) {\n imageOwners.push(key);\n continue;\n }\n const owner = userIdOf(key);\n if (owner && owner !== userId) others.add(owner);\n }\n if (!imageOwners.includes(imagesOwnerName(userId))) {\n await deleteImagesCaches();\n for (const name of imageOwners) await removeIfPresent(directory, name);\n await directory.getFileHandle(imagesOwnerName(userId), { create: true });\n }\n for (const other of others) {\n if (await entryExists(directory, pendingMarkerName(other))) continue;\n await deleteDeviceStore(other);\n }\n}\n\n/** Locked now: the server said so, or the lock date it gave has passed. */\nexport function isRootLocked(s: { locked: boolean; lockedFrom: Date | null }, now: Date): boolean {\n if (s.locked) return true;\n return s.lockedFrom !== null && s.lockedFrom.getTime() <= now.getTime();\n}\n"]}
|