rapier-markdown-kit 1.1.99 → 1.1.102

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.
@@ -0,0 +1,316 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Exact source projection shared by canonical history and causal transport.
3
+ // Callers admit and order the records first; this projection retains no state.
4
+ import {_rapierTransformSplices, _rapierHistoryEffects} from './journal-records.mjs';
5
+ import {diff} from '../../packages/rapier-diff/index.mjs';
6
+
7
+ const historyFailure = reason => ({ok: false, reason});
8
+ const historyBoundary = (source, at) => Number.isSafeInteger(at) && at >= 0 && !Object.is(at, -0) && at <= source.length &&
9
+ !(at > 0 && at < source.length && (source.charCodeAt(at - 1) & 0xFC00) === 0xD800 &&
10
+ (source.charCodeAt(at) & 0xFC00) === 0xDC00);
11
+
12
+ function minimalHistorySplice(before, after, pos = 0) {
13
+ let first = 0, a = before.length, b = after.length;
14
+ while (first < a && first < b && before[first] === after[first]) first++;
15
+ if (!historyBoundary(before, first) || !historyBoundary(after, first)) first--;
16
+ while (a > first && b > first && before[a - 1] === after[b - 1]) { a--; b--; }
17
+ if (!historyBoundary(before, a) || !historyBoundary(after, b)) { a++; b++; }
18
+ return {pos: pos + first, removed: before.slice(first, a), inserted: after.slice(first, b)};
19
+ }
20
+
21
+ // Split recorded replacements only at exact unchanged word or line tokens.
22
+ // Shared letters inside changed words do not establish surviving ownership.
23
+ function historyHunks(row) {
24
+ if (row.removed === row.inserted) return [];
25
+ if (!row.removed || !row.inserted) return [{...row}];
26
+ const size = row.removed.length + row.inserted.length;
27
+ const edits = size <= 1024 * 1024 ? diff(row.removed, row.inserted,
28
+ {_unit: size <= 16384 ? 'word' : 'line', limit: 1000000}).edits : null;
29
+ if (!edits || edits.length > 64 || edits.some(edit => edit.coarse))
30
+ return [minimalHistorySplice(row.removed, row.inserted, row.pos)];
31
+ return edits.map(edit => ({pos: row.pos + edit.before[0], removed: row.removed.slice(...edit.before),
32
+ inserted: row.inserted.slice(...edit.after)})).reverse();
33
+ }
34
+
35
+
36
+ // Rows are already proved source placements, optionally carrying sourceIndices from
37
+ // causal projection. Multiple inverse blockers can change history with no source edit.
38
+ export function projectInverseSource(prepared, selected, projectedEffects, options = {}) {
39
+ const records = prepared.rows.map(({record, splices}) => ({...record, splices}));
40
+ if (!records.some(record => !prepared.effects.references.get(record.transaction.id)?.length &&
41
+ prepared.effects.active.has(record.transaction.id) !== projectedEffects.active.has(record.transaction.id)))
42
+ return {ok: true, splices: [], after: prepared.source, superseded: [], overlaps: []};
43
+ try {
44
+ let pieces = prepared.initialSource ? [{text: prepared.initialSource, owner: null, lineage: []}] : [];
45
+ const all = [], byId = new Map(), byAct = new Map(), processed = [], keptInverseAncestors = new Set();
46
+ const textOf = rows => rows.map(row => row.text || '').join('');
47
+ const boundary = pos => {
48
+ let offset = 0;
49
+ for (let index = 0; index < pieces.length; index++) {
50
+ const row = pieces[index];
51
+ if (!row.text) continue;
52
+ const end = offset + row.text.length;
53
+ if (pos > offset && pos < end) {
54
+ const at = pos - offset;
55
+ pieces.splice(index, 1, {...row, text: row.text.slice(0, at)}, {...row, text: row.text.slice(at)});
56
+ break;
57
+ }
58
+ offset = end;
59
+ }
60
+ offset = 0;
61
+ let first = pieces.length, last = pieces.length;
62
+ for (let index = 0; index < pieces.length; index++) {
63
+ if (offset === pos) { first = index; last = index; while (last < pieces.length && !pieces[last].text) last++; return {first, last}; }
64
+ offset += pieces[index].text?.length || 0;
65
+ }
66
+ if (offset !== pos) throw Error('range');
67
+ return {first, last};
68
+ };
69
+ const owns = (piece, hunk) => piece === hunk.marker || piece.owner === hunk.id ||
70
+ piece.restoredBy?.includes(hunk.id) || hunk.restoredMarkers?.has(piece);
71
+ const effectiveBefore = (hunk, effects, visiting = new Set()) => {
72
+ if (visiting.has(hunk.id)) return null;
73
+ visiting.add(hunk.id); let text = '';
74
+ for (const piece of hunk.before) {
75
+ if (!piece.text) continue;
76
+ const owner = byId.get(piece.owner);
77
+ if (owner && !effects.active.has(owner.actId) && !owner.kept) {
78
+ if (piece.text !== owner.inserted) return null;
79
+ const prior = effectiveBefore(owner, effects, new Set(visiting));
80
+ if (prior === null) return null;
81
+ text += prior;
82
+ } else text += piece.text;
83
+ }
84
+ return text;
85
+ };
86
+ const restoreHunk = hunk => {
87
+ if (!pieces.includes(hunk.marker)) throw Error('reactivation');
88
+ for (let at = pieces.length - 1; at >= 0; at--)
89
+ if (pieces[at] !== hunk.marker && owns(pieces[at], hunk)) pieces.splice(at, 1);
90
+ pieces.splice(pieces.indexOf(hunk.marker), 1, ...hunk.before);
91
+ for (const piece of hunk.before) if (piece.mark) { piece.mark.active = true; delete piece.mark.undoneBy; }
92
+ };
93
+ const laterSource = (hunk, effects) => [...hunk.supersededBy].some(id => {
94
+ const next = byId.get(id);
95
+ return next && effects.active.has(next.actId) && !prepared.effects.references.get(next.actId)?.length;
96
+ });
97
+ const reactivate = (actIds, effects) => {
98
+ for (const hunk of all) {
99
+ if (!actIds.has(hunk.actId) || hunk.derived || prepared.effects.references.get(hunk.actId)?.length) continue;
100
+ if (keptInverseAncestors.has(hunk.id)) continue;
101
+ if (laterSource(hunk, effects) || pieces.some(piece => piece.text && piece.owner === hunk.id) ||
102
+ (!hunk.inserted && hunk.active)) continue;
103
+ const marker = pieces.indexOf(hunk.marker);
104
+ if (marker < 0) {
105
+ // A zero-row withdrawal can release source still carried by another
106
+ // inverse's marker. Its exact ancestry also preserves later inserted runs.
107
+ const inverse = all.findLast(prior => prior.reversedHunk === hunk.id && pieces.includes(prior.marker));
108
+ if (!inverse) throw Error('reactivation');
109
+ if (laterSource(inverse, effects)) continue;
110
+ restoreHunk(inverse); inverse.active = false;
111
+ hunk.active = true; delete hunk.undoneBy;
112
+ continue;
113
+ }
114
+ const pos = textOf(pieces.slice(0, marker)).length, current = textOf(pieces);
115
+ const removed = effectiveBefore(hunk, effects);
116
+ if (removed === null || current.slice(pos, pos + removed.length) !== removed) throw Error('reactivation');
117
+ boundary(pos + removed.length);
118
+ const start = boundary(pos), end = boundary(pos + removed.length);
119
+ const at = start.last, count = Math.max(0, end.first - at);
120
+ if (textOf(pieces.slice(at, at + count)) !== removed) throw Error('reactivation');
121
+ const replacedOwners = new Set(pieces.slice(at, at + count).map(piece => piece.owner).filter(Boolean));
122
+ pieces.splice(at, count, ...(hunk.inserted ? [{text: hunk.inserted, owner: hunk.id, lineage: hunk.lineage.slice()}] : []));
123
+ for (const owner of replacedOwners) if (owner !== hunk.id && !pieces.some(piece => piece.text && piece.owner === owner)) {
124
+ const prior = byId.get(owner); if (prior) prior.active = false;
125
+ }
126
+ hunk.active = true; delete hunk.undoneBy;
127
+ }
128
+ };
129
+ const pictureInsertions = new Map(records.filter(record => prepared.effects.pictures.has(record.transaction.id))
130
+ .map(record => [prepared.effects.pictures.get(record.transaction.id), record.splices.map(row => row.removed)]));
131
+ for (const {record, splices, sourceIndices} of prepared.rows) {
132
+ const tx = record.transaction, references = new Set(prepared.effects.references.get(tx.id) || []);
133
+ const actSource = options.keepHunk && selected.has(tx.id) ? textOf(pieces) : null;
134
+ processed.push(record);
135
+ const picture = prepared.effects.pictures.get(tx.id);
136
+ if (picture) {
137
+ // The new codec bytes keep the exact inserted run's ownership. Do not
138
+ // cross a later edit, combine authors, or create another reversible act.
139
+ for (const row of splices) {
140
+ boundary(row.pos + row.removed.length);
141
+ const start = boundary(row.pos), end = boundary(row.pos + row.removed.length);
142
+ const removed = pieces.slice(start.last, end.first), owner = removed[0]?.owner, hunk = byId.get(owner);
143
+ if (!hunk || hunk.actId !== picture || removed.some(piece => !piece.text || piece.owner !== owner) || textOf(removed) !== row.removed)
144
+ throw Error('picture_ownership');
145
+ const at = hunk.inserted.indexOf(row.removed);
146
+ if (at < 0 || hunk.inserted.indexOf(row.removed, at + 1) >= 0) throw Error('picture_ownership');
147
+ hunk.inserted = hunk.inserted.slice(0, at) + row.inserted + hunk.inserted.slice(at + row.removed.length);
148
+ pieces.splice(start.last, end.first - start.last, {text: row.inserted, owner, lineage: hunk.lineage.slice()});
149
+ }
150
+ continue;
151
+ }
152
+ if (references.size) {
153
+ // Exact inverse rows restore the previous run identities. Retain the inverse's
154
+ // own transient marker too, so reversing it restores that same ancestry again.
155
+ const targets = all.filter(hunk => hunk.active && references.has(hunk.actId));
156
+ let restored = targets.length ? pieces.slice() : null;
157
+ const inverses = [], priorSplices = options.keepHunk ? [] : null;
158
+ for (let index = targets.length - 1; restored && index >= 0; index--) {
159
+ const target = targets[index], first = restored.indexOf(target.marker);
160
+ if (first < 0) { restored = null; break; }
161
+ let last = first;
162
+ for (let at = first; at < restored.length; at++) if (owns(restored[at], target)) last = at;
163
+ const pos = textOf(restored.slice(0, first)).length;
164
+ const before = restored.slice(first, last + 1), replacement = [];
165
+ for (const piece of before) {
166
+ if (piece === target.marker) replacement.push(...target.before);
167
+ else if (!owns(piece, target)) replacement.push(piece);
168
+ }
169
+ const id = tx.id + '#inverse:' + index, lineage = new Set([id]);
170
+ for (const piece of before) for (const parent of piece.lineage || piece.mark?.lineage || []) lineage.add(parent);
171
+ const hunk = {id, actId: tx.id, removed: textOf(before), inserted: textOf(replacement), before, reversedHunk: target.id,
172
+ chosen: selected.has(tx.id), lineage: [...lineage], supersededBy: new Set(), active: true,
173
+ restoredMarkers: new Set(replacement.filter(piece => !piece.text))};
174
+ if (hunk.chosen && options.keepHunk) {
175
+ const spliceIndex = splices.findIndex(row => row.pos <= pos && row.pos + row.removed.length >= pos);
176
+ const keep = options.keepHunk({actId: tx.id, spliceIndex: Math.max(0, spliceIndex), hunkIndex: index,
177
+ splice: {pos, removed: hunk.removed, inserted: hunk.inserted}, beforeSource: textOf(restored), actSource, priorSplices});
178
+ if (typeof keep !== 'boolean') throw Error('hunk_selection');
179
+ hunk.chosen = !keep;
180
+ }
181
+ hunk.kept = selected.has(tx.id) && !hunk.chosen;
182
+ // A semantic owner retaining this inverse also retains the exact ancestors
183
+ // it restored; effect reactivation must not change them behind that owner.
184
+ if (hunk.kept) for (let prior = target; prior; prior = byId.get(prior.reversedHunk)) keptInverseAncestors.add(prior.id);
185
+ hunk.marker = {mark: hunk};
186
+ const insertion = replacement.map(piece => piece.text ? {...piece,
187
+ lineage: [...new Set([...(piece.lineage || []), id])],
188
+ restoredBy: [...(piece.restoredBy || []), id]} : piece);
189
+ restored.splice(first, last + 1 - first, hunk.marker, ...insertion);
190
+ inverses.push(hunk); priorSplices?.push({pos, removed: hunk.removed, inserted: hunk.inserted});
191
+ }
192
+ if (restored && textOf(restored) === _rapierTransformSplices(textOf(pieces), splices)) {
193
+ pieces = restored;
194
+ for (const target of targets) { target.active = false; target.undoneBy = tx.id; }
195
+ for (const piece of pieces) if (piece.mark) {
196
+ piece.mark.active = true; delete piece.mark.undoneBy;
197
+ }
198
+ for (const hunk of inverses) { all.push(hunk); byId.set(hunk.id, hunk); }
199
+ byAct.set(tx.id, inverses);
200
+ continue;
201
+ }
202
+ }
203
+
204
+ if (references.size) {
205
+ const previous = _rapierHistoryEffects(processed.slice(0, -1)), current = _rapierHistoryEffects(processed);
206
+ const activated = new Set(current.effectiveActIds.filter(id => !previous.active.has(id) &&
207
+ id !== tx.id && !current.references.get(id)?.length));
208
+ if (activated.size) {
209
+ const before = pieces, priorStates = all.map(hunk => [hunk, hunk.active, hunk.undoneBy]); pieces = pieces.slice();
210
+ try {
211
+ reactivate(activated, current);
212
+ if (textOf(pieces) === _rapierTransformSplices(textOf(before), splices)) continue;
213
+ } catch (_) {}
214
+ pieces = before;
215
+ for (const [hunk, active, undoneBy] of priorStates) { hunk.active = active; hunk.undoneBy = undoneBy; }
216
+ }
217
+ }
218
+ const priorSplices = options.keepHunk ? [] : null;
219
+ // An admitted codec finish replaces the complete embedded definition/reference.
220
+ // Keep its inserted run whole, including byte-identical prefixes of a re-edit;
221
+ // the previous picture stays in `before` for Undo, not as mixed live ownership.
222
+ const hunks = row => pictureInsertions.get(tx.id)?.some(text => row.inserted.includes(text)) ? [{...row}] : historyHunks(row);
223
+ for (let rowIndex = 0; rowIndex < splices.length; rowIndex++) {
224
+ const original = splices[rowIndex];
225
+ let rows;
226
+ if (options.splitSplice) {
227
+ const beforeSource = textOf(pieces), parts = options.splitSplice({actId: tx.id, beforeSource, splice: original});
228
+ // Semantic boundaries only refine this act's exact bytes, never its history.
229
+ if (!Array.isArray(parts) || _rapierTransformSplices(beforeSource, parts) !== _rapierTransformSplices(beforeSource, [original]))
230
+ throw Error('hunk_projection');
231
+ rows = parts.flatMap(hunks);
232
+ } else rows = hunks(original);
233
+ for (let index = 0; index < rows.length; index++) {
234
+ const row = rows[index];
235
+ const derived = (sourceIndices?.[rowIndex] ?? rowIndex) === record.derivedCommentIndex;
236
+ let chosen = selected.has(tx.id) && !derived;
237
+ boundary(row.pos + row.removed.length);
238
+ const start = boundary(row.pos), end = boundary(row.pos + row.removed.length);
239
+ let at = start.last, count = Math.max(0, end.first - at);
240
+ if (!row.removed.length) {
241
+ // A new insertion at a replacement's left boundary stays before that replacement.
242
+ const marker = pieces.slice(start.first, start.last).findIndex(item => item.mark?.inserted.length);
243
+ at = marker < 0 ? start.last : start.first + marker; count = 0;
244
+ }
245
+ const removed = pieces.slice(at, at + count);
246
+ if (textOf(removed) !== row.removed) throw Error('replay');
247
+ // A semantic owner may retain a drawing hunk while prose in that same act undoes.
248
+ // The predicate only selects transient hunks; every original row still replays.
249
+ if (chosen && options.keepHunk) {
250
+ const keep = options.keepHunk({actId: tx.id, spliceIndex: rowIndex, hunkIndex: index,
251
+ splice: {pos: row.pos, removed: row.removed, inserted: row.inserted}, beforeSource: textOf(pieces), actSource, priorSplices});
252
+ if (typeof keep !== 'boolean') throw Error('hunk_selection');
253
+ chosen = !keep;
254
+ }
255
+ const id = tx.id + '#' + rowIndex + ':' + index;
256
+ const lineage = new Set([id]);
257
+ for (const piece of removed) for (const parent of piece.lineage || piece.mark?.lineage || []) lineage.add(parent);
258
+ const hunk = {id, actId: tx.id, removed: row.removed, inserted: row.inserted, before: removed, derived,
259
+ chosen, kept: selected.has(tx.id) && !chosen, lineage: [...lineage], supersededBy: new Set(), active: true};
260
+ hunk.marker = {mark: hunk};
261
+ all.push(hunk); byId.set(id, hunk);
262
+ if (!byAct.has(tx.id)) byAct.set(tx.id, []);
263
+ byAct.get(tx.id).push(hunk);
264
+ for (const parent of lineage) {
265
+ const earlier = byId.get(parent);
266
+ if (earlier && earlier !== hunk) earlier.supersededBy.add(id);
267
+ }
268
+ pieces.splice(at, count, hunk.marker, ...(row.inserted ? [{text: row.inserted, owner: id, lineage: [...lineage]}] : []));
269
+ priorSplices?.push({pos: row.pos, removed: row.removed, inserted: row.inserted});
270
+ }
271
+ }
272
+ }
273
+ if (textOf(pieces) !== prepared.source) throw Error('head');
274
+ const origins = new Map();
275
+ let offset = 0;
276
+ for (const piece of pieces) if (piece.text) { origins.set(piece, offset); offset += piece.text.length; }
277
+ const superseded = [];
278
+ for (let index = all.length - 1; index >= 0; index--) {
279
+ const hunk = all[index];
280
+ const inheritedInactive = !hunk.derived && !hunk.kept && !keptInverseAncestors.has(hunk.id) && !projectedEffects.active.has(hunk.actId) &&
281
+ !prepared.effects.references.get(hunk.actId)?.length;
282
+ if (!hunk.chosen && !inheritedInactive) continue;
283
+ hunk.active = prepared.effects.active.has(hunk.actId);
284
+ const laterActIds = [...new Set([...hunk.supersededBy].filter(id =>
285
+ (byId.get(id)?.kept || byId.get(id)?.active && projectedEffects.active.has(byId.get(id)?.actId))).map(id => byId.get(id).actId))];
286
+ const marker = pieces.indexOf(hunk.marker);
287
+ if ((!hunk.active && !inheritedInactive) || laterActIds.length || marker < 0) {
288
+ if (hunk.chosen) superseded.push({actId: hunk.actId, hunk: hunk.id, laterActIds: hunk.undoneBy ? [hunk.undoneBy] : laterActIds,
289
+ reason: hunk.active ? 'later_edit' : 'already_undone'});
290
+ continue;
291
+ }
292
+ // Restoring an inverse can restore a deletion marker without any owned text.
293
+ restoreHunk(hunk);
294
+ }
295
+ // A hidden withdrawal can have no splice of its own. Reversing that withdrawal
296
+ // reactivates the original hunk at its retained marker, never at a text search.
297
+ reactivate(new Set(records.filter(record => !prepared.effects.active.has(record.transaction.id) &&
298
+ projectedEffects.active.has(record.transaction.id)).map(record => record.transaction.id)), projectedEffects);
299
+ const after = textOf(pieces), changes = [];
300
+ let cursor = 0, inserted = '';
301
+ for (const piece of pieces) {
302
+ if (!piece.text) continue;
303
+ const origin = origins.get(piece);
304
+ if (origin == null) { inserted += piece.text; continue; }
305
+ if (origin < cursor) throw Error('source_order');
306
+ const removed = prepared.source.slice(cursor, origin);
307
+ if (removed !== inserted) changes.push(minimalHistorySplice(removed, inserted, cursor));
308
+ inserted = ''; cursor = origin + piece.text.length;
309
+ }
310
+ const removed = prepared.source.slice(cursor);
311
+ if (removed !== inserted) changes.push(minimalHistorySplice(removed, inserted, cursor));
312
+ const splices = changes.length <= 64 ? changes.reverse() : [minimalHistorySplice(prepared.source, after)];
313
+ if (_rapierTransformSplices(prepared.source, splices) !== after) throw Error('plan');
314
+ return {ok: true, splices, after, superseded, overlaps: superseded.filter(row => row.reason === 'later_edit')};
315
+ } catch (_) { return historyFailure('history_invalid'); }
316
+ }
@@ -124,6 +124,22 @@ function _rapierRecordSplices(entry, ledger) {
124
124
  } catch (_) { return null; }
125
125
  }
126
126
 
127
+ // Source identifies a retained checkpoint even when host revision numbers differ.
128
+ function journalOperations(before, after, journal) {
129
+ if (typeof before !== 'string' || typeof after !== 'string' || !Array.isArray(journal)) return;
130
+ if (before === after) return [];
131
+ let text = after;
132
+ const groups = [];
133
+ for (let index = journal.length - 1; index >= 0; index--) {
134
+ const rows = _rapierRecordSplices(journal[index], journal);
135
+ const prior = _rapierTransformSplices(text, rows, true);
136
+ if (prior === null) return;
137
+ groups.push(rows);
138
+ if (prior === before) return groups.reverse().flat().map(({pos, removed, inserted}) => ({pos, removed, inserted}));
139
+ text = prior;
140
+ }
141
+ }
142
+
127
143
  function _rapierRecordMetadata(entry, ledger) {
128
144
  try {
129
145
  if (Object.hasOwn(entry, 'metadata')) return _rapierValidMetadataEffect(entry.metadata) ? entry.metadata : undefined;
@@ -140,12 +156,61 @@ function _rapierRecordMetadata(entry, ledger) {
140
156
  } catch (_) { return undefined; }
141
157
  }
142
158
 
143
- // Active inverses suppress their targets. Reversing an inverse restores its targets,
144
- // including assignments hidden by later choices. Nothing outside these records is retained.
159
+ // A codec replacement owns only one inserted definition and its reference. Its
160
+ // source act remains the picture's author; an arbitrary edit cannot claim upkeep.
161
+ function _rapierPictureFinish(record, original, earlier) {
162
+ const tx = record?.transaction, rows = record?.splices;
163
+ if (tx?.operation !== 'document.finish-picture' || !tx.sourceTransactionId || tx.sourceTransactionIds ||
164
+ tx.reverts || tx.reapplies || record.metadata || !Array.isArray(rows) || rows.length !== 2 ||
165
+ original?.transaction?.operation !== 'document.embed-image' || !Array.isArray(original.splices)) return false;
166
+ const reference = rows.find(row => /^\]\[[a-z0-9-]+\]$/i.test(row.removed));
167
+ if (!reference || !/^\]\[[a-z0-9-]+\]$/i.test(reference.inserted) || reference.removed === reference.inserted) return false;
168
+ const definition = rows.find(row => row !== reference), oldLabel = reference.removed.slice(2, -1), newLabel = reference.inserted.slice(2, -1);
169
+ const body = (text, label) => {
170
+ const prefix = '[' + label + ']: data:image/svg+xml;base64,';
171
+ if (typeof text !== 'string' || !text.startsWith(prefix)) return null;
172
+ const value = text.slice(prefix.length), end = value.indexOf(' '), bytes = end < 0 ? value : value.slice(0, end), title = end < 0 ? '' : value.slice(end);
173
+ return /^[A-Za-z0-9+/]+={0,2}$/.test(bytes) && !/[\r\n]/.test(title) ? title : null;
174
+ };
175
+ const title = body(definition.removed, oldLabel);
176
+ if (title === null || body(definition.inserted, newLabel) !== title) return false;
177
+ if (![definition.removed, reference.removed].every(text => {
178
+ let count = 0;
179
+ for (const row of original.splices) for (let at = row.inserted.indexOf(text); at >= 0; at = row.inserted.indexOf(text, at + 1)) count++;
180
+ return count === 1;
181
+ })) return false;
182
+ return rows.every((row, index) => {
183
+ let start = row.pos, end = start + row.removed.length;
184
+ const cross = (splice, owner) => {
185
+ const stop = splice.pos + splice.inserted.length;
186
+ if (splice.pos < end && stop > start || !splice.inserted.length && splice.pos > start && splice.pos < end) {
187
+ if (owner && start >= splice.pos && end <= stop && splice.inserted.slice(start - splice.pos, end - splice.pos) === row.removed) return 'owned';
188
+ return 'changed';
189
+ }
190
+ if (stop <= start) { const shift = splice.removed.length - splice.inserted.length; start += shift; end += shift; }
191
+ return null;
192
+ };
193
+ for (const prior of rows.slice(0, index).reverse()) if (cross(prior, false)) return false;
194
+ for (let at = earlier.length - 1; at >= 0; at--) {
195
+ const prior = earlier[at], source = _rapierRecordSplices(prior, earlier);
196
+ if (!source) return false;
197
+ const owner = prior === original || prior.transaction?.reapplies === original.transaction.id;
198
+ for (const splice of source.slice().reverse()) {
199
+ const result = cross(splice, owner);
200
+ if (result) return result === 'owned';
201
+ }
202
+ }
203
+ return false;
204
+ });
205
+ }
206
+
207
+ // Every active inverse keeps its original targets suppressed. Independent inverses
208
+ // can share a target; withdrawing one restores it only after the last blocker leaves.
209
+ // This includes assignments hidden by later choices. No second history is retained.
145
210
  function _rapierHistoryEffects(records, visit = null) {
146
211
  try {
147
212
  if (!Array.isArray(records)) return {ok: false};
148
- const nodes = new Map(), references = new Map();
213
+ const nodes = new Map(), references = new Map(), pictures = new Map(), byId = new Map(), finished = new Set(), earlier = [];
149
214
  const update = (target, delta) => {
150
215
  const pending = [[target, delta]];
151
216
  while (pending.length) {
@@ -156,7 +221,32 @@ function _rapierHistoryEffects(records, visit = null) {
156
221
  if (was !== node.active) for (const ref of node.references) pending.push([ref, node.active ? 1 : -1]);
157
222
  }
158
223
  };
159
- const lastUndo = new Map();
224
+ const lastUndo = new Map(), contexts = new Map(), order = [];
225
+ // Native Redo names an original act. Its inverse target is the last Undo
226
+ // in the author's proved causal view, not an unrelated later arrival.
227
+ const contextOf = origin => {
228
+ if (!origin) return null;
229
+ if (!_rapierValidAuthored(origin)) throw Error('origin');
230
+ const known = new Set(), pending = origin.basis.slice();
231
+ while (pending.length) {
232
+ const id = pending.pop(), context = contexts.get(id);
233
+ if (!context) throw Error('origin');
234
+ if (known.has(id)) continue;
235
+ known.add(id);
236
+ if (context.basis) pending.push(...context.basis);
237
+ else for (let index = 0; index < context.index; index++) known.add(order[index]);
238
+ }
239
+ const inactive = new Set();
240
+ for (let index = order.length - 1; index >= 0; index--) {
241
+ const id = order[index];
242
+ if (!known.has(id) || inactive.has(id)) continue;
243
+ for (const ref of references.get(id)) {
244
+ if (!known.has(ref)) throw Error('origin');
245
+ inactive.add(ref);
246
+ }
247
+ }
248
+ return {known, inactive};
249
+ };
160
250
  for (const record of records) {
161
251
  const tx = record?.transaction;
162
252
  if (!tx || typeof tx.id !== 'string' || !tx.id || nodes.has(tx.id) || (tx.reverts && tx.reapplies)) return {ok: false};
@@ -164,24 +254,38 @@ function _rapierHistoryEffects(records, visit = null) {
164
254
  if (!Array.isArray(supplied) || new Set(supplied).size !== supplied.length ||
165
255
  (tx.sourceTransactionIds && tx.sourceTransactionId && (supplied.length !== 1 || supplied[0] !== tx.sourceTransactionId))) return {ok: false};
166
256
  let refs = supplied;
257
+ if (tx.operation === 'document.finish-picture' && tx.sourceTransactionId) {
258
+ const owner = tx.sourceTransactionId;
259
+ if (!nodes.get(owner)?.active || finished.has(owner) || !_rapierPictureFinish(record, byId.get(owner), earlier)) return {ok: false};
260
+ pictures.set(tx.id, owner); finished.add(owner); refs = [];
261
+ }
167
262
  if (tx.reverts || tx.reapplies) {
168
263
  const target = tx.reverts || tx.reapplies;
169
264
  if (!nodes.has(target) || (supplied.length && (supplied.length !== 1 || supplied[0] !== target))) return {ok: false};
170
265
  if (tx.reverts) refs = [target];
171
266
  else {
172
- const undo = lastUndo.get(target);
173
- if (!undo || !nodes.get(undo).active) return {ok: false};
267
+ const context = contextOf(record.authored || tx.authored);
268
+ const undo = (lastUndo.get(target) || []).findLast(id => context
269
+ ? context.known.has(id) && !context.inactive.has(id) : nodes.get(id).active);
270
+ if (!undo) return {ok: false};
174
271
  refs = [undo];
175
272
  }
176
273
  }
177
- if (refs.some(id => typeof id !== 'string' || !nodes.get(id)?.active)) return {ok: false};
274
+ if (refs.some(id => typeof id !== 'string' || !nodes.has(id) || pictures.has(id))) return {ok: false};
178
275
  nodes.set(tx.id, {active: true, blockers: 0, references: refs}); references.set(tx.id, refs.slice());
276
+ byId.set(tx.id, record);
277
+ earlier.push(record);
179
278
  for (const ref of refs) update(ref, 1);
180
- if (tx.reverts) lastUndo.set(tx.reverts, tx.id);
279
+ const origin = record.authored || tx.authored;
280
+ contexts.set(tx.id, {index: order.length, basis: origin?.basis || null}); order.push(tx.id);
281
+ if (tx.reverts) {
282
+ if (!lastUndo.has(tx.reverts)) lastUndo.set(tx.reverts, []);
283
+ lastUndo.get(tx.reverts).push(tx.id);
284
+ }
181
285
  if (visit) visit(record, nodes, refs);
182
286
  }
183
- const effectiveActIds = [...nodes].filter(([, node]) => node.active).map(([id]) => id);
184
- return {ok: true, effectiveActIds, active: new Set(effectiveActIds), references};
287
+ const effectiveActIds = [...nodes].filter(([id, node]) => node.active && !pictures.has(id)).map(([id]) => id);
288
+ return {ok: true, effectiveActIds, active: new Set(effectiveActIds), references, pictures};
185
289
  } catch (_) { return {ok: false}; }
186
290
  }
187
291
 
@@ -311,8 +415,8 @@ function _rapierValidLedgerRecord(record, prior, earlier, documentAuthority, max
311
415
  /^(?:human|agent|system)$/.test(transaction.actor.kind) &&
312
416
  typeof transaction.actor.id === 'string' && transaction.actor.id.length > 0 &&
313
417
  transaction.actor.id.length <= _RAPIER_TRANSACTION_ACTOR_LIMIT &&
314
- optional(transaction.actor.name, value => nullableText(value, 120, false));
418
+ optional(transaction.actor.name, value => identity(value, 120));
315
419
  } catch (_) { return false; }
316
420
  }
317
421
 
318
- export { _rapierValidAuthored, _RAPIER_TRANSACTION_ACTOR_LIMIT, _RAPIER_TRANSACTION_OPERATION_LIMIT, _RAPIER_TRANSACTION_REQUEST_LIMIT, _rapierTransformSplices, _rapierRecordSplices, _rapierValidLedgerRecord, _rapierJournalRecord, _rapierValidMetadata, _rapierValidMetadataEffect, _rapierMetadataDelta, _rapierTransformMetadata, _rapierRecordMetadata, _rapierHistoryEffects, _rapierMetadataState, _rapierReplayMetadata, _rapierHasHistoryEffect};
422
+ export { _rapierValidAuthored, _RAPIER_TRANSACTION_ACTOR_LIMIT, _RAPIER_TRANSACTION_OPERATION_LIMIT, _RAPIER_TRANSACTION_REQUEST_LIMIT, _rapierTransformSplices, _rapierRecordSplices, _rapierValidLedgerRecord, _rapierJournalRecord, _rapierValidMetadata, _rapierValidMetadataEffect, _rapierMetadataDelta, _rapierTransformMetadata, _rapierRecordMetadata, _rapierHistoryEffects, _rapierMetadataState, _rapierReplayMetadata, _rapierHasHistoryEffect, journalOperations};
@@ -39,7 +39,7 @@ function actFingerprint(record, effects) {
39
39
  const tx = record.transaction, references = effects.references.get(tx.id) || [];
40
40
  const {id, documentAuthority, baseRevision, revision, parent, reverts, reapplies,
41
41
  sourceTransactionId, sourceTransactionIds, remoteTransactionId, ...authored} = tx;
42
- return sha256(canonicalJSON({transaction: authored, references,
42
+ return sha256(canonicalJSON({transaction: authored, references, picture: effects.pictures.get(tx.id) || null,
43
43
  changeSet: record.changeSet ?? null, derivedCommentIndex: record.derivedCommentIndex ?? null,
44
44
  // Applied source placement is proved below, never inferred from this fingerprint.
45
45
  // An inverse's physical metadata is projected from its targets at the destination.
@@ -143,7 +143,7 @@ export function merge(first, second) {
143
143
  }
144
144
  const records = ours.ledger.records.slice(), imported = new Set(localIds.keys());
145
145
  let text = ours.text, metadata = {...ours.metadata}, root = ours.root;
146
- const append = (splices, original, effect = null, inverseIds = [], origin = original.authored) => {
146
+ const append = (splices, original, effect = null, inverseIds = [], origin = original.authored, picture = null) => {
147
147
  const next = _rapierTransformSplices(text, splices);
148
148
  if (next === null) return null;
149
149
  const beforeHash = root, revision = (records.at(-1)?.transaction.revision ?? ours.ledger.start.revision) + 1;
@@ -153,7 +153,7 @@ export function merge(first, second) {
153
153
  const record = {beforeHash, afterHash: nextRoot, splices, ...(origin ? {authored: origin} : {}), transaction: {
154
154
  ...tx, id: tx.id, documentAuthority: ours.ledger.documentAuthority,
155
155
  baseRevision: revision - 1, revision, actor: tx.actor, transport: tx.transport, operation: tx.operation, requestId: tx.requestId,
156
- sourceTransactionId: inverseIds.length === 1 ? inverseIds[0] : null,
156
+ sourceTransactionId: picture || (inverseIds.length === 1 ? inverseIds[0] : null),
157
157
  ...(inverseIds.length > 1 ? {sourceTransactionIds: inverseIds} : {}),
158
158
  ...(tx.turnId ? {turnId: tx.turnId} : {}), affectedBlockIds: tx.affectedBlockIds.slice(),
159
159
  parent: records.at(-1)?.transaction.id ?? null, reverts: null, reapplies: null, createdAt: tx.createdAt,
@@ -167,6 +167,7 @@ export function merge(first, second) {
167
167
  const nextMetadata = _rapierTransformMetadata(metadata, effect);
168
168
  if (!nextMetadata) return null;
169
169
  if (effect) record.metadata = effect;
170
+ if (picture && !_rapierHistoryEffects([...records, record]).ok) return null;
170
171
  records.push(record); text = next; metadata = nextMetadata; root = nextRoot;
171
172
  return record.transaction.id;
172
173
  };
@@ -209,6 +210,8 @@ export function merge(first, second) {
209
210
  const references = incomingEffects.references.get(record.transaction.id) || [];
210
211
  const inverseIds = references.map(id => mappedIds.get(id));
211
212
  if (inverseIds.some(id => !id || !_rapierHistoryEffects(records).active.has(id))) return metadataConflict('inverse_target_unavailable');
213
+ const pictureOwner = incomingEffects.pictures.get(record.transaction.id), picture = pictureOwner && mappedIds.get(pictureOwner);
214
+ if (pictureOwner && (!picture || !_rapierHistoryEffects(records).active.has(picture))) return refused('history', 'record_transport_conflict');
212
215
  let moved;
213
216
  try { moved = transposeAuthored(ours.ledger.start.text, records, {...record, authored: incomingOrigins.get(record.transaction.id)},
214
217
  theirs.ledger.records.slice(0, theirs.ledger.records.indexOf(record)).map(row => row.transaction.id)); } catch (_) {}
@@ -217,7 +220,7 @@ export function merge(first, second) {
217
220
  const effect = record.metadata ? Object.fromEntries(Object.entries(record.metadata).map(([key, pair]) =>
218
221
  [key, {before: metadata[key], after: pair.after}])) : null;
219
222
  const origin = record.authored ?? (against.length ? incomingOrigins.get(record.transaction.id) : null);
220
- const mapped = append(moved.splices, record, effect, inverseIds, origin);
223
+ const mapped = append(moved.splices, record, effect, inverseIds, origin, picture);
221
224
  if (!mapped) return metadataConflict(inverseIds.length ? 'inverse_target_unavailable' : 'record_transport_conflict');
222
225
  against = against.filter(row => row.transaction.id !== record.transaction.id);
223
226
  mappedIds.set(record.transaction.id, mapped);