@panphora/clayjs 1.9.2 → 1.11.0

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 (42) hide show
  1. package/README.md +3 -3
  2. package/dist/clay.standalone.js +9701 -6938
  3. package/entries/clay-router.js +38 -0
  4. package/entries/sap.js +6 -6
  5. package/package.json +2 -2
  6. package/packed-contract.json +1 -0
  7. package/src/attrs/add-from.js +72 -0
  8. package/src/attrs/save-freeze.js +12 -2
  9. package/src/core/conflict-notice.js +2 -5
  10. package/src/core/conflict-presentation.js +42 -0
  11. package/src/core/save-core.js +22 -0
  12. package/src/core/save.js +11 -2
  13. package/src/core/snapshot.js +4 -1
  14. package/src/core/source-map.js +1 -1
  15. package/src/lib/mutation.js +28 -4
  16. package/src/lib/person-face.js +16 -0
  17. package/src/lib/region-capabilities.js +1 -0
  18. package/src/lib/region-policy.js +10 -3
  19. package/src/lib/root-attrs.js +28 -0
  20. package/src/live/checkpoint.js +106 -0
  21. package/src/live/fast-lane.js +590 -0
  22. package/src/live/local-lane.js +582 -0
  23. package/src/live/ops.js +291 -0
  24. package/src/live/server-lane.js +84 -0
  25. package/src/live/store.js +291 -0
  26. package/src/live/stream-bridge.js +60 -0
  27. package/src/loader-logic.js +7 -2
  28. package/src/loader.js +2 -0
  29. package/src/plugins/live.js +446 -0
  30. package/src/plugins/people.js +3 -14
  31. package/src/plugins/sortable.js +3 -1
  32. package/src/route/route.js +188 -0
  33. package/src/standalone.js +1 -0
  34. package/src/sync/conflict-revert.js +2 -3
  35. package/src/sync/editing-marker.js +172 -0
  36. package/src/sync/editing-path.js +46 -0
  37. package/src/sync/editing-sender.js +100 -0
  38. package/src/sync/live-sync.js +85 -19
  39. package/src/sync/presence.js +16 -44
  40. package/src/sync/stream.js +16 -5
  41. package/src/vendor/hyper-morph.vendor.js +6 -4
  42. package/src/sync/section-notice.js +0 -254
@@ -43,6 +43,20 @@ let nextSaveKeepalive = false;
43
43
  // CustomEvent would put it on a bus any script on the page can listen to.
44
44
  const saveAcceptedHooks = [];
45
45
 
46
+ // Headers another module asks every save to carry, by name.
47
+ const extraSaveHeaders = new Map();
48
+
49
+ /**
50
+ * Add a header to every save from now on, or remove it with a null value.
51
+ *
52
+ * @param {string} name
53
+ * @param {string|null} value
54
+ */
55
+ export function setSaveHeader(name, value) {
56
+ if (value == null) extraSaveHeaders.delete(name);
57
+ else extraSaveHeaders.set(name, String(value));
58
+ }
59
+
46
60
  /**
47
61
  * Send the next save with `keepalive`, so a request whose page is going away (a tab
48
62
  * closing, a navigation) is still finished by the transport.
@@ -238,6 +252,11 @@ function errorResult(err) {
238
252
  // Live sync compares it with the frames it merges to know when the refusal
239
253
  // is answered.
240
254
  conflictEtag: conflicted ? (err.etag ?? null) : null,
255
+ // The host's answer, null when none came (a lost connection or a timeout).
256
+ status: Number.isInteger(err.status) ? err.status : null,
257
+ // An attempt of this save timed out, so it may have landed whatever the
258
+ // final answer says.
259
+ hadTimeout: timedOut || err.hadTimeout === true,
241
260
  etag: null
242
261
  };
243
262
  }
@@ -312,6 +331,8 @@ function buildSaveRequest(content, userDriven, signal, saveId, etag, keepalive =
312
331
  body: content
313
332
  };
314
333
 
334
+ for (const [name, value] of extraSaveHeaders) options.headers[name] = value;
335
+
315
336
  if (etag) options.headers['If-Match'] = etag;
316
337
 
317
338
  if (keepalive && new Blob([content]).size < KEEPALIVE_MAX_BODY) options.keepalive = true;
@@ -559,6 +580,7 @@ async function sendSave(content) {
559
580
  try {
560
581
  return await sendOnce(content, saveId, etag);
561
582
  } catch (err) {
583
+ if (reSent) err.hadTimeout = true;
562
584
  if (err.name === 'AbortError') {
563
585
  if (reSent) throw err;
564
586
  const { landed, resend, etag: original } = await recoverUnknownSave();
package/src/core/save.js CHANGED
@@ -221,6 +221,13 @@ export function resumeAutosave() {
221
221
  savePageThrottled();
222
222
  }
223
223
 
224
+ // Whether an agent's request currently holds autosave off. A caller that forces a
225
+ // save on its own schedule (live data checkpoints) asks this first, because a
226
+ // forced save would write the browser's bytes over the agent's.
227
+ export function autosaveIsSuspended() {
228
+ return autosaveSuspended > 0;
229
+ }
230
+
224
231
  // ============================================
225
232
  // THE ALL-SAVE HOLD
226
233
  // ============================================
@@ -372,15 +379,17 @@ function applySaveResult(result, forComparison, forDirty, label, gateToken, forS
372
379
  } else if (result.msgType === 'conflict') {
373
380
  holdForConflict(result.conflictEtag);
374
381
  setSaveState('conflict', result.msg, result.msgType, {
382
+ hadTimeout: result.hadTimeout === true,
375
383
  changedBy: result.changedBy ?? null,
376
384
  afterTimeout: result.afterTimeout === true,
377
385
  etag: result.conflictEtag ?? null,
378
386
  });
379
387
  } else if (result.msgType !== 'skipped') {
388
+ const extra = { status: result.status ?? null, hadTimeout: result.hadTimeout === true };
380
389
  if (!navigator.onLine) {
381
- setSaveState('offline', result.msg);
390
+ setSaveState('offline', result.msg, '', extra);
382
391
  } else {
383
- setSaveState('error', result.msg);
392
+ setSaveState('error', result.msg, '', extra);
384
393
  }
385
394
  }
386
395
  }
@@ -41,7 +41,7 @@
41
41
  import { stripExtensionNoise } from '../lib/extension-noise.js';
42
42
  import { restoreAuthoredUrls } from '../lib/authored-url.js';
43
43
  import { STRIP_FROM_SAVE, STRIP_FROM_COMPARISON, STRIP_FROM_DIRTY_CHECK, NO_TRIGGER_AUTOSAVE_SELECTOR, SNAPSHOT_REMOVE_SELECTOR } from '../lib/region-policy.js';
44
- import { TAB_LOCAL_ROOT_ATTRS } from '../lib/root-attrs.js';
44
+ import { TAB_LOCAL_ROOT_ATTRS, stripRouteAttrs } from '../lib/root-attrs.js';
45
45
  import { createContentView } from '../lib/content-dom.js';
46
46
 
47
47
  // =============================================================================
@@ -277,6 +277,9 @@ export function captureSnapshot({ flushUndo = true, authored = true } = {}) {
277
277
  // snapshot so it never reaches a save, a comparison, or a live-sync broadcast.
278
278
  stripExtensionNoise(clone);
279
279
 
280
+ // Router state says where this tab is, not what the document holds.
281
+ stripRouteAttrs(clone);
282
+
280
283
  return clone;
281
284
  }
282
285
 
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * source-map.js — save the file, not a serialization of it.
3
3
  *
4
- * Every program that edits a malleable HTML file rewrites the whole file, because
4
+ * Every program that edits a malleable document rewrites the whole file, because
5
5
  * each one parses to a tree and serializes the tree back out, and a serializer does
6
6
  * not reproduce its input. A no-edit save through `clone.outerHTML` rewrites about
7
7
  * 88% of an authored document's lines: attributes reorder, quoting normalises, `&`
@@ -69,7 +69,7 @@
69
69
  import { EXTENSION_ATTR_PATTERN } from './extension-noise.js';
70
70
  import { resolveRegionPolicy, isInert, strictestPolicy, skipForPolicy } from './region-policy.js';
71
71
  import { isPolicyAttribute } from './region-capabilities.js';
72
- import { ROOT_LIBRARY_ATTRS } from './root-attrs.js';
72
+ import { ROOT_LIBRARY_ATTRS, ROUTE_ROOT_PREFIX, ROUTE_ACTIVE_ATTR } from './root-attrs.js';
73
73
 
74
74
  import { isUserDrivenNow, markUserDriven } from './user-gesture.js';
75
75
 
@@ -83,12 +83,15 @@ const dummyElem = document.createElement("div");
83
83
  // instead of body. The raw lane is deliberately unfiltered ("raw means raw"), so
84
84
  // without this every save would push a savestatus flip into undo's record stream
85
85
  // and Cmd+Z after a save would undo that instead of the user's edit.
86
+ // Router state (route-* on the root, route-active anywhere) is dropped here for the same reason.
86
87
  const ROOT_LIBRARY_ATTR_SET = new Set(ROOT_LIBRARY_ATTRS);
87
88
 
88
89
  function isLibraryRootAttr(record) {
89
- return record.type === 'attributes'
90
- && record.target === document.documentElement
91
- && ROOT_LIBRARY_ATTR_SET.has(record.attributeName);
90
+ if (record.type !== 'attributes') return false;
91
+ const name = record.attributeName;
92
+ if (name === ROUTE_ACTIVE_ATTR) return true;
93
+ return record.target === document.documentElement
94
+ && (ROOT_LIBRARY_ATTR_SET.has(name) || name.startsWith(ROUTE_ROOT_PREFIX));
92
95
  }
93
96
 
94
97
  function withoutLibraryNoise(records) {
@@ -127,6 +130,7 @@ const localMutation = {
127
130
  * Always pair with resume() in a try/finally block.
128
131
  */
129
132
  pause() {
133
+ if (this._pauseDepth === 0) this._deliverBeforePause();
130
134
  // Reference-counted so nested pauses (e.g. a programmatic pause wrapping a
131
135
  // live-sync morph that also pauses) only resume on the outermost release.
132
136
  this._pauseDepth++;
@@ -139,6 +143,26 @@ const localMutation = {
139
143
  this._log('Paused', this._pauseDepth);
140
144
  },
141
145
 
146
+ // Records the browser queued before this pause describe changes made outside
147
+ // it. Left in the queue, resume() would drain them as paused-time records and
148
+ // only non-pausable callbacks would see them, so autosave missed a change made
149
+ // just before a pause. Deliver them on a microtask, as the observer would have.
150
+ _deliverBeforePause() {
151
+ if (!this._observer) return;
152
+ const records = this._observer.takeRecords();
153
+ if (records.length) queueMicrotask(() => this._onRecords(records));
154
+ },
155
+
156
+ /**
157
+ * Hand the records the browser has queued to raw subscribers' buffers and to
158
+ * non-pausable callbacks only, as if they had happened inside a pause. For a
159
+ * caller whose writes and their undoing cancel out, so the page's change feed
160
+ * has nothing to see.
161
+ */
162
+ dropQueued() {
163
+ if (this._observer) this._drainBrowserQueue(this);
164
+ },
165
+
142
166
  /**
143
167
  * Resume mutation observation after a pause.
144
168
  */
@@ -0,0 +1,16 @@
1
+ export const PERSON_COLORS = Object.freeze(['#6b4fa8', '#2f6a9e', '#9a6a1c', '#a8463d', '#2f7a69', '#4f6178', '#3f7d3a', '#a2457a']);
2
+
3
+ export function colorIndexOf(id) {
4
+ let hash = 0;
5
+ for (const ch of String(id)) hash = (hash * 31 + ch.codePointAt(0)) >>> 0;
6
+ return hash % PERSON_COLORS.length;
7
+ }
8
+
9
+ export function personColor(id) {
10
+ return PERSON_COLORS[colorIndexOf(id)];
11
+ }
12
+
13
+ export function initialsOf(name) {
14
+ const letters = String(name).trim().split(/\s+/).filter(Boolean).slice(0, 2).map((word) => Array.from(word)[0]);
15
+ return letters.join("").toUpperCase() || "?";
16
+ }
@@ -21,6 +21,7 @@ export const POLICY_TOKENS = Object.freeze([
21
21
  'no-data',
22
22
  'freeze',
23
23
  'editor-ui',
24
+ 'live',
24
25
  ])
25
26
 
26
27
  export function hasPolicyToken(element, token) {
@@ -49,7 +49,7 @@ import { capabilitySelector, expandsTo, hasCapability, hasPolicyToken, POLICY_TO
49
49
  export const PERSIST = { FULL: 'full', FROZEN: 'frozen', NONE: 'none' };
50
50
 
51
51
  // The canonical region tokens (spelled in the `clay` attribute or as bare attrs).
52
- export const REGION_ATTRS = ['no-save', 'no-trigger-autosave', 'no-dirty', 'no-undo', 'no-watch', 'no-data', 'freeze', 'editor-ui'];
52
+ export const REGION_ATTRS = ['no-save', 'no-trigger-autosave', 'no-dirty', 'no-undo', 'no-watch', 'no-data', 'freeze', 'editor-ui', 'live'];
53
53
 
54
54
  // Every canonical token spellable inside the space-separated `clay` attribute.
55
55
  // Exported: this list used to exist as a private CLAY_TOKENS that nothing read,
@@ -83,7 +83,7 @@ export const FREEZE_SELECTOR = '[clay~="freeze"], [freeze], [save-freeze]';
83
83
  // mutations-ignore footgun (their content stays in the saved file, but is no
84
84
  // longer counted as a change).
85
85
  export const STRIP_FROM_COMPARISON =
86
- '[clay~="no-save"], [clay~="no-trigger-autosave"], [clay~="no-dirty"], [clay~="freeze"], [clay~="no-watch"], [no-save], [save-remove], [no-trigger-autosave], [no-dirty], [save-ignore], [freeze], [save-freeze], [no-watch], [mutations-ignore]';
86
+ '[clay~="no-save"], [clay~="no-trigger-autosave"], [clay~="no-dirty"], [clay~="freeze"], [clay~="no-watch"], [no-save], [save-remove], [no-trigger-autosave], [no-dirty], [save-ignore], [freeze], [save-freeze], [no-watch], [mutations-ignore], [clay~="live"]';
87
87
 
88
88
  // Every spelling of no-trigger-autosave: the ONE difference between the autosave
89
89
  // domain and the dirty domain. A document containing none of these has identical
@@ -94,6 +94,10 @@ export const STRIP_FROM_COMPARISON =
94
94
  export const NO_TRIGGER_AUTOSAVE_SELECTOR =
95
95
  '[clay~="no-trigger-autosave"], [no-trigger-autosave]';
96
96
 
97
+ // Live JSON stores (clay="live" on a merge= JSON script). Saved, stripped from both
98
+ // comparison domains, and ignored by the live-sync merge: the live plugin owns the value.
99
+ export const LIVE_SELECTOR = '[clay~="live"]';
100
+
97
101
  // Disposable regions: saved, but their content is explicitly not work. Stripped
98
102
  // from BOTH comparison domains, skipped by the merge on both sides, and never
99
103
  // counted by the close warning. This is the declared signal that lets the merge
@@ -108,7 +112,7 @@ export const NO_DIRTY_SELECTOR =
108
112
  // dirty-tracked either, and counting it would re-freeze the live-sync baseline
109
113
  // the way the mounted-tool bug did (see dirty-gate.js).
110
114
  export const STRIP_FROM_DIRTY_CHECK =
111
- '[clay~="no-save"], [clay~="no-dirty"], [clay~="freeze"], [clay~="no-watch"], [no-save], [save-remove], [no-dirty], [save-ignore], [freeze], [save-freeze], [no-watch], [mutations-ignore]';
115
+ '[clay~="no-save"], [clay~="no-dirty"], [clay~="freeze"], [clay~="no-watch"], [no-save], [save-remove], [no-dirty], [save-ignore], [freeze], [save-freeze], [no-watch], [mutations-ignore], [clay~="live"]';
112
116
 
113
117
  // Snapshot-layer marker: removed from EVERY snapshot (save, live-sync broadcast,
114
118
  // dirty-comparison) in snapshot.js. `no-snapshot` is the consistent alias for the
@@ -163,6 +167,9 @@ export function resolveRegionPolicy(node) {
163
167
  if (expandsTo(element, 'no-watch')) watched = false;
164
168
  if (hasRegionToken(element, 'no-trigger-autosave')) autosaveOff = true;
165
169
  if (hasRegionToken(element, 'no-dirty')) { autosaveOff = true; dirtyOff = true; }
170
+ // A live JSON store: saved, but its value lives in memory and the live plugin
171
+ // writes it into each save clone, so the tag itself is never work to track.
172
+ if (hasRegionToken(element, 'live')) { autosaveOff = true; dirtyOff = true; undoable = false; }
166
173
  if (expandsTo(element, 'no-undo')) undoable = false;
167
174
  if (expandsTo(element, 'no-save')) persistRank = Math.max(persistRank, PERSIST_RANK.none);
168
175
  if (hasRegionToken(element, 'freeze')) persistRank = Math.max(persistRank, PERSIST_RANK.frozen);
@@ -116,3 +116,31 @@ export const TAB_LOCAL_ROOT_ATTRS = new Set([
116
116
  export function isTabLocalRootAttr(name, element) {
117
117
  return element === document.documentElement && TAB_LOCAL_ROOT_ATTRS.has(name);
118
118
  }
119
+
120
+ // Router state. The router mirrors each URL-hash param onto the root as
121
+ // route-<name>, and stamps route-active on the record a param selects. Both say
122
+ // where this tab is in the document, not what the document holds, so they are
123
+ // tab-local like the library's own root state: never saved, never dirty, never
124
+ // undone, never sent to or taken from a peer. route-* is reserved on the root
125
+ // only; route-active is reserved on every element.
126
+ export const ROUTE_ROOT_PREFIX = "route-";
127
+ export const ROUTE_ACTIVE_ATTR = "route-active";
128
+
129
+ /**
130
+ * True when `name` on `el` belongs to this tab rather than to the document:
131
+ * every tab-local root attribute, route-* on a root, and route-active anywhere.
132
+ * Works on the live root and on a snapshot clone's or parsed frame's root.
133
+ */
134
+ export function isTabLocalAttr(name, el) {
135
+ if (name === ROUTE_ACTIVE_ATTR) return true;
136
+ if (!el || el.localName !== "html" || el.parentElement) return false;
137
+ return TAB_LOCAL_ROOT_ATTRS.has(name) || name.startsWith(ROUTE_ROOT_PREFIX);
138
+ }
139
+
140
+ /** Remove router state from a snapshot clone: route-* on its root, route-active anywhere. */
141
+ export function stripRouteAttrs(root) {
142
+ for (const attr of [...root.attributes]) {
143
+ if (attr.name.startsWith(ROUTE_ROOT_PREFIX)) root.removeAttribute(attr.name);
144
+ }
145
+ for (const el of root.querySelectorAll(`[${ROUTE_ACTIVE_ATTR}]`)) el.removeAttribute(ROUTE_ACTIVE_ATTR);
146
+ }
@@ -0,0 +1,106 @@
1
+ // Checkpoints for clay.live stores. A live change never marks the page unsaved
2
+ // (live tags are outside the comparison domains), so nothing else would ever save
3
+ // it. This does: at a fixed deadline CHECKPOINT_MS after the first unsaved change,
4
+ // so a steady stream of moves still saves on time, and once more, right away,
5
+ // when the tab is hidden or closed.
6
+
7
+ export const CHECKPOINT_MS = 8000;
8
+ export const RETRY_MS = 1000;
9
+
10
+ /**
11
+ * @param {object} deps
12
+ * @param {() => boolean} deps.needsSave - is there a live change no accepted save holds
13
+ * @param {() => boolean} deps.blocked - must a save wait (settling, conflict, agent editing)
14
+ * @param {() => Promise<object>} deps.save - the checkpoint save; resolves { ok, msgType }
15
+ * @param {() => unknown} deps.saveNow - the hide/close save, sent with keepalive
16
+ */
17
+ export function createCheckpoints({ needsSave, blocked, save, saveNow, now = () => Date.now(), setTimer = setTimeout, clearTimer = clearTimeout }) {
18
+ let deadline = null;
19
+ let timer = null;
20
+ let running = false;
21
+ let flushing = false;
22
+ // After a save that did not land, no save before this time (soon included).
23
+ let retryAt = 0;
24
+
25
+ function after(result) {
26
+ const skipped = result && !result.ok && result.msgType === "skipped";
27
+ retryAt = result?.ok || skipped ? 0 : now() + CHECKPOINT_MS;
28
+ return skipped ? RETRY_MS : CHECKPOINT_MS;
29
+ }
30
+
31
+ function arm(at) {
32
+ deadline = at;
33
+ if (timer !== null) clearTimer(timer);
34
+ timer = setTimer(run, Math.max(0, at - now()));
35
+ }
36
+
37
+ function disarm() {
38
+ if (timer !== null) clearTimer(timer);
39
+ timer = null;
40
+ deadline = null;
41
+ }
42
+
43
+ async function run() {
44
+ timer = null;
45
+ deadline = null;
46
+ if (!needsSave()) return;
47
+ if (blocked()) {
48
+ arm(now() + RETRY_MS);
49
+ return;
50
+ }
51
+ running = true;
52
+ let result = null;
53
+ try {
54
+ result = await save();
55
+ } catch {
56
+ result = null;
57
+ } finally {
58
+ running = false;
59
+ }
60
+ const wait = after(result);
61
+ if (!needsSave()) return;
62
+ // A save skipped because another was on the wire is retried soon; anything
63
+ // else waits a full interval, so a failing host is not asked every second.
64
+ arm(now() + wait);
65
+ }
66
+
67
+ return {
68
+ // A live change happened. The first one after a save starts the clock; later
69
+ // ones do not move it.
70
+ changed() {
71
+ if (deadline === null && !running) arm(now() + CHECKPOINT_MS);
72
+ },
73
+ // The tab is going away or out of sight: save now if anything is unsaved.
74
+ // A save that did not land arms the clock again, as run() does.
75
+ flush() {
76
+ if (running || !needsSave() || blocked()) return;
77
+ disarm();
78
+ running = true;
79
+ flushing = true;
80
+ Promise.resolve()
81
+ .then(saveNow)
82
+ .catch(() => null)
83
+ .then((result) => {
84
+ running = false;
85
+ flushing = false;
86
+ const wait = after(result);
87
+ if (!needsSave()) return;
88
+ arm(now() + wait);
89
+ });
90
+ },
91
+ // Save as soon as possible: the room asks for it (its log is half full, or
92
+ // the file holds a version the room cannot rebuild from).
93
+ soon() {
94
+ if (running) return;
95
+ const at = Math.max(now(), retryAt);
96
+ if (deadline === null || at < deadline) arm(at);
97
+ },
98
+ // True while the hide/close save is being made.
99
+ get flushing() {
100
+ return flushing;
101
+ },
102
+ get deadline() {
103
+ return deadline;
104
+ },
105
+ };
106
+ }