@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.
Files changed (136) hide show
  1. package/dist/all.js +1 -1
  2. package/dist/clay-dom.js +1 -1
  3. package/dist/clay-events.js +1 -1
  4. package/dist/clay-internals.js +1 -1
  5. package/dist/clay-options.js +1 -1
  6. package/dist/clay-router.js +1 -1
  7. package/dist/clay-ui.js +1 -1
  8. package/dist/clay-utils.js +1 -1
  9. package/dist/clay.js +1 -1
  10. package/dist/clay.standalone.js +15016 -13603
  11. package/dist/m/c-277QP5ET.js +1 -0
  12. package/dist/m/{c-BULK5J4M.js → c-3UW5EJ77.js} +1 -1
  13. package/dist/m/{c-GM7OA6EJ.js → c-42ZNV7LI.js} +1 -1
  14. package/dist/m/{c-RXTPKCMC.js → c-6IJEVE6B.js} +1 -1
  15. package/dist/m/{c-2R4OBWIP.js → c-6LEYVQJK.js} +1 -1
  16. package/dist/m/{c-LO4C4J77.js → c-6Q6RGFBF.js} +1 -1
  17. package/dist/m/{c-PD5AKHY3.js → c-7CM4GMTS.js} +1 -1
  18. package/dist/m/c-APGHGDZM.js +1 -0
  19. package/dist/m/{c-Q6VNQDJE.js → c-B7GOFGQR.js} +1 -1
  20. package/dist/m/{c-RJKKRWHW.js → c-BGN7RCTJ.js} +1 -1
  21. package/dist/m/{c-A7N5TNSL.js → c-C5AKJVRH.js} +1 -1
  22. package/dist/m/{c-SNVDWMM3.js → c-CJ7QYEWI.js} +1 -1
  23. package/dist/m/{c-PVJMJL3P.js → c-CZHTXGJS.js} +1 -1
  24. package/dist/m/{c-KJHVB2KG.js → c-DDS23EY6.js} +1 -1
  25. package/dist/m/{c-VSEUFJAO.js → c-DFKBGSN2.js} +1 -1
  26. package/dist/m/{c-DLBX4JCJ.js → c-EDE45NM7.js} +1 -1
  27. package/dist/m/c-EILLN2H2.js +1 -0
  28. package/dist/m/{c-EY5KDUZF.js → c-FEC6LGFF.js} +1 -1
  29. package/dist/m/{c-PMRRMT72.js → c-HDVH3MVC.js} +1 -1
  30. package/dist/m/c-HKSLOVU4.js +1 -0
  31. package/dist/m/{c-GNIVHLEJ.js → c-HL6BLJNA.js} +1 -1
  32. package/dist/m/c-HTLYXCUH.js +1 -0
  33. package/dist/m/{c-RTSRGGTP.js → c-JQF2EUCG.js} +1 -1
  34. package/dist/m/{c-O5ARALMJ.js → c-JWDCQL6J.js} +1 -1
  35. package/dist/m/{c-WOELSNWS.js → c-LATNQ6Q5.js} +1 -1
  36. package/dist/m/{c-UM7VZDTW.js → c-LSVN3OLG.js} +1 -1
  37. package/dist/m/{c-HSNGLIFB.js → c-MFOSMAVM.js} +2 -2
  38. package/dist/m/{c-FZRZPIFS.js → c-MQYEDF3Q.js} +1 -1
  39. package/dist/m/{c-2OIKXOY6.js → c-ND3Q2G2I.js} +1 -1
  40. package/dist/m/{c-PZRWZZRO.js → c-OCYG4NBQ.js} +1 -1
  41. package/dist/m/{c-54ZZZYUN.js → c-P2HKMJAW.js} +1 -1
  42. package/dist/m/{c-VI3BJHCM.js → c-PCNJETUA.js} +1 -1
  43. package/dist/m/{c-7YETSAVY.js → c-PDBNCJCT.js} +1 -1
  44. package/dist/m/{c-A5ZBFICF.js → c-PYG2NGWU.js} +1 -1
  45. package/dist/m/{c-RBPZNU2D.js → c-QQFHEODI.js} +1 -1
  46. package/dist/m/{c-YINHPF4V.js → c-SGH3UTF3.js} +1 -1
  47. package/dist/m/{c-FV46ZKGX.js → c-SKMIR5HQ.js} +1 -1
  48. package/dist/m/{c-TWOXQQKV.js → c-STRI73GQ.js} +1 -1
  49. package/dist/m/{c-75N3KG35.js → c-T3P3AXEW.js} +1 -1
  50. package/dist/m/c-U4NTUVDZ.js +1 -0
  51. package/dist/m/{c-P5NKFRL4.js → c-UOOIF2AL.js} +1 -1
  52. package/dist/m/c-VHSMPITY.js +1 -0
  53. package/dist/m/{c-6UPEKW6O.js → c-X75G43SZ.js} +1 -1
  54. package/dist/m/{c-YGGO2G2L.js → c-XW5TIYYB.js} +1 -1
  55. package/dist/m/{c-DZPO22O3.js → c-YDW4VCHP.js} +1 -1
  56. package/dist/m/{c-YB3CDTDZ.js → c-YLHTD3KX.js} +1 -1
  57. package/dist/m/c-Z4G2UK6T.js +4 -0
  58. package/dist/m/{c-VNAYHV2J.js → c-ZGCO4J3H.js} +1 -1
  59. package/dist/m/core/{admin-attrs-5UGMYCKW.js → admin-attrs-ONRWVXPK.js} +1 -1
  60. package/dist/m/core/{autosave-4P66VXX7.js → autosave-TBKAAAFB.js} +1 -1
  61. package/dist/m/core/{conflict-notice-BAR23OWH.js → conflict-notice-FOEE6I7E.js} +1 -1
  62. package/dist/m/core/draft-mode-4MCL3LYI.js +1 -0
  63. package/dist/m/core/{host-permission-3HE4CFD6.js → host-permission-X4BENZEU.js} +1 -1
  64. package/dist/m/core/{is-edit-mode-GK7IFFMT.js → is-edit-mode-FFHCQ5DR.js} +1 -1
  65. package/dist/m/core/{persist-AIJGTKKI.js → persist-6ILUR34S.js} +1 -1
  66. package/dist/m/core/{recovery-LPB6X72P.js → recovery-APWADU4K.js} +1 -1
  67. package/dist/m/core/{save-FV4ZCKBI.js → save-6HT2LABE.js} +1 -1
  68. package/dist/m/core/{save-core-K7XFAWQ4.js → save-core-EVMNZQ4V.js} +1 -1
  69. package/dist/m/core/{site-edits-TEQORBJU.js → site-edits-G4BOZMV4.js} +1 -1
  70. package/dist/m/core/{unsaved-warning-FF63QGKI.js → unsaved-warning-ITZDIZ2I.js} +1 -1
  71. package/dist/m/internals/{index-AX4CG7B2.js → index-HS3CQDFW.js} +1 -1
  72. package/dist/m/loader-GLLVDUZA.js +1 -0
  73. package/dist/m/manifest-50FF1C45.js +1 -0
  74. package/dist/m/manifest-edit-BE1A621D.js +1 -0
  75. package/dist/m/manifest.json +710 -674
  76. package/dist/m/plugins/{ai-edit-CUKQQWJX.js → ai-edit-LABECCSN.js} +1 -1
  77. package/dist/m/plugins/{cms-JANMGPHM.js → cms-T2HLI7JG.js} +16 -16
  78. package/dist/m/plugins/{demo-JIR7DXJA.js → demo-T7B4RIKL.js} +1 -1
  79. package/dist/m/plugins/{derive-QVI3LI3A.js → derive-6LPJOCAV.js} +1 -1
  80. package/dist/m/plugins/{indicator-DAKZGAWI.js → indicator-J4ABFARX.js} +1 -1
  81. package/dist/m/plugins/{live-UAJGWRNP.js → live-PGBIEMGB.js} +1 -1
  82. package/dist/m/plugins/{menu-BFJ644YX.js → menu-YZL3X2AJ.js} +1 -1
  83. package/dist/m/plugins/page-WOCNX2IN.js +2 -0
  84. package/dist/m/plugins/{page-view-OYN7Z5TS.js → page-view-SJODOOGE.js} +1 -1
  85. package/dist/m/plugins/{people-VBDR5Y22.js → people-C4TCWFOQ.js} +1 -1
  86. package/dist/m/plugins/{richclay-CBLZRFB4.js → richclay-Y2NV44NA.js} +3 -3
  87. package/dist/m/plugins/sortable-XCHQG65Z.js +1 -0
  88. package/dist/m/plugins/{source-ZXDD7F6X.js → source-YK7MO23W.js} +1 -1
  89. package/dist/m/plugins/{undo-5JI5G7CQ.js → undo-VLRBQNPN.js} +1 -1
  90. package/dist/m/plugins/{upgrade-6B6QS3NR.js → upgrade-NNTJBSK2.js} +1 -1
  91. package/dist/m/plugins/{upload-DNJNW6H3.js → upload-ZKKJV76X.js} +1 -1
  92. package/dist/m/plugins/visitor-drafts-RTZNONHT.js +20 -0
  93. package/dist/m/plugins/{visitor-drafts-owner-VIAZC3CF.js → visitor-drafts-owner-LHUHFU3L.js} +1 -1
  94. package/dist/m/plugins/{visitor-menu-ZHKWIKKY.js → visitor-menu-FY4KM2SW.js} +1 -1
  95. package/dist/m/plugins/wire-M2NR354P.js +1 -0
  96. package/dist/m/{reader-core-F6RY3V64.js → reader-core-T2S3JC4E.js} +2 -2
  97. package/dist/m/sync/{live-sync-M2PP2IWY.js → live-sync-Z64YREWQ.js} +1 -1
  98. package/dist/m/vendor/{sap-core.vendor-OA5SKL7Q.js → sap-core.vendor-QAEPMETX.js} +1 -1
  99. package/dist/offline-manifest.json +455 -425
  100. package/entries/clay-data.js +1 -1
  101. package/entries/sap.js +1 -1
  102. package/package.json +2 -2
  103. package/src/core/draft-mode.js +6 -0
  104. package/src/core/save-core.js +7 -8
  105. package/src/core/unsaved-warning.js +2 -2
  106. package/src/lib/idb-error-code.js +20 -0
  107. package/src/loader.js +2 -0
  108. package/src/plugins/page.js +19 -5
  109. package/src/plugins/sortable.js +7 -0
  110. package/src/plugins/visitor-drafts/commit.js +303 -0
  111. package/src/plugins/visitor-drafts/idb.js +221 -0
  112. package/src/plugins/visitor-drafts/prompt.js +23 -3
  113. package/src/plugins/visitor-drafts/session.js +345 -0
  114. package/src/plugins/visitor-drafts/store.js +210 -26
  115. package/src/plugins/visitor-drafts.js +148 -28
  116. package/src/vendor/hyper-html-api-data.vendor.js +1 -1
  117. package/src/vendor/hyper-html-api-upgrade.vendor.js +2 -2
  118. package/src/vendor/hypercms.vendor.js +16 -16
  119. package/src/vendor/richclay.vendor.js +1 -1
  120. package/src/vendor/sap-core.vendor.js +1 -1
  121. package/dist/m/c-234RSEIW.js +0 -1
  122. package/dist/m/c-2YIJ6ZA6.js +0 -1
  123. package/dist/m/c-H2H7TI6O.js +0 -1
  124. package/dist/m/c-JMDKPBMC.js +0 -4
  125. package/dist/m/c-N7JPUPGC.js +0 -1
  126. package/dist/m/c-NEXFVJIT.js +0 -1
  127. package/dist/m/c-T527QHOB.js +0 -1
  128. package/dist/m/core/draft-mode-WSQAT67S.js +0 -1
  129. package/dist/m/loader-5IA5OJ3J.js +0 -1
  130. package/dist/m/manifest-98258645.js +0 -1
  131. package/dist/m/manifest-edit-ABF56000.js +0 -1
  132. package/dist/m/plugins/page-ZBIYHGK4.js +0 -2
  133. package/dist/m/plugins/sortable-HDOQ6GAC.js +0 -1
  134. package/dist/m/plugins/visitor-drafts-A5FDQ2G7.js +0 -20
  135. package/dist/m/plugins/wire-XOT7XKJL.js +0 -1
  136. /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
@@ -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
- if (!loaded.featureIds.length) for (const node of savedContent) node.remove();
1255
- if (shell) article.appendChild(shell);
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
- for (const node of savedContent) node.remove();
1467
- if (shell) shell.style.visibility = "";
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.
@@ -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
+ }