@panphora/clayjs 1.10.0 → 1.11.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.
@@ -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);
@@ -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
+ }