@panphora/clayjs 1.6.1 → 1.8.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.
@@ -506,10 +506,10 @@ async function flushSave() {
506
506
  // surface is an opt-in satellite script, absent on every page the loader
507
507
  // builds, so reading it here made "is a save on the wire" permanently false.
508
508
  const { isSaveInProgress } = await import("../core/save-core.js");
509
- // A held save is an AI edit preview waiting for Keep or Revert, not a page with
510
- // nothing to save: posting now would hand the agent a file that is about to change.
511
- const { savesHeld } = await import("../core/save.js");
512
- if (savesHeld()) throw new Error("keep or revert the AI edit first");
509
+ // A held save means another operation owns the page's persisted state, not a
510
+ // page with nothing to save: posting now could hand the agent stale bytes.
511
+ const { savesHeld, saveHoldReason } = await import("../core/save.js");
512
+ if (savesHeld()) throw new Error(saveHoldReason());
513
513
  const deadline = Date.now() + SAVE_FLUSH_TIMEOUT_MS;
514
514
 
515
515
  for (let attempt = 0; attempt < 3; attempt++) {
@@ -0,0 +1,278 @@
1
+ const states = new WeakMap();
2
+ const released = new WeakSet();
3
+ const pending = new Set();
4
+ let generation = 0;
5
+ let sequence = 0;
6
+
7
+ const TEXT_BLOCKS = new Set('P H1 H2 H3 H4 H5 H6 DIV LI DD DT BLOCKQUOTE FIGCAPTION SUMMARY ADDRESS'.split(' '));
8
+ const isElement = (node) => node?.nodeType === 1;
9
+ const onPage = (node) => {
10
+ try {
11
+ return !!node && node.ownerDocument === document && document.documentElement.contains(node);
12
+ } catch {
13
+ return false;
14
+ }
15
+ };
16
+ const rawOf = (record) => record.rawReports?.length ? record.rawReports : [record];
17
+ const rawSet = (records) => new Set(records.flatMap(rawOf).filter((raw) => !released.has(raw)));
18
+
19
+ function childrenOf(node) {
20
+ return [...node.childNodes, ...(node.nodeType === 1 && node.localName === 'template' && node.content ? [node.content] : [])];
21
+ }
22
+
23
+ function attributesOf(node) {
24
+ return isElement(node) ? [...node.attributes].map((a) => [a.namespaceURI, a.name, a.value]) : [];
25
+ }
26
+
27
+ function fieldsOf(node) {
28
+ const names = {
29
+ input: ['HTMLInputElement', 'value', 'checked'],
30
+ textarea: ['HTMLTextAreaElement', 'value'],
31
+ select: ['HTMLSelectElement', 'selectedIndex'],
32
+ option: ['HTMLOptionElement', 'selected'],
33
+ }[node.localName];
34
+ const prototype = names && document.defaultView[names[0]]?.prototype;
35
+ if (!prototype?.isPrototypeOf(node)) return [];
36
+ return names.slice(1).map((name) => Object.getOwnPropertyDescriptor(prototype, name).get.call(node));
37
+ }
38
+
39
+ function witness(node) {
40
+ const parents = [];
41
+ for (let p = node.parentNode; p; p = p.parentNode) parents.push(p);
42
+ const rows = [];
43
+ const visit = (n) => {
44
+ const children = childrenOf(n);
45
+ rows.push({ node: n, value: n.nodeValue, attributes: attributesOf(n), fields: fieldsOf(n), children });
46
+ for (const child of children) visit(child);
47
+ };
48
+ visit(node);
49
+ return { node, parents, rows };
50
+ }
51
+
52
+ function valid(w, topologyOnly = false) {
53
+ if (!onPage(w.node)) return false;
54
+ let p = w.node.parentNode;
55
+ for (const parent of w.parents) {
56
+ if (p !== parent) return false;
57
+ p = p.parentNode;
58
+ }
59
+ if (p) return false;
60
+ for (const row of w.rows) {
61
+ const children = childrenOf(row.node);
62
+ if (children.length !== row.children.length || children.some((n, i) => n !== row.children[i])) return false;
63
+ if (topologyOnly) continue;
64
+ if (row.node.nodeValue !== row.value) return false;
65
+ const attrs = attributesOf(row.node);
66
+ if (attrs.length !== row.attributes.length || attrs.some((a, i) => a.some((v, j) => v !== row.attributes[i][j]))) return false;
67
+ if (fieldsOf(row.node).some((value, i) => value !== row.fields[i])) return false;
68
+ }
69
+ return true;
70
+ }
71
+
72
+ export const captureFootprintWitnesses = (nodes) => [...new Set(nodes)].map(witness);
73
+ export const footprintWitnessesValid = (witnesses) => witnesses.every((w) => valid(w));
74
+ export const protectionGeneration = () => generation;
75
+
76
+ function put(raw, nodes, mode, status = 'proved') {
77
+ if (released.has(raw)) return null;
78
+ const unique = [...new Set(nodes)];
79
+ const state = {
80
+ nodes: unique,
81
+ mode,
82
+ status,
83
+ generation: ++generation,
84
+ witnesses: mode === 'document' ? [] : unique.map(witness),
85
+ };
86
+ states.set(raw, state);
87
+ return state;
88
+ }
89
+
90
+ const broad = (raw) => put(raw, [document.documentElement], 'document', 'unknown');
91
+
92
+ function spanNode(span) {
93
+ if (!onPage(span?.startContainer) || !onPage(span?.endContainer)) return null;
94
+ const above = new Set();
95
+ for (let n = span.startContainer; n; n = n.parentNode) above.add(n);
96
+ let node = span.endContainer;
97
+ while (node && !above.has(node)) node = node.parentNode;
98
+ if (!isElement(node)) node = node?.parentElement;
99
+ for (let n = node; n; n = n.parentElement) if (TEXT_BLOCKS.has(n.tagName)) return n;
100
+ return node;
101
+ }
102
+
103
+ function seed(raw) {
104
+ const recovery = raw.recovery;
105
+ for (const span of [recovery?.text?.liveSpan, recovery?.text?.liveScope]) {
106
+ const node = spanNode(span);
107
+ if (node && node !== document.documentElement) return put(raw, [node], 'source');
108
+ }
109
+ const live = recovery?.subject?.live || [];
110
+ const nodes = live.map((n) => isElement(n) ? n : n?.parentElement);
111
+ if (nodes.length && nodes.every((n) => onPage(n) && n !== document.documentElement)) return put(raw, nodes, 'source');
112
+ return broad(raw);
113
+ }
114
+
115
+ function current(raw, create = false) {
116
+ let state = states.get(raw);
117
+ if (!state) return create ? seed(raw) : null;
118
+ if (state.mode === 'document') return state.nodes[0] === document.documentElement ? state : broad(raw);
119
+ if (state.witnesses.every((w) => valid(w))) return state;
120
+ // Original live identity still locates a loss after ordinary value edits.
121
+ // A transferred or broadened region has no such independent recovery anchor.
122
+ if (state.mode === 'source' && state.witnesses.every((w) => valid(w, true))) {
123
+ return put(raw, state.nodes, 'source');
124
+ }
125
+ return broad(raw);
126
+ }
127
+
128
+ export function protectionOf(record) {
129
+ if (released.has(record)) return null;
130
+ const raws = [...rawSet([record])];
131
+ const values = raws.map((raw) => current(raw));
132
+ if (!values.some(Boolean)) return null;
133
+ if (values.some((state) => !state)) {
134
+ for (const raw of raws) if (!states.has(raw)) broad(raw);
135
+ return protectionOf(record);
136
+ }
137
+ const union = [...new Set(values.flatMap((state) => state.nodes))];
138
+ if (values.every((state) => state.mode === 'source') && union.length === 1) return null;
139
+ const nodes = values.some((state) => state.mode === 'document')
140
+ ? [document.documentElement]
141
+ : union;
142
+ return { nodes, status: values.some((state) => state.status === 'unknown') ? 'unknown' : 'proved', generation };
143
+ }
144
+
145
+ export function releaseProtection(record) {
146
+ for (const raw of rawOf(record)) {
147
+ released.add(raw);
148
+ states.delete(raw);
149
+ }
150
+ released.add(record);
151
+ states.delete(record);
152
+ generation++;
153
+ }
154
+
155
+ function allRaw(records) {
156
+ const raws = rawSet(records);
157
+ for (const frame of pending) {
158
+ for (const raw of [...frame.tracked, ...frame.raws]) if (!released.has(raw)) raws.add(raw);
159
+ }
160
+ return raws;
161
+ }
162
+
163
+ function positive(entry) {
164
+ if (entry?.complete !== true || !Array.isArray(entry.to) || entry.to.length !== 1) return null;
165
+ const target = entry.to[0];
166
+ if (!isElement(target) || !onPage(target)) return null;
167
+ if (entry.kind === 'retained' && target === entry.from) return target;
168
+ if (entry.kind === 'replaced' && target !== entry.from && !onPage(entry.from)) return target;
169
+ return null;
170
+ }
171
+
172
+ export function beginLineageCapture(records) {
173
+ const frame = {
174
+ serial: ++sequence,
175
+ tracked: allRaw(records),
176
+ raws: new Set(),
177
+ before: new Map(),
178
+ delivered: false,
179
+ invalid: false,
180
+ report: null,
181
+ finished: false,
182
+ };
183
+ generation++;
184
+ const elements = new Set();
185
+ for (const raw of frame.tracked) {
186
+ const state = current(raw, true);
187
+ const chains = state.mode === 'document' ? [] : state.nodes.map((node) => {
188
+ const chain = [];
189
+ for (let n = node; n && n !== document.documentElement; n = n.parentElement) {
190
+ if (onPage(n)) { chain.push(n); elements.add(n); }
191
+ }
192
+ return chain;
193
+ });
194
+ frame.before.set(raw, { state, chains });
195
+ }
196
+ pending.add(frame);
197
+
198
+ const invalidate = (openRecords = []) => {
199
+ frame.invalid = true;
200
+ generation++;
201
+ for (const raw of allRaw(openRecords)) broad(raw);
202
+ };
203
+
204
+ const lineage = {
205
+ elements: [...elements],
206
+ onResult(result, report) {
207
+ if (frame.finished || frame.delivered) {
208
+ invalidate();
209
+ return;
210
+ }
211
+ frame.delivered = true;
212
+ frame.report = report;
213
+ frame.raws = rawSet(report?.conflicts || []);
214
+ const complete = !frame.invalid && frame.serial === sequence && result?.version === 1 &&
215
+ result.root === document.documentElement && result.status === 'complete' &&
216
+ Array.isArray(result.entries) && report?.lineage === result;
217
+ if (!complete) { invalidate(); return; }
218
+ const entries = new Map();
219
+ for (const entry of result.entries) {
220
+ if (!entry || typeof entry !== 'object' || !isElement(entry.from)) continue;
221
+ if (entries.has(entry.from)) entries.set(entry.from, null);
222
+ else entries.set(entry.from, entry);
223
+ }
224
+ for (const [raw, { state, chains }] of frame.before) {
225
+ if (released.has(raw)) continue;
226
+ if (state.mode === 'document') { broad(raw); continue; }
227
+ const nodes = [];
228
+ let fallback = false;
229
+ for (const chain of chains) {
230
+ let found = null;
231
+ for (let i = 0; i < chain.length; i++) {
232
+ found = positive(entries.get(chain[i]));
233
+ if (found) { fallback ||= i > 0; break; }
234
+ }
235
+ if (!found) { nodes.push(document.documentElement); break; }
236
+ nodes.push(found);
237
+ }
238
+ if (!nodes.length || nodes.includes(document.documentElement)) broad(raw);
239
+ else {
240
+ const same = !fallback && state.mode === 'source' && nodes.every((n, i) => n === state.nodes[i]);
241
+ put(raw, nodes, same ? 'source' : 'lineage', fallback ? 'unknown' : state.status);
242
+ }
243
+ }
244
+ for (const raw of frame.raws) if (!states.has(raw)) seed(raw);
245
+ frame.before.clear();
246
+ },
247
+ };
248
+
249
+ return {
250
+ lineage,
251
+ returned() {
252
+ if (!frame.delivered) invalidate();
253
+ },
254
+ invalidate,
255
+ prepareComplete(report, openRecords) {
256
+ if (report !== frame.report || !frame.delivered || frame.invalid) {
257
+ for (const raw of rawSet(report?.conflicts || [])) frame.raws.add(raw);
258
+ invalidate(openRecords);
259
+ } else {
260
+ for (const raw of rawSet(report?.conflicts || [])) {
261
+ if (!frame.raws.has(raw)) { frame.raws.add(raw); broad(raw); }
262
+ }
263
+ }
264
+ },
265
+ finish(openRecords) {
266
+ if (frame.finished) return;
267
+ const keep = rawSet(openRecords);
268
+ for (const raw of frame.raws) if (!keep.has(raw)) states.delete(raw);
269
+ frame.finished = true;
270
+ pending.delete(frame);
271
+ frame.tracked.clear();
272
+ frame.raws.clear();
273
+ frame.before.clear();
274
+ frame.report = null;
275
+ lineage.elements = [];
276
+ },
277
+ };
278
+ }
@@ -1,5 +1,6 @@
1
1
  import { HyperMorph } from "../vendor/hyper-morph.vendor.js";
2
2
  import { conflicts } from "./conflicts.js";
3
+ import { protectionOf, protectionGeneration, captureFootprintWitnesses, footprintWitnessesValid } from "./conflict-footprints.js";
3
4
  import { SYNC_IGNORE_SELECTOR, REMOTE_WINS_SELECTOR } from "./live-sync.js";
4
5
  import { savePage } from "../core/save.js";
5
6
  import { gateMarkDirty } from "../lib/dirty-gate.js";
@@ -794,6 +795,11 @@ function covers(k, op) {
794
795
  // the text block around its recorded clash, else around its recorded scope, else its
795
796
  // live subject, else its live placement parent, else the nearest ancestor of its
796
797
  // clone path still on the page by identity, else the whole page.
798
+ function footprintsOf(rec, apply) {
799
+ const protection = protectionOf(rec);
800
+ return protection ? protection.nodes : [footprintOf(rec, apply)];
801
+ }
802
+
797
803
  function footprintOf(rec, apply) {
798
804
  const r = rec.recovery;
799
805
  const subject = r.subject;
@@ -835,7 +841,7 @@ function planOne(rec, apply) {
835
841
  return null;
836
842
  }
837
843
 
838
- const revisionNow = () => `${conflicts.hasPendingApply() ? 1 : 0}|${conflicts.list().map((r) => r.id).join(",")}`;
844
+ const revisionNow = () => `${protectionGeneration()}|${conflicts.hasPendingApply() ? 1 : 0}|${conflicts.list().map((r) => r.id).join(",")}`;
839
845
 
840
846
  // ---------------------------------------------------------------------------
841
847
  // The whole page: what must hold after the writes
@@ -1004,7 +1010,9 @@ export function prepareRevert(ids) {
1004
1010
  const plan = (rec) => {
1005
1011
  const apply = conflicts.recoveryOf(rec.id);
1006
1012
  const op = apply ? planOne(rec, apply) : null;
1007
- if (!op && apply) constraints.push({ ticket: apply.ticket, node: footprintOf(rec, apply) });
1013
+ if (!op && apply) {
1014
+ for (const node of footprintsOf(rec, apply)) constraints.push({ ticket: apply.ticket, node });
1015
+ }
1008
1016
  return op;
1009
1017
  };
1010
1018
  const ops = [];
@@ -1050,7 +1058,10 @@ export function prepareRevert(ids) {
1050
1058
  block(newer);
1051
1059
  }
1052
1060
  }
1053
- return { ops: kept, covered, blocked, revision, before: census() };
1061
+ const protectedWitnesses = captureFootprintWitnesses(constraints
1062
+ .filter((c) => kept.some((op) => c.ticket > op.ticket))
1063
+ .map((c) => c.node));
1064
+ return { ops: kept, covered, blocked, revision: revisionNow(), protectedWitnesses, before: census() };
1054
1065
  }
1055
1066
 
1056
1067
  /**
@@ -1061,8 +1072,9 @@ export function prepareRevert(ids) {
1061
1072
  * saved.
1062
1073
  */
1063
1074
  export function applyRevert(plan) {
1064
- if (plan.revision !== revisionNow()) {
1075
+ if (plan.revision !== revisionNow() || !footprintWitnessesValid(plan.protectedWitnesses || [])) {
1065
1076
  for (const op of plan.ops) plan.blocked.set(op.id, BLOCKED);
1077
+ for (const id of plan.covered.keys()) plan.blocked.set(id, BLOCKED);
1066
1078
  return [];
1067
1079
  }
1068
1080
  const page = remember();
@@ -1082,7 +1094,8 @@ export function applyRevert(plan) {
1082
1094
  for (const op of plan.ops) if (op.kind === "attr") applyAttr(op);
1083
1095
  applyStructure(plan.ops.filter((op) => op.kind !== "text" && op.kind !== "attr"), parked);
1084
1096
  records = observer.takeRecords();
1085
- ok = plan.ops.every((op) => op.check()) && holds(plan);
1097
+ ok = plan.ops.every((op) => op.check()) && holds(plan) &&
1098
+ plan.revision === revisionNow() && footprintWitnessesValid(plan.protectedWitnesses || []);
1086
1099
  } catch (err) {
1087
1100
  console.error("[clay] Revert to mine failed while writing and was undone", err);
1088
1101
  } finally {
@@ -1,4 +1,5 @@
1
1
  import { registerUnsavedState } from '../lib/unsaved-state.js';
2
+ import { releaseProtection } from './conflict-footprints.js';
2
3
 
3
4
  // What incoming frames won over this tab's unsaved edits, kept for the life of
4
5
  // the document until the person decides. Nothing here is read by a save, a
@@ -63,6 +64,33 @@ export function beginApply({ source, seq = null, etag = null, domain, root }) {
63
64
  return id;
64
65
  }
65
66
 
67
+ function insertionProof(raw) {
68
+ if (!raw.every((c) => c.kind === 'structure' && c.detail === 'insert-collision' && c.recovery?.applied === true && !c.recovery.unavailable && c.recovery.structure?.localAction === 'inserted' && c.recovery.structure.fragmentKind === 'element')) return null;
69
+ const subject = raw[0].recovery.subject;
70
+ if (subject?.local?.length !== 1 || subject.merged?.length !== 1 || subject.live?.length !== 1) return null;
71
+ return { local: subject.local[0], merged: subject.merged[0], live: subject.live[0] };
72
+ }
73
+
74
+ function foldInsertions(groups) {
75
+ const proofs = new Map(groups.map((raw) => [raw, insertionProof(raw)]));
76
+ const below = (parent, child) => parent.length < child.length && parent.every((step, i) => step === child[i]);
77
+ const at = (root, path) => path.reduce((node, step) => step === 'content' ? node?.content : node?.childNodes[step], root);
78
+ const folded = new Map();
79
+ for (const raw of groups) {
80
+ const child = proofs.get(raw);
81
+ let owner = raw;
82
+ if (child) {
83
+ for (const candidate of groups) {
84
+ const parent = proofs.get(candidate);
85
+ if (parent && parent.local.length < proofs.get(owner).local.length && below(parent.local, child.local) && below(parent.merged, child.merged) && (parent.live.contains(child.live) || at(parent.live, child.merged.slice(parent.merged.length)) === child.live)) owner = candidate;
86
+ }
87
+ }
88
+ if (!folded.has(owner)) folded.set(owner, [...owner]);
89
+ if (raw !== owner) folded.get(owner).push(...raw);
90
+ }
91
+ return [...folded.values()];
92
+ }
93
+
66
94
  export function completeApply(applyId, conflicts, { ticket }) {
67
95
  const apply = applies.get(applyId);
68
96
  if (!apply) return [];
@@ -74,7 +102,7 @@ export function completeApply(applyId, conflicts, { ticket }) {
74
102
  if (!groups.has(key)) groups.set(key, []);
75
103
  groups.get(key).push(c);
76
104
  }
77
- const ids = [...groups.values()].map((raw) => install(apply, raw));
105
+ const ids = foldInsertions([...groups.values()]).map((raw) => install(apply, raw));
78
106
  pending.delete(applyId);
79
107
  if (!ids.length) applies.delete(applyId);
80
108
  if (ids.length) changed({ added: ids.map((id) => records.get(id)) });
@@ -110,6 +138,7 @@ export const conflicts = {
110
138
  for (const id of ids) {
111
139
  const rec = records.get(id);
112
140
  if (!rec) continue;
141
+ releaseProtection(rec);
113
142
  records.delete(id);
114
143
  removedIds.push(id);
115
144
  const apply = applies.get(rec.applyId);