@panphora/clayjs 1.24.0 → 1.24.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/all.js +1 -1
- package/dist/clay-dom.js +1 -1
- package/dist/clay-events.js +1 -1
- package/dist/clay-internals.js +1 -1
- package/dist/clay-options.js +1 -1
- package/dist/clay-router.js +1 -1
- package/dist/clay-ui.js +1 -1
- package/dist/clay-utils.js +1 -1
- package/dist/clay.js +1 -1
- package/dist/clay.standalone.js +15016 -13603
- package/dist/m/c-277QP5ET.js +1 -0
- package/dist/m/{c-BULK5J4M.js → c-3UW5EJ77.js} +1 -1
- package/dist/m/{c-GM7OA6EJ.js → c-42ZNV7LI.js} +1 -1
- package/dist/m/{c-RXTPKCMC.js → c-6IJEVE6B.js} +1 -1
- package/dist/m/{c-2R4OBWIP.js → c-6LEYVQJK.js} +1 -1
- package/dist/m/{c-LO4C4J77.js → c-6Q6RGFBF.js} +1 -1
- package/dist/m/{c-PD5AKHY3.js → c-7CM4GMTS.js} +1 -1
- package/dist/m/c-APGHGDZM.js +1 -0
- package/dist/m/{c-Q6VNQDJE.js → c-B7GOFGQR.js} +1 -1
- package/dist/m/{c-RJKKRWHW.js → c-BGN7RCTJ.js} +1 -1
- package/dist/m/{c-A7N5TNSL.js → c-C5AKJVRH.js} +1 -1
- package/dist/m/{c-SNVDWMM3.js → c-CJ7QYEWI.js} +1 -1
- package/dist/m/{c-PVJMJL3P.js → c-CZHTXGJS.js} +1 -1
- package/dist/m/{c-KJHVB2KG.js → c-DDS23EY6.js} +1 -1
- package/dist/m/{c-VSEUFJAO.js → c-DFKBGSN2.js} +1 -1
- package/dist/m/{c-DLBX4JCJ.js → c-EDE45NM7.js} +1 -1
- package/dist/m/c-EILLN2H2.js +1 -0
- package/dist/m/{c-EY5KDUZF.js → c-FEC6LGFF.js} +1 -1
- package/dist/m/{c-PMRRMT72.js → c-HDVH3MVC.js} +1 -1
- package/dist/m/c-HKSLOVU4.js +1 -0
- package/dist/m/{c-GNIVHLEJ.js → c-HL6BLJNA.js} +1 -1
- package/dist/m/c-HTLYXCUH.js +1 -0
- package/dist/m/{c-RTSRGGTP.js → c-JQF2EUCG.js} +1 -1
- package/dist/m/{c-O5ARALMJ.js → c-JWDCQL6J.js} +1 -1
- package/dist/m/{c-WOELSNWS.js → c-LATNQ6Q5.js} +1 -1
- package/dist/m/{c-UM7VZDTW.js → c-LSVN3OLG.js} +1 -1
- package/dist/m/{c-HSNGLIFB.js → c-MFOSMAVM.js} +2 -2
- package/dist/m/{c-FZRZPIFS.js → c-MQYEDF3Q.js} +1 -1
- package/dist/m/{c-2OIKXOY6.js → c-ND3Q2G2I.js} +1 -1
- package/dist/m/{c-PZRWZZRO.js → c-OCYG4NBQ.js} +1 -1
- package/dist/m/{c-54ZZZYUN.js → c-P2HKMJAW.js} +1 -1
- package/dist/m/{c-VI3BJHCM.js → c-PCNJETUA.js} +1 -1
- package/dist/m/{c-7YETSAVY.js → c-PDBNCJCT.js} +1 -1
- package/dist/m/{c-A5ZBFICF.js → c-PYG2NGWU.js} +1 -1
- package/dist/m/{c-RBPZNU2D.js → c-QQFHEODI.js} +1 -1
- package/dist/m/{c-YINHPF4V.js → c-SGH3UTF3.js} +1 -1
- package/dist/m/{c-FV46ZKGX.js → c-SKMIR5HQ.js} +1 -1
- package/dist/m/{c-TWOXQQKV.js → c-STRI73GQ.js} +1 -1
- package/dist/m/{c-75N3KG35.js → c-T3P3AXEW.js} +1 -1
- package/dist/m/c-U4NTUVDZ.js +1 -0
- package/dist/m/{c-P5NKFRL4.js → c-UOOIF2AL.js} +1 -1
- package/dist/m/c-VHSMPITY.js +1 -0
- package/dist/m/{c-6UPEKW6O.js → c-X75G43SZ.js} +1 -1
- package/dist/m/{c-YGGO2G2L.js → c-XW5TIYYB.js} +1 -1
- package/dist/m/{c-DZPO22O3.js → c-YDW4VCHP.js} +1 -1
- package/dist/m/{c-YB3CDTDZ.js → c-YLHTD3KX.js} +1 -1
- package/dist/m/c-Z4G2UK6T.js +4 -0
- package/dist/m/{c-VNAYHV2J.js → c-ZGCO4J3H.js} +1 -1
- package/dist/m/core/{admin-attrs-5UGMYCKW.js → admin-attrs-ONRWVXPK.js} +1 -1
- package/dist/m/core/{autosave-4P66VXX7.js → autosave-TBKAAAFB.js} +1 -1
- package/dist/m/core/{conflict-notice-BAR23OWH.js → conflict-notice-FOEE6I7E.js} +1 -1
- package/dist/m/core/draft-mode-4MCL3LYI.js +1 -0
- package/dist/m/core/{host-permission-3HE4CFD6.js → host-permission-X4BENZEU.js} +1 -1
- package/dist/m/core/{is-edit-mode-GK7IFFMT.js → is-edit-mode-FFHCQ5DR.js} +1 -1
- package/dist/m/core/{persist-AIJGTKKI.js → persist-6ILUR34S.js} +1 -1
- package/dist/m/core/{recovery-LPB6X72P.js → recovery-APWADU4K.js} +1 -1
- package/dist/m/core/{save-FV4ZCKBI.js → save-6HT2LABE.js} +1 -1
- package/dist/m/core/{save-core-K7XFAWQ4.js → save-core-EVMNZQ4V.js} +1 -1
- package/dist/m/core/{site-edits-TEQORBJU.js → site-edits-G4BOZMV4.js} +1 -1
- package/dist/m/core/{unsaved-warning-FF63QGKI.js → unsaved-warning-ITZDIZ2I.js} +1 -1
- package/dist/m/internals/{index-AX4CG7B2.js → index-HS3CQDFW.js} +1 -1
- package/dist/m/loader-GLLVDUZA.js +1 -0
- package/dist/m/manifest-50FF1C45.js +1 -0
- package/dist/m/manifest-edit-BE1A621D.js +1 -0
- package/dist/m/manifest.json +710 -674
- package/dist/m/plugins/{ai-edit-CUKQQWJX.js → ai-edit-LABECCSN.js} +1 -1
- package/dist/m/plugins/{cms-JANMGPHM.js → cms-T2HLI7JG.js} +16 -16
- package/dist/m/plugins/{demo-JIR7DXJA.js → demo-T7B4RIKL.js} +1 -1
- package/dist/m/plugins/{derive-QVI3LI3A.js → derive-6LPJOCAV.js} +1 -1
- package/dist/m/plugins/{indicator-DAKZGAWI.js → indicator-J4ABFARX.js} +1 -1
- package/dist/m/plugins/{live-UAJGWRNP.js → live-PGBIEMGB.js} +1 -1
- package/dist/m/plugins/{menu-BFJ644YX.js → menu-YZL3X2AJ.js} +1 -1
- package/dist/m/plugins/page-WOCNX2IN.js +2 -0
- package/dist/m/plugins/{page-view-OYN7Z5TS.js → page-view-SJODOOGE.js} +1 -1
- package/dist/m/plugins/{people-VBDR5Y22.js → people-C4TCWFOQ.js} +1 -1
- package/dist/m/plugins/{richclay-CBLZRFB4.js → richclay-Y2NV44NA.js} +3 -3
- package/dist/m/plugins/sortable-XCHQG65Z.js +1 -0
- package/dist/m/plugins/{source-ZXDD7F6X.js → source-YK7MO23W.js} +1 -1
- package/dist/m/plugins/{undo-5JI5G7CQ.js → undo-VLRBQNPN.js} +1 -1
- package/dist/m/plugins/{upgrade-6B6QS3NR.js → upgrade-NNTJBSK2.js} +1 -1
- package/dist/m/plugins/{upload-DNJNW6H3.js → upload-ZKKJV76X.js} +1 -1
- package/dist/m/plugins/visitor-drafts-RTZNONHT.js +20 -0
- package/dist/m/plugins/{visitor-drafts-owner-VIAZC3CF.js → visitor-drafts-owner-LHUHFU3L.js} +1 -1
- package/dist/m/plugins/{visitor-menu-ZHKWIKKY.js → visitor-menu-FY4KM2SW.js} +1 -1
- package/dist/m/plugins/wire-M2NR354P.js +1 -0
- package/dist/m/{reader-core-F6RY3V64.js → reader-core-T2S3JC4E.js} +2 -2
- package/dist/m/sync/{live-sync-M2PP2IWY.js → live-sync-Z64YREWQ.js} +1 -1
- package/dist/m/vendor/{sap-core.vendor-OA5SKL7Q.js → sap-core.vendor-QAEPMETX.js} +1 -1
- package/dist/offline-manifest.json +455 -425
- package/entries/clay-data.js +1 -1
- package/entries/sap.js +1 -1
- package/package.json +2 -2
- package/src/core/draft-mode.js +6 -0
- package/src/core/save-core.js +7 -8
- package/src/core/unsaved-warning.js +2 -2
- package/src/lib/idb-error-code.js +20 -0
- package/src/loader.js +2 -0
- package/src/plugins/page.js +19 -5
- package/src/plugins/sortable.js +7 -0
- package/src/plugins/visitor-drafts/commit.js +303 -0
- package/src/plugins/visitor-drafts/idb.js +221 -0
- package/src/plugins/visitor-drafts/prompt.js +23 -3
- package/src/plugins/visitor-drafts/session.js +345 -0
- package/src/plugins/visitor-drafts/store.js +210 -26
- package/src/plugins/visitor-drafts.js +148 -28
- package/src/vendor/hyper-html-api-data.vendor.js +1 -1
- package/src/vendor/hyper-html-api-upgrade.vendor.js +2 -2
- package/src/vendor/hypercms.vendor.js +16 -16
- package/src/vendor/richclay.vendor.js +1 -1
- package/src/vendor/sap-core.vendor.js +1 -1
- package/dist/m/c-234RSEIW.js +0 -1
- package/dist/m/c-2YIJ6ZA6.js +0 -1
- package/dist/m/c-H2H7TI6O.js +0 -1
- package/dist/m/c-JMDKPBMC.js +0 -4
- package/dist/m/c-N7JPUPGC.js +0 -1
- package/dist/m/c-NEXFVJIT.js +0 -1
- package/dist/m/c-T527QHOB.js +0 -1
- package/dist/m/core/draft-mode-WSQAT67S.js +0 -1
- package/dist/m/loader-5IA5OJ3J.js +0 -1
- package/dist/m/manifest-98258645.js +0 -1
- package/dist/m/manifest-edit-ABF56000.js +0 -1
- package/dist/m/plugins/page-ZBIYHGK4.js +0 -2
- package/dist/m/plugins/sortable-HDOQ6GAC.js +0 -1
- package/dist/m/plugins/visitor-drafts-A5FDQ2G7.js +0 -20
- package/dist/m/plugins/wire-XOT7XKJL.js +0 -1
- /package/dist/m/plugins/{richclay-CBLZRFB4.js.LEGAL.txt → richclay-Y2NV44NA.js.LEGAL.txt} +0 -0
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
18
|
import { isEditMode, isDraftMode } from "./is-edit-mode.js";
|
|
19
|
-
import { draftStoreFailed, flushDraft } from "./draft-mode.js";
|
|
19
|
+
import { draftStoreFailed, draftUnconfirmed, flushDraft } from "./draft-mode.js";
|
|
20
20
|
import { captureForDirtyCheck } from "./snapshot.js";
|
|
21
21
|
import { getLastSavedDirty, sessionParked, styleAdoptionPending } from "./save.js";
|
|
22
22
|
import { logUnloadDiffSync, preloadIfEnabled } from "../lib/autosave-debug-gate.js";
|
|
@@ -57,7 +57,7 @@ export function hasUnsavedChanges() {
|
|
|
57
57
|
// Work outside the DOM first: it needs no capture, and a capture that throws
|
|
58
58
|
// must not hide it. The demo plugin does not save it either, so it warns there too.
|
|
59
59
|
if (hasUnsavedState()) return true;
|
|
60
|
-
if (isDraftMode) return draftStoreFailed();
|
|
60
|
+
if (isDraftMode) return draftStoreFailed() || draftUnconfirmed();
|
|
61
61
|
|
|
62
62
|
// The demo plugin saves the page's bytes into this browser's own storage, so
|
|
63
63
|
// leaving loses none of them.
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The failure code a thrown IndexedDB error means to a caller.
|
|
3
|
+
*
|
|
4
|
+
* Only one of these is about this device being full (`quota`); the rest are about
|
|
5
|
+
* the database being unreachable, refused, or mid-upgrade somewhere else. None of
|
|
6
|
+
* them is a save result, and none of them is durable.
|
|
7
|
+
*
|
|
8
|
+
* @param {*} error
|
|
9
|
+
* @returns {string}
|
|
10
|
+
*/
|
|
11
|
+
export function codeFor(error) {
|
|
12
|
+
const name = error?.name || "";
|
|
13
|
+
if (name === "QuotaExceededError" || name === "NS_ERROR_DOM_QUOTA_REACHED") return "quota";
|
|
14
|
+
if (name === "VersionError") return "unknown-schema";
|
|
15
|
+
if (name === "DataCloneError") return "uncloneable";
|
|
16
|
+
if (name === "AbortError") return "aborted";
|
|
17
|
+
if (name === "TransactionInactiveError" || name === "InvalidStateError") return "aborted";
|
|
18
|
+
if (name === "SecurityError" || name === "UnknownError" || name === "NotSupportedError") return "unavailable";
|
|
19
|
+
return "error";
|
|
20
|
+
}
|
package/src/loader.js
CHANGED
|
@@ -174,6 +174,8 @@ export async function boot(base, params, readyResolve, manifest = null, suppleme
|
|
|
174
174
|
continue;
|
|
175
175
|
}
|
|
176
176
|
loaded[path] = mod;
|
|
177
|
+
// A draft held only in the database goes onto the page before a plugin that reads the page loads.
|
|
178
|
+
if (path === "plugins/visitor-drafts.js") await mod.restoring;
|
|
177
179
|
if (path === "plugins/page-view.js") mod.start(readerOptions);
|
|
178
180
|
attachPluginMember(path, mod, loaded); // immediately, not after the loop: hypercms's ?cms=true
|
|
179
181
|
// auto-open runs as a microtask queued during ITS evaluation
|
package/src/plugins/page.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { FEATURES, featureForNode, featureForType } from "../page/features/registry.js";
|
|
2
2
|
import { mergeEngine } from "../sync/merge-engine.js";
|
|
3
3
|
import { registerUnsavedState } from "../lib/unsaved-state.js";
|
|
4
|
+
import Mutation from "../lib/mutation.js";
|
|
4
5
|
import { hostMeta } from "../core/host-meta.js";
|
|
5
6
|
import { savePageForce, holdAllSaves, releaseAllSaves, suspendAutosave, resumeAutosave, getLastSavedBytes, savesAdmitted, claimFailure } from "../core/save.js";
|
|
6
7
|
import { setSaveHeader, onSaveAccepted } from "../core/save-core.js";
|
|
@@ -1250,9 +1251,16 @@ function mountAndAttach(inst, source, loaded) {
|
|
|
1250
1251
|
if (shell) shell.inert = true;
|
|
1251
1252
|
if (loaded.featureIds.length && shell) shell.style.visibility = "hidden";
|
|
1252
1253
|
// One synchronous move: the finished shell replaces the saved surface as a
|
|
1253
|
-
// single change, and the article element itself stays where it is.
|
|
1254
|
-
|
|
1255
|
-
|
|
1254
|
+
// single change, and the article element itself stays where it is. The editor
|
|
1255
|
+
// chunk can land after the boot settle, so the move is paused: it is the editor
|
|
1256
|
+
// presenting the saved surface, never an edit to save.
|
|
1257
|
+
Mutation.pause();
|
|
1258
|
+
try {
|
|
1259
|
+
if (!loaded.featureIds.length) for (const node of savedContent) node.remove();
|
|
1260
|
+
if (shell) article.appendChild(shell);
|
|
1261
|
+
} finally {
|
|
1262
|
+
Mutation.resume();
|
|
1263
|
+
}
|
|
1256
1264
|
loaded.refreshView?.(view);
|
|
1257
1265
|
binding.attach(view);
|
|
1258
1266
|
// The page's history tells whoever presents it once it exists (a menu opened before the
|
|
@@ -1462,9 +1470,15 @@ function mountAndAttach(inst, source, loaded) {
|
|
|
1462
1470
|
// the commit that enters edit cannot fail here.
|
|
1463
1471
|
function openInput(inst, mounted) {
|
|
1464
1472
|
const shell = mounted.view.dom.closest(".blockeditor-shell");
|
|
1473
|
+
// Like the boot move, revealing the editor over the saved surface is presentation, not an edit.
|
|
1465
1474
|
const showEditor = () => {
|
|
1466
|
-
|
|
1467
|
-
|
|
1475
|
+
Mutation.pause();
|
|
1476
|
+
try {
|
|
1477
|
+
for (const node of savedContent) node.remove();
|
|
1478
|
+
if (shell) shell.style.visibility = "";
|
|
1479
|
+
} finally {
|
|
1480
|
+
Mutation.resume();
|
|
1481
|
+
}
|
|
1468
1482
|
};
|
|
1469
1483
|
// A replacement that landed during the feature wait keeps the editor read-only,
|
|
1470
1484
|
// exactly as one that landed before it does.
|
package/src/plugins/sortable.js
CHANGED
|
@@ -188,6 +188,13 @@ function makeSortable(sortableElem, Sortable, record = loose) {
|
|
|
188
188
|
let options = {};
|
|
189
189
|
options.draggable = draggableSelector(sortableElem);
|
|
190
190
|
|
|
191
|
+
// A reorder is not a text drag. SortableJS writes the item's text onto the drag by
|
|
192
|
+
// default, and a rich text field under the drop pastes it in, duplicating the text.
|
|
193
|
+
options.setData = (dataTransfer) => {
|
|
194
|
+
dataTransfer.clearData();
|
|
195
|
+
dataTransfer.setData('application/x-clay-sortable', '');
|
|
196
|
+
};
|
|
197
|
+
|
|
191
198
|
// Check if Sortable instance already exists
|
|
192
199
|
if (Sortable.get(sortableElem)) return;
|
|
193
200
|
|
|
@@ -0,0 +1,303 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one change a visitor draft's stored state ever takes.
|
|
3
|
+
*
|
|
4
|
+
* IndexedDB holds one record per draft key K, and only `transact()` changes it: one
|
|
5
|
+
* readwrite transaction reads what is stored, runs `commit()` over it, and writes the
|
|
6
|
+
* result. IndexedDB runs readwrite transactions on a store one after another across every
|
|
7
|
+
* tab of the origin, so this is the single writer, with no lock and no leader tab.
|
|
8
|
+
*
|
|
9
|
+
* What it applies:
|
|
10
|
+
*
|
|
11
|
+
* - operations: for each top-level key a tab changed, the value before (`b`) and after
|
|
12
|
+
* (`m`), a missing one meaning the key was absent. Each carries its own merge base, so
|
|
13
|
+
* the store's `merge3` lays it over whatever is stored. A tab's operations are a
|
|
14
|
+
* chain `{tab, reset, acked, ops: [{seq, savedAt, keys, unit?}]}`, where `reset` is
|
|
15
|
+
* the Start over marker of the generation the tab shows (a tab knows that before its
|
|
16
|
+
* first transaction; it does not know the number). An operation applies only when
|
|
17
|
+
* its chain's `reset` is the stored one and its `seq` is the next for its tab, so
|
|
18
|
+
* each applies exactly once and in order, whichever tab delivers it;
|
|
19
|
+
* - Start over: "end generation g", given explicitly or as a marker `<g+1>~<random>`
|
|
20
|
+
* in `localStorage`; an unverified marker starts empty and keeps recovery copies;
|
|
21
|
+
* - the move: the full version 1 record in `localStorage`, taken in as the first record
|
|
22
|
+
* when nothing is stored yet;
|
|
23
|
+
* - another writer's record: a version 1 record in `localStorage` without the cache's
|
|
24
|
+
* `w` field, written by a tab on a released ClayJS or by a page load of this one that
|
|
25
|
+
* had no database. It is taken in once: merged when the copy it was built on is held
|
|
26
|
+
* here, kept whole as a recovery copy when it is not. Nothing is merged on a guess.
|
|
27
|
+
*
|
|
28
|
+
* Stored rows (JSON text): K (the record, today's format), `K:meta`
|
|
29
|
+
* `{gen, rev, reset, ended?, tabs: {tab: {seq, at}}, foreign, unit, recovery?}`, `K:v1base` (the
|
|
30
|
+
* last record of another writer that was merged), `K:op:<tab>` (a chain a tab left at a
|
|
31
|
+
* hide), `K:conflict:<id>` with `K:conflict` naming the latest, and `K:recovery:<mark>`.
|
|
32
|
+
* `meta.unit` is the mark of the Block editor page the record holds (`unitMark`). A record
|
|
33
|
+
* id or ancestry identifies another writer's held base or the reset its copy follows.
|
|
34
|
+
* Operations of this ClayJS never read one. Recovery rows can also hold exact ended chains.
|
|
35
|
+
*
|
|
36
|
+
* This module imports nothing: the page fetches it with the database, after the boot, and
|
|
37
|
+
* the store that is already on the page hands it `merge3` and `unitMark`.
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
export const LINEAGE = 32;
|
|
41
|
+
// Recovery copies kept per draft, newest first.
|
|
42
|
+
export const RECOVERY_KEEP = 8;
|
|
43
|
+
// A tab not heard from in this long is forgotten; a chain it still sends carries `acked`.
|
|
44
|
+
export const TAB_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
45
|
+
|
|
46
|
+
const clone = (value) => (value === undefined ? undefined : JSON.parse(JSON.stringify(value)));
|
|
47
|
+
const isPlain = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
48
|
+
|
|
49
|
+
export const metaKey = (key) => key + ":meta";
|
|
50
|
+
export const rowKey = (key, tab) => `${key}:op:${tab}`;
|
|
51
|
+
export const conflictKey = (key, id) => (id === undefined ? key + ":conflict" : `${key}:conflict:${id}`);
|
|
52
|
+
export const recoveryKey = (key, mark) => `${key}:recovery:${mark}`;
|
|
53
|
+
|
|
54
|
+
// A version 1 record without the cache's `w` field is another writer's.
|
|
55
|
+
export const isForeign = (value) => isPlain(value) && value.v === 1 && value.w === undefined && isPlain(value.data);
|
|
56
|
+
// What names one: its id, or its time for a record from before ids (ClayJS 1.22.0 and earlier).
|
|
57
|
+
export const foreignMark = (record) => (typeof record.id === "string" ? record.id : "at:" + record.savedAt);
|
|
58
|
+
|
|
59
|
+
export const markerFor = (gen, nonce) => `${gen}~${nonce}`;
|
|
60
|
+
export function markerGen(marker) {
|
|
61
|
+
const match = /^(\d+)~/.exec(marker ?? "");
|
|
62
|
+
return match ? Number(match[1]) : null;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// One operation's keys laid over `data` with the store's three-way merge.
|
|
66
|
+
export function applyKeys(data, keys, merge3) {
|
|
67
|
+
const base = { ...data };
|
|
68
|
+
const mine = { ...data };
|
|
69
|
+
for (const [name, pair] of Object.entries(keys)) {
|
|
70
|
+
if ("b" in pair) base[name] = pair.b; else delete base[name];
|
|
71
|
+
if ("m" in pair) mine[name] = pair.m; else delete mine[name];
|
|
72
|
+
}
|
|
73
|
+
return merge3(base, mine, data);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export const isChain = (value) => isPlain(value) && typeof value.tab === "string" && typeof value.reset === "string" && Array.isArray(value.ops);
|
|
77
|
+
export const recoveryMark = (copy) => isChain(copy) ? `op:${copy.tab}:${copy.ops.at(-1)?.seq ?? copy.acked ?? 0}` : foreignMark(copy);
|
|
78
|
+
|
|
79
|
+
// Cleanup needs proof for this exact chain, even when its generation has ended.
|
|
80
|
+
export function settled(chain, meta) {
|
|
81
|
+
if (isPlain(chain) && meta.recovery?.includes(recoveryMark(chain))) return true;
|
|
82
|
+
if (!isChain(chain)) return false;
|
|
83
|
+
const at = Math.max(chain.acked ?? 0, chain.reset === meta.reset ? meta.tabs[chain.tab]?.seq ?? 0 : 0);
|
|
84
|
+
return chain.ops.every((op) => op.seq <= at);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function emptyMeta(marker = null) {
|
|
88
|
+
return { gen: markerGen(marker) ?? 1, rev: 0, reset: marker ?? "", tabs: {}, foreign: null, unit: null };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const unitOf = (record) => (record && record.blockeditor ? { live: record.live, blockeditor: record.blockeditor } : null);
|
|
92
|
+
|
|
93
|
+
// The ids a record built on `record` descends from, nearest first.
|
|
94
|
+
function lineage(first, record, meta) {
|
|
95
|
+
const line = [...first, record?.id, ...(record?.ancestry ?? [])].filter((id) => typeof id === "string");
|
|
96
|
+
return line.length ? line.slice(0, LINEAGE) : ["root:" + meta.reset];
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// The copy another writer's record was built on, when this side holds it and what it held
|
|
100
|
+
// is in the stored record: the stored record itself, a cache copy of this generation, or
|
|
101
|
+
// the last record of another writer that was merged. A record that descends from one of
|
|
102
|
+
// them (its ancestry names it, or a storage event says it replaced that cache copy)
|
|
103
|
+
// carries every change made since as its difference; the nearest one is the base.
|
|
104
|
+
function foreignBase(line, { record, seen, direct, v1base, meta }) {
|
|
105
|
+
const cache = isPlain(seen) && seen.v === 1 && isPlain(seen.data) && seen.w?.reset === meta.reset ? seen : null;
|
|
106
|
+
const at = (held) => (held && typeof held.id === "string" ? line.indexOf(held.id) : -1);
|
|
107
|
+
const held = [[record, at(record)], [cache, cache && direct ? 0 : at(cache)], [v1base, at(v1base)]].filter(([, index]) => index >= 0);
|
|
108
|
+
return held.length ? held.reduce((nearest, next) => (next[1] < nearest[1] ? next : nearest))[0] : null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* @param {{meta: ?Object, record: ?Object, v1base: ?Object}} stored
|
|
113
|
+
* @param {Object} input
|
|
114
|
+
* @param {Object} input.seed - the data a generation starts from (the served page's)
|
|
115
|
+
* @param {() => string} input.newId
|
|
116
|
+
* @param {number} input.now - milliseconds
|
|
117
|
+
* @param {Function} input.merge3 - the store's three-way merge
|
|
118
|
+
* @param {Function} input.unitMark - the store's mark of a Block editor page
|
|
119
|
+
* @param {?string} [input.marker] - the Start over marker `localStorage` holds now
|
|
120
|
+
* @param {Array<{ends: number, marker: string}>} [input.resets]
|
|
121
|
+
* @param {Array<Object>} [input.chains] - oldest carriers first, the caller's own last
|
|
122
|
+
* @param {?Object} [input.local] - what `localStorage` holds at the draft's key now, parsed
|
|
123
|
+
* @param {?Object} [input.seen] - the latest cache copy the caller saw at the draft's key
|
|
124
|
+
* @param {boolean} [input.direct] - a storage event says `local` replaced exactly that copy
|
|
125
|
+
*/
|
|
126
|
+
export function commit(stored, { seed, newId, now, merge3, unitMark, marker = null, resets = [], chains = [], local = null, seen = null, direct = false }) {
|
|
127
|
+
const meta = stored.meta ? clone(stored.meta) : emptyMeta(marker);
|
|
128
|
+
// A database the browser cleared counts on from this ClayJS's cache, so the cache never
|
|
129
|
+
// reads as newer than the record.
|
|
130
|
+
if (!stored.meta && Number.isInteger(local?.w?.rev)) meta.rev = local.w.rev;
|
|
131
|
+
let record = stored.record ? clone(stored.record) : null;
|
|
132
|
+
let v1base = stored.v1base ? clone(stored.v1base) : null;
|
|
133
|
+
const out = { conflicts: [], recovered: [], released: [], dropped: [], stale: [], applied: {}, changed: !stored.meta };
|
|
134
|
+
|
|
135
|
+
function recover(copy, supersedes = []) {
|
|
136
|
+
const mark = recoveryMark(copy);
|
|
137
|
+
if (meta.recovery?.includes(mark)) return;
|
|
138
|
+
out.recovered.push(clone(copy));
|
|
139
|
+
const kept = [mark, ...(meta.recovery ?? []).filter((held) => held !== mark && !supersedes.includes(held))].slice(0, RECOVERY_KEEP);
|
|
140
|
+
out.dropped = [...new Set([...out.dropped, ...(meta.recovery ?? [])])].filter((held) => !kept.includes(held));
|
|
141
|
+
meta.recovery = kept;
|
|
142
|
+
out.changed = true;
|
|
143
|
+
return true;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const ends = [...resets];
|
|
147
|
+
if (marker && marker !== meta.reset) ends.push({ ends: markerGen(marker) === null ? null : markerGen(marker) - 1, marker });
|
|
148
|
+
for (const reset of ends) {
|
|
149
|
+
if (reset.marker === meta.reset || meta.ended?.includes(reset.marker) || (reset.ends !== null && reset.ends < meta.gen)) continue;
|
|
150
|
+
if (reset.ends !== meta.gen) {
|
|
151
|
+
if (record) recover(record);
|
|
152
|
+
}
|
|
153
|
+
const follows = typeof local?.reset === "string" ? local.reset === reset.marker
|
|
154
|
+
: reset.ends === null || (Array.isArray(local?.ancestry) && local.ancestry.includes("root:" + reset.marker));
|
|
155
|
+
if (isForeign(local) && foreignMark(local) !== meta.foreign && !follows) {
|
|
156
|
+
recover(local);
|
|
157
|
+
meta.foreign = foreignMark(local);
|
|
158
|
+
}
|
|
159
|
+
meta.ended = [...(meta.ended ?? []), meta.reset].slice(-LINEAGE);
|
|
160
|
+
Object.assign(meta, { gen: meta.gen + 1, reset: reset.marker, tabs: {}, unit: null });
|
|
161
|
+
meta.rev++;
|
|
162
|
+
record = null;
|
|
163
|
+
v1base = null;
|
|
164
|
+
out.changed = true;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Nothing stored yet: the full copy in `localStorage` is the first record, whether a
|
|
168
|
+
// 1.23.0 tab wrote it (the move) or it is this ClayJS's cache, of this generation, of a
|
|
169
|
+
// database the browser has since cleared.
|
|
170
|
+
if (!stored.meta && !record && isPlain(local) && local.v === 1 && isPlain(local.data) && (local.w === undefined || local.w.reset === meta.reset)) {
|
|
171
|
+
const { w, ...copy } = local;
|
|
172
|
+
record = clone(copy);
|
|
173
|
+
if (w === undefined) {
|
|
174
|
+
v1base = clone(copy);
|
|
175
|
+
meta.foreign = foreignMark(local);
|
|
176
|
+
meta.rev++;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (!stored.meta) meta.unit = unitMark(unitOf(record));
|
|
180
|
+
|
|
181
|
+
// Another writer's record met after the move, taken in once and before any chain.
|
|
182
|
+
if (stored.meta && isForeign(local) && foreignMark(local) !== meta.foreign) {
|
|
183
|
+
const mark = foreignMark(local);
|
|
184
|
+
const line = Array.isArray(local.ancestry) ? local.ancestry : [];
|
|
185
|
+
const base = record ? foreignBase(line, { record, seen, direct, v1base, meta }) : null;
|
|
186
|
+
if (typeof local.reset === "string" && meta.ended?.includes(local.reset)) {
|
|
187
|
+
if (recover(local, line)) out.released.push(clone(local));
|
|
188
|
+
} else if (!record) {
|
|
189
|
+
// Nothing is stored in this generation: its page is the draft, as it is between two
|
|
190
|
+
// tabs of that ClayJS.
|
|
191
|
+
record = clone(local);
|
|
192
|
+
v1base = clone(local);
|
|
193
|
+
meta.unit = unitMark(unitOf(record));
|
|
194
|
+
meta.rev++;
|
|
195
|
+
} else if (base) {
|
|
196
|
+
const merged = merge3(base.data, local.data, record.data);
|
|
197
|
+
// A page it carries counts as changed only when it differs from its base's.
|
|
198
|
+
const page = unitOf(local);
|
|
199
|
+
const paged = Boolean(page) && unitMark(page) !== unitMark(unitOf(base));
|
|
200
|
+
const pageMoved = paged && unitOf(record) && unitMark(unitOf(base)) !== meta.unit;
|
|
201
|
+
if (merged.conflict || pageMoved) out.conflicts.push(clone(record));
|
|
202
|
+
const unit = paged ? page : unitOf(record);
|
|
203
|
+
record = {
|
|
204
|
+
v: 1,
|
|
205
|
+
savedAt: local.savedAt ?? new Date(now).toISOString(),
|
|
206
|
+
id: newId(),
|
|
207
|
+
ancestry: lineage(typeof local.id === "string" ? [local.id] : [], record, meta),
|
|
208
|
+
data: merged.value,
|
|
209
|
+
...(unit && { live: unit.live, blockeditor: unit.blockeditor }),
|
|
210
|
+
};
|
|
211
|
+
if (paged) meta.unit = unitMark(unit);
|
|
212
|
+
v1base = clone(local);
|
|
213
|
+
meta.rev++;
|
|
214
|
+
} else {
|
|
215
|
+
// The copy it was built on is not held here: it is kept whole, beside the draft. A
|
|
216
|
+
// later copy of the same line replaces the one it descends from.
|
|
217
|
+
if (recover(local, line)) out.released.push(clone(local));
|
|
218
|
+
}
|
|
219
|
+
meta.foreign = mark;
|
|
220
|
+
out.changed = true;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
for (const chain of chains) {
|
|
224
|
+
if (!isChain(chain)) continue;
|
|
225
|
+
if (chain.reset !== meta.reset) {
|
|
226
|
+
const acked = chain.reset === stored.meta?.reset ? stored.meta.tabs[chain.tab]?.seq ?? 0 : 0;
|
|
227
|
+
if (chain.ops.some((op) => op.seq > Math.max(acked, chain.acked ?? 0))) {
|
|
228
|
+
recover(chain);
|
|
229
|
+
}
|
|
230
|
+
if (!out.stale.includes(chain.tab)) out.stale.push(chain.tab);
|
|
231
|
+
continue;
|
|
232
|
+
}
|
|
233
|
+
const before = meta.tabs[chain.tab]?.seq ?? 0;
|
|
234
|
+
let at = Math.max(before, Number.isInteger(chain.acked) ? chain.acked : 0);
|
|
235
|
+
for (const op of chain.ops) {
|
|
236
|
+
if (op.seq <= at) continue;
|
|
237
|
+
if (op.seq !== at + 1) break;
|
|
238
|
+
const merged = applyKeys(record ? record.data : seed, op.keys ?? {}, merge3);
|
|
239
|
+
const unitMoved = op.unit !== undefined && record && unitOf(record) && op.unit.seen !== meta.unit;
|
|
240
|
+
if (record && (merged.conflict || unitMoved)) out.conflicts.push(clone(record));
|
|
241
|
+
const unit = op.unit !== undefined ? op.unit.to : unitOf(record);
|
|
242
|
+
const id = newId();
|
|
243
|
+
record = {
|
|
244
|
+
v: 1,
|
|
245
|
+
savedAt: op.savedAt ?? new Date(now).toISOString(),
|
|
246
|
+
id,
|
|
247
|
+
ancestry: lineage([], record, meta),
|
|
248
|
+
data: merged.value,
|
|
249
|
+
...(unit && { live: unit.live, blockeditor: unit.blockeditor }),
|
|
250
|
+
};
|
|
251
|
+
if (op.unit !== undefined) meta.unit = unitMark(unit);
|
|
252
|
+
meta.rev++;
|
|
253
|
+
at = op.seq;
|
|
254
|
+
out.changed = true;
|
|
255
|
+
}
|
|
256
|
+
if (at !== before || meta.tabs[chain.tab]) {
|
|
257
|
+
meta.tabs[chain.tab] = { seq: at, at: now };
|
|
258
|
+
out.changed = true;
|
|
259
|
+
}
|
|
260
|
+
out.applied[chain.tab] = at;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
for (const [tab, entry] of Object.entries(meta.tabs)) {
|
|
264
|
+
if (now - (entry.at ?? 0) > TAB_TTL_MS) {
|
|
265
|
+
delete meta.tabs[tab];
|
|
266
|
+
out.changed = true;
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return { meta, record, v1base, ...out };
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
const parse = (text) => {
|
|
273
|
+
if (text == null) return null;
|
|
274
|
+
try { return JSON.parse(text); } catch { return null; }
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Run `commit()` in one readwrite transaction and write what it returns. `input` is a
|
|
279
|
+
* function, called inside the transaction once its reads are in, so what it reads from
|
|
280
|
+
* `localStorage` (journal slots, the marker, the cache) is read at that moment.
|
|
281
|
+
* Resolves on the transaction's `complete` event with `commit()`'s result.
|
|
282
|
+
*/
|
|
283
|
+
export function transact(drafts, key, input, options) {
|
|
284
|
+
return drafts.change(key, (raw) => {
|
|
285
|
+
const stored = { meta: parse(raw.meta), record: parse(raw.record), v1base: parse(raw.v1base) };
|
|
286
|
+
const rows = raw.rows.map(([name, text]) => [name, parse(text)]);
|
|
287
|
+
const given = input(stored);
|
|
288
|
+
const result = commit(stored, { ...given, chains: [...rows.map(([, chain]) => chain), ...(given.chains ?? [])] });
|
|
289
|
+
const puts = [];
|
|
290
|
+
const deletes = [];
|
|
291
|
+
if (result.changed) {
|
|
292
|
+
puts.push([metaKey(key), JSON.stringify(result.meta)]);
|
|
293
|
+
if (result.record) puts.push([key, JSON.stringify(result.record)]); else if (stored.record) deletes.push(key);
|
|
294
|
+
if (result.v1base) puts.push([key + ":v1base", JSON.stringify(result.v1base)]); else if (stored.v1base) deletes.push(key + ":v1base");
|
|
295
|
+
}
|
|
296
|
+
for (const copy of result.conflicts) puts.push([conflictKey(key, foreignMark(copy)), JSON.stringify(copy)]);
|
|
297
|
+
if (result.conflicts.length) puts.push([conflictKey(key), JSON.stringify(result.conflicts[result.conflicts.length - 1])]);
|
|
298
|
+
for (const copy of result.recovered) puts.push([recoveryKey(key, recoveryMark(copy)), JSON.stringify(copy)]);
|
|
299
|
+
for (const mark of result.dropped) deletes.push(recoveryKey(key, mark));
|
|
300
|
+
for (const [name, chain] of rows) if (settled(chain, result.meta)) deletes.push(name);
|
|
301
|
+
return { puts, deletes, result };
|
|
302
|
+
}, options);
|
|
303
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The IndexedDB half of the visitor draft store.
|
|
3
|
+
*
|
|
4
|
+
* One database per origin (`clay-visitor-drafts`), two stores:
|
|
5
|
+
*
|
|
6
|
+
* records key: the draft's text key (`clay:visitor-drafts:<slot>@<path>`) and its
|
|
7
|
+
* siblings `<key>:<name>`; value: JSON text
|
|
8
|
+
* parts key: `[recordKey, regionKey, partId]`; value: `{ rev, text }`
|
|
9
|
+
*
|
|
10
|
+
* Every change to a draft goes through `change()`, one readwrite transaction that reads,
|
|
11
|
+
* merges (the caller's `build`) and writes; see `commit.js`. The only other write is
|
|
12
|
+
* `putRow()`, a tab's own chain left blind at a hide. A transaction resolves only once it
|
|
13
|
+
* completes. Every failure rejects with an Error whose `code` says what happened
|
|
14
|
+
* (`unavailable`, `blocked`, `quota`, `aborted`, `unknown-schema`, `closed`, `bad-op`,
|
|
15
|
+
* `error`).
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { codeFor } from "../../lib/idb-error-code.js";
|
|
19
|
+
|
|
20
|
+
export const DB_NAME = "clay-visitor-drafts";
|
|
21
|
+
export const DB_VERSION = 1;
|
|
22
|
+
export const RECORDS = "records";
|
|
23
|
+
export const PARTS = "parts";
|
|
24
|
+
const OPEN_TIMEOUT_MS = 2000;
|
|
25
|
+
|
|
26
|
+
function failure(code, cause) {
|
|
27
|
+
const error = new Error(`visitor-drafts: the draft database failed (${code})`);
|
|
28
|
+
error.code = code;
|
|
29
|
+
if (cause !== undefined) error.cause = cause;
|
|
30
|
+
return error;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function cursorEntries(source, range) {
|
|
34
|
+
return new Promise((resolve, reject) => {
|
|
35
|
+
const out = [];
|
|
36
|
+
const req = source.openCursor(range);
|
|
37
|
+
req.onerror = () => reject(req.error);
|
|
38
|
+
req.onsuccess = () => {
|
|
39
|
+
const cursor = req.result;
|
|
40
|
+
if (!cursor) return resolve(out);
|
|
41
|
+
out.push([cursor.key, cursor.value]);
|
|
42
|
+
cursor.continue();
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Open (and, the first time, create) the draft database.
|
|
49
|
+
*
|
|
50
|
+
* A database that does not open within `timeout` milliseconds (another tab holds an
|
|
51
|
+
* upgrade, or the browser never answers) rejects with `blocked`, so the caller can
|
|
52
|
+
* refuse to write over a draft it could not read rather than wait forever.
|
|
53
|
+
*
|
|
54
|
+
* @param {Object} [options]
|
|
55
|
+
* @param {?IDBFactory} [options.factory]
|
|
56
|
+
* @param {?typeof IDBKeyRange} [options.keyRange]
|
|
57
|
+
* @returns {Promise<{readAll: Function, readParts: Function, change: Function, putRow: Function, close: Function}>}
|
|
58
|
+
*/
|
|
59
|
+
export function openDrafts({ factory = globalThis.indexedDB, keyRange = globalThis.IDBKeyRange, name = DB_NAME, timeout = OPEN_TIMEOUT_MS } = {}) {
|
|
60
|
+
return new Promise((resolve, reject) => {
|
|
61
|
+
if (!factory || typeof factory.open !== "function" || !keyRange) return reject(failure("unavailable"));
|
|
62
|
+
let req;
|
|
63
|
+
try {
|
|
64
|
+
req = factory.open(name, DB_VERSION);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
return reject(failure(codeFor(error), error));
|
|
67
|
+
}
|
|
68
|
+
let settled = false;
|
|
69
|
+
const finish = (fn, value) => {
|
|
70
|
+
if (settled) return false;
|
|
71
|
+
settled = true;
|
|
72
|
+
clearTimeout(timer);
|
|
73
|
+
fn(value);
|
|
74
|
+
return true;
|
|
75
|
+
};
|
|
76
|
+
const timer = setTimeout(() => finish(reject, failure("blocked")), timeout);
|
|
77
|
+
req.onupgradeneeded = () => {
|
|
78
|
+
const db = req.result;
|
|
79
|
+
if (!db.objectStoreNames.contains(RECORDS)) db.createObjectStore(RECORDS);
|
|
80
|
+
if (!db.objectStoreNames.contains(PARTS)) db.createObjectStore(PARTS);
|
|
81
|
+
};
|
|
82
|
+
req.onsuccess = () => {
|
|
83
|
+
const db = req.result;
|
|
84
|
+
// An open that answered after the timeout is closed again, or it would hold the
|
|
85
|
+
// database against another tab's upgrade for as long as this page stays open.
|
|
86
|
+
if (!finish(resolve, wrap(db, keyRange))) db.close();
|
|
87
|
+
};
|
|
88
|
+
req.onerror = () => finish(reject, failure(codeFor(req.error), req.error));
|
|
89
|
+
req.onblocked = () => finish(reject, failure("blocked"));
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function wrap(db, keyRange) {
|
|
94
|
+
let closed = false;
|
|
95
|
+
// Another tab opening a newer version needs this connection to stand aside.
|
|
96
|
+
db.onversionchange = () => {
|
|
97
|
+
closed = true;
|
|
98
|
+
try { db.close(); } catch {}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
function transaction(stores, mode) {
|
|
102
|
+
if (closed) throw failure("closed");
|
|
103
|
+
try {
|
|
104
|
+
return db.transaction(stores, mode);
|
|
105
|
+
} catch (error) {
|
|
106
|
+
throw failure(codeFor(error), error);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// The record named `recordKey` and its siblings (`<recordKey>:<anything>`), as a Map
|
|
111
|
+
// from key to stored text. A key that only starts with `recordKey`, such as another
|
|
112
|
+
// document whose path extends this one's, is not a sibling.
|
|
113
|
+
async function readAll(recordKey) {
|
|
114
|
+
const tx = transaction(RECORDS, "readonly");
|
|
115
|
+
const range = keyRange.bound(recordKey, recordKey + ":");
|
|
116
|
+
try {
|
|
117
|
+
const entries = await cursorEntries(tx.objectStore(RECORDS), range);
|
|
118
|
+
return new Map(entries.filter(([key]) => key === recordKey || key.startsWith(recordKey + ":")));
|
|
119
|
+
} catch (error) {
|
|
120
|
+
throw failure(codeFor(error), error);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// The parts of one region of one record, as a Map from part id to `{ rev, text }`.
|
|
125
|
+
// An array sorts after every string, so `[recordKey, regionKey, []]` bounds them all.
|
|
126
|
+
async function readParts(recordKey, regionKey) {
|
|
127
|
+
const tx = transaction(PARTS, "readonly");
|
|
128
|
+
const range = keyRange.bound([recordKey, regionKey], [recordKey, regionKey, []]);
|
|
129
|
+
try {
|
|
130
|
+
const entries = await cursorEntries(tx.objectStore(PARTS), range);
|
|
131
|
+
return new Map(entries.map(([key, value]) => [key[2], value]));
|
|
132
|
+
} catch (error) {
|
|
133
|
+
throw failure(codeFor(error), error);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// The one read-merge-write. Reads the record, `:meta`, `:v1base` and every `:op:` row of
|
|
138
|
+
// `key`, hands them to `build` inside the transaction, and writes the `puts` and
|
|
139
|
+
// `deletes` it returns before the transaction can complete. A `build` or a write that
|
|
140
|
+
// throws aborts it, so nothing lands. Resolves with `build`'s `result` on `complete`.
|
|
141
|
+
function change(key, build, { timeout = 0 } = {}) {
|
|
142
|
+
return new Promise((resolve, reject) => {
|
|
143
|
+
let tx;
|
|
144
|
+
try {
|
|
145
|
+
tx = transaction([RECORDS, PARTS], "readwrite");
|
|
146
|
+
} catch (error) {
|
|
147
|
+
return reject(error);
|
|
148
|
+
}
|
|
149
|
+
let thrown = null;
|
|
150
|
+
let result;
|
|
151
|
+
let settled = false;
|
|
152
|
+
let timer;
|
|
153
|
+
const finish = (fn, value) => {
|
|
154
|
+
if (settled) return;
|
|
155
|
+
settled = true;
|
|
156
|
+
clearTimeout(timer);
|
|
157
|
+
fn(value);
|
|
158
|
+
};
|
|
159
|
+
if (timeout > 0) timer = setTimeout(() => {
|
|
160
|
+
thrown = failure("blocked");
|
|
161
|
+
try { tx.abort(); } catch {}
|
|
162
|
+
finish(reject, thrown);
|
|
163
|
+
}, timeout);
|
|
164
|
+
tx.oncomplete = () => finish(resolve, result);
|
|
165
|
+
tx.onabort = () => finish(reject, thrown || failure(tx.error ? codeFor(tx.error) : "aborted", tx.error));
|
|
166
|
+
try {
|
|
167
|
+
const store = tx.objectStore(RECORDS);
|
|
168
|
+
const rows = keyRange.bound(key + ":op:", key + ":op:\uffff");
|
|
169
|
+
const reads = { record: store.get(key), meta: store.get(key + ":meta"), v1base: store.get(key + ":v1base"), names: store.getAllKeys(rows), values: store.getAll(rows) };
|
|
170
|
+
// Requests in one transaction answer in the order they were made: the last one in
|
|
171
|
+
// means every read is.
|
|
172
|
+
reads.values.onsuccess = () => {
|
|
173
|
+
if (settled) return;
|
|
174
|
+
try {
|
|
175
|
+
const raw = {
|
|
176
|
+
record: reads.record.result ?? null,
|
|
177
|
+
meta: reads.meta.result ?? null,
|
|
178
|
+
v1base: reads.v1base.result ?? null,
|
|
179
|
+
rows: reads.names.result.map((name, i) => [name, reads.values.result[i]]),
|
|
180
|
+
};
|
|
181
|
+
const planned = build(raw);
|
|
182
|
+
for (const [name, value] of planned.puts) store.put(value, name);
|
|
183
|
+
for (const name of planned.deletes) store.delete(name);
|
|
184
|
+
result = planned.result;
|
|
185
|
+
} catch (error) {
|
|
186
|
+
thrown = failure(codeFor(error), error);
|
|
187
|
+
try { tx.abort(); } catch {}
|
|
188
|
+
}
|
|
189
|
+
};
|
|
190
|
+
} catch (error) {
|
|
191
|
+
thrown = failure(codeFor(error), error);
|
|
192
|
+
try { tx.abort(); } catch {}
|
|
193
|
+
}
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// A tab's own unconfirmed chain, written blind (no read) at a hide, with an explicit
|
|
198
|
+
// commit so it needs no callback in a page about to go away. `change()` applies it later.
|
|
199
|
+
function putRow(name, value) {
|
|
200
|
+
return new Promise((resolve, reject) => {
|
|
201
|
+
if (!/:op:[^:]+$/.test(name)) return reject(failure("bad-op"));
|
|
202
|
+
let tx;
|
|
203
|
+
try {
|
|
204
|
+
tx = transaction(RECORDS, "readwrite");
|
|
205
|
+
tx.objectStore(RECORDS).put(value, name);
|
|
206
|
+
tx.commit?.();
|
|
207
|
+
} catch (error) {
|
|
208
|
+
return reject(error instanceof Error && error.code ? error : failure(codeFor(error), error));
|
|
209
|
+
}
|
|
210
|
+
tx.oncomplete = () => resolve();
|
|
211
|
+
tx.onabort = () => reject(failure(tx.error ? codeFor(tx.error) : "aborted", tx.error));
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function close() {
|
|
216
|
+
closed = true;
|
|
217
|
+
try { db.close(); } catch {}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return { readAll, readParts, change, putRow, close };
|
|
221
|
+
}
|