rapier-html 1.1.104 → 1.2.5

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.
@@ -1,10 +1,10 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  // Transient projections and selective inverses of the existing canonical journal.
3
- import {_rapierTransformSplices, _rapierRecordSplices, _rapierRecordMetadata, _rapierValidMetadata, _rapierTransformMetadata, _rapierMetadataDelta, _rapierHistoryEffects, _rapierMetadataState, _rapierReplayMetadata, _rapierPrepareHistoryRecords} from './journal-records.mjs';
3
+ import {_rapierTransformSplices, _rapierValidMetadata, _rapierTransformMetadata, _rapierMetadataDelta, _rapierHistoryEffects, _rapierMetadataState, _rapierPrepareHistoryRecords} from './journal-records.mjs';
4
4
  import {projectInverseSource, HistorySource} from './inverse-source.mjs';
5
5
  import {textRootParts, rootAfter} from './hash.mjs';
6
- import {authoredHistory} from './transport.mjs';
7
- import {plainData} from './data.mjs';
6
+ import {authoredHistory, authoredPlacements} from './transport.mjs';
7
+ import {plainData, canonicalJSON} from './data.mjs';
8
8
 
9
9
  const historyFailure = reason => ({ok: false, reason});
10
10
  const historyInteger = value => Number.isSafeInteger(value) && value >= 0 && !Object.is(value, -0);
@@ -43,11 +43,15 @@ export function groupHistoryActs(acts) {
43
43
 
44
44
  // A picture's codec replacement travels with its original navigation step.
45
45
  // Match every complete replacement exactly before moving any source bytes.
46
- export function pictureHistorySplices({source, records}, target, redo) {
46
+ export function pictureHistorySplices(input, target, redo) {
47
+ const {records} = input;
47
48
  const id = target?.transaction?.id, at = records.indexOf(target);
48
49
  if (!id || at < 0 || target.transaction.operation === 'document.finish-picture' || !Array.isArray(target.splices)) return null;
49
50
  const later = records.slice(at + 1).filter(row => row?.transaction?.operation === 'document.finish-picture' && Array.isArray(row.splices) && row.splices.length);
50
51
  if (!later.length) return null;
52
+ // A caller may hold a persistent source tree: request its text only when
53
+ // a later picture replacement can actually affect this navigation step.
54
+ const source = input.source;
51
55
  const own = new Set(later.filter(row => row.transaction.sourceTransactionId === id));
52
56
  const step = redo ? target.splices : target.splices.slice().reverse().map(row => ({pos: row.pos, removed: row.inserted, inserted: row.removed}));
53
57
  const plan = moveOthers => {
@@ -100,8 +104,11 @@ function prepareHistory(input) {
100
104
  !(tx.createdAt === null || historyInteger(tx.createdAt)) ||
101
105
  (tx.turnId != null && (typeof tx.turnId !== 'string' || !tx.turnId || tx.turnId.length > 160))) return historyFailure('history_invalid');
102
106
  if (tx.baseRevision !== prior) complete = false;
107
+ const coincidentWitness = !splices.length && record.authored && Array.isArray(record.authored.splices) &&
108
+ record.authored.splices.some(row => row.removed.length);
103
109
  if (!splices || effect === undefined || (!splices.length &&
104
- !Object.values(effect || {}).some(pair => pair.before !== pair.after) && !effects.references.get(tx.id)?.length)) return historyFailure('history_unavailable');
110
+ !Object.values(effect || {}).some(pair => pair.before !== pair.after) && !effects.references.get(tx.id)?.length &&
111
+ !coincidentWitness)) return historyFailure('history_unavailable');
105
112
  const row = {record, splices, metadata: effect};
106
113
  rows.push(row); byId.set(tx.id, row); prior = tx.revision;
107
114
  }
@@ -116,7 +123,9 @@ function prepareHistory(input) {
116
123
  row.beforeSource = initial; row.beforeMetadata = initialMetadata;
117
124
  }
118
125
  const earliestRevision = records[0]?.transaction.baseRevision ?? revision;
119
- let root = earliestRevision === 0 ? textRootParts(initial.parts()) : null, rootRevision = earliestRevision;
126
+ // The window's owner may name its root (the source store mints it once per text); otherwise hash the initial text.
127
+ let root = earliestRevision === 0 ? (typeof input.earliestRoot === 'string' && input.earliestRoot || textRootParts(initial.parts())) : null,
128
+ rootRevision = earliestRevision;
120
129
  for (const {record, splices} of rows) {
121
130
  const hashed = Object.hasOwn(record, 'beforeHash') || Object.hasOwn(record, 'afterHash');
122
131
  if (hashed && (![record.beforeHash, record.afterHash].every(value =>
@@ -281,10 +290,19 @@ export function historyPlaces(input, blocks) {
281
290
  }
282
291
  touched.set(node, after);
283
292
  });
293
+ // Blocks one splice covers share its range (a whole-document delete maps every block to all it removed): slice each range once.
294
+ const slices = new Map(), cut = (source, side, from, to) => {
295
+ const key = side + from + ':' + to;
296
+ if (!slices.has(key)) slices.set(key, source.slice(from, to));
297
+ return slices.get(key);
298
+ };
299
+ // One record's act is read once; the blocks it touched share its fields (a whole-document delete touches every block).
300
+ let act = null;
284
301
  for (const [node, afterRange] of touched) {
285
- const beforeRange = ranges.range(node), previous = {...beforeRange, source: beforeSource.slice(beforeRange.from, beforeRange.to)},
286
- after = {...afterRange, source: afterSource.slice(afterRange.from, afterRange.to)};
287
- if (previous.source !== after.source) node.block.acts.push({...historyAct(record), before: previous, after});
302
+ const beforeRange = ranges.range(node), previous = {...beforeRange, source: cut(beforeSource, 'b', beforeRange.from, beforeRange.to)},
303
+ after = {...afterRange, source: cut(afterSource, 'a', afterRange.from, afterRange.to)};
304
+ // A point (where words were deleted) has no words to compare: the act that touched it wrote there.
305
+ if (previous.source !== after.source || afterRange.from === afterRange.to) node.block.acts.push({...(act ||= historyAct(record)), before: previous, after});
288
306
  }
289
307
  }
290
308
 
@@ -322,7 +340,27 @@ export function selectiveUndo(input, actIds, options = {}) {
322
340
  const metadata = _rapierMetadataDelta(prepared.metadata, afterMetadata), effectChanged = selected.size > 0;
323
341
  const sourceEffectChanged = records.some(record => record.splices.length && !prepared.effects.references.get(record.transaction.id)?.length &&
324
342
  prepared.effects.active.has(record.transaction.id) !== projectedEffects.active.has(record.transaction.id));
325
- if (!sourceEffectChanged) return {ok: true, splices: [], after: prepared.source, actIds: [...requested],
343
+ // One shared replacement is visible while any of its authors is active. That
344
+ // includes an author stored with no second physical splice, and an inverse
345
+ // whose only effect is to make such an author active again.
346
+ let coverVisibilityChanged = false;
347
+ if (!sourceEffectChanged && selected.size) {
348
+ let origins = null;
349
+ try { origins = authoredPlacements(prepared.initialSource, records); } catch { origins = null; }
350
+ const groups = new Map();
351
+ if (origins) for (const record of records) {
352
+ const tx = record.transaction, origin = origins.get(tx.id);
353
+ if (!origin?.splices.some(row => row.removed.length) || tx.reverts || tx.reapplies ||
354
+ tx.sourceTransactionId || tx.sourceTransactionIds?.length) continue;
355
+ const key = canonicalJSON({basis: origin.basis, splices: origin.splices});
356
+ if (!groups.has(key)) groups.set(key, []);
357
+ groups.get(key).push(tx.id);
358
+ }
359
+ const covered = (effects, ids) => ids.some(id => effects.active.has(id));
360
+ for (const ids of groups.values()) if (ids.length > 1 && covered(prepared.effects, ids) !== covered(projectedEffects, ids))
361
+ coverVisibilityChanged = true;
362
+ }
363
+ if (!sourceEffectChanged && !coverVisibilityChanged) return {ok: true, splices: [], after: prepared.source, actIds: [...requested],
326
364
  sourceTransactionIds: [...selected], metadata, afterMetadata, effectChanged, unchanged: !metadata && !effectChanged,
327
365
  superseded: [], overlaps: []};
328
366
  try {
@@ -2,7 +2,9 @@
2
2
  // Exact source projection shared by canonical history and causal transport.
3
3
  // Callers admit and order the records first; this projection retains no state.
4
4
  import {_rapierTransformSplices, _rapierHistoryEffects} from './journal-records.mjs';
5
- import {diff} from './diff.mjs';
5
+ import {authoredPlacements} from './authored-origin.mjs';
6
+ import {canonicalJSON} from './data.mjs';
7
+ import {diff} from './diff/index.mjs';
6
8
 
7
9
  const historyFailure = reason => ({ok: false, reason});
8
10
  const historyBoundary = (source, at) => Number.isSafeInteger(at) && at >= 0 && !Object.is(at, -0) && at <= source.length &&
@@ -174,6 +176,24 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
174
176
  try {
175
177
  let pieces = prepared.initialSource ? [{text: prepared.initialSource, owner: null, lineage: []}] : [];
176
178
  const all = [], byId = new Map(), byAct = new Map(), processed = [], keptInverseAncestors = new Set();
179
+ const recordsForCover = prepared.records || prepared.rows.map(({record, splices}) => record.splices === splices ? record : {...record, splices});
180
+ const coverGroups = new Map();
181
+ let coverOrigins = null;
182
+ // An empty document is a real causal source. Fresh notes start there, and a
183
+ // shared replacement authored against that source still has to stay covered.
184
+ if (typeof prepared.initialSource === 'string' && recordsForCover.some(record => record.authored?.splices?.some(row => row.removed.length))) {
185
+ try { coverOrigins = authoredPlacements(prepared.initialSource, recordsForCover); } catch { coverOrigins = null; }
186
+ }
187
+ const coverIdsFor = id => {
188
+ const origin = coverOrigins?.get(id);
189
+ if (!origin?.splices.some(row => row.removed.length)) return null;
190
+ const key = canonicalJSON({basis: origin.basis, splices: origin.splices});
191
+ if (!coverGroups.has(key)) coverGroups.set(key, []);
192
+ const ids = coverGroups.get(key);
193
+ if (!ids.includes(id)) ids.push(id);
194
+ return ids;
195
+ };
196
+ const coverHeld = hunk => hunk.coverIds?.length > 1 && hunk.coverIds.some(id => projectedEffects.active.has(id));
177
197
  const texts = rows => rows.map(row => row.text || '');
178
198
  const textOf = rows => texts(rows).join('');
179
199
  const lengthOf = (rows, to = rows.length) => { let size = 0; for (let i = 0; i < to; i++) size += rows[i].text?.length || 0; return size; };
@@ -234,8 +254,16 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
234
254
  return next && effects.active.has(next.actId) && !prepared.effects.references.get(next.actId)?.length;
235
255
  });
236
256
  const reactivate = (actIds, effects) => {
257
+ // Any author of one shared removal or replacement restores that one physical
258
+ // hunk, including an author who stored no splice. A later independent act
259
+ // keeps the bytes it wrote until that act itself is withdrawn.
260
+ const wanted = new Set(actIds);
261
+ for (const id of actIds) for (const hunk of all) {
262
+ if (hunk.reversedHunk || hunk.derived || !hunk.coverIds?.includes(id) || wanted.has(hunk.actId)) continue;
263
+ if (hunk.coverIds.some(cover => effects.active.has(cover))) wanted.add(hunk.actId);
264
+ }
237
265
  for (const hunk of all) {
238
- if (!actIds.has(hunk.actId) || hunk.derived || prepared.effects.references.get(hunk.actId)?.length) continue;
266
+ if (!wanted.has(hunk.actId) || hunk.derived || prepared.effects.references.get(hunk.actId)?.length) continue;
239
267
  if (keptInverseAncestors.has(hunk.id)) continue;
240
268
  if (laterSource(hunk, effects) || pieces.some(piece => piece.text && piece.owner === hunk.id) ||
241
269
  (!hunk.inserted && hunk.active)) continue;
@@ -245,8 +273,9 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
245
273
  // inverse's marker. Its exact ancestry also preserves later inserted runs.
246
274
  const inverse = all.findLast(prior => prior.reversedHunk === hunk.id && pieces.includes(prior.marker));
247
275
  if (!inverse) throw Error('reactivation');
248
- if (laterSource(inverse, effects)) continue;
249
- restoreHunk(inverse); inverse.active = false;
276
+ const runs = all.filter(prior => prior.actId === inverse.actId && prior.reversedHunk === hunk.id && pieces.includes(prior.marker));
277
+ if (runs.some(prior => laterSource(prior, effects))) continue;
278
+ for (const prior of runs) { restoreHunk(prior); prior.active = false; }
250
279
  hunk.active = true; delete hunk.undoneBy;
251
280
  continue;
252
281
  }
@@ -291,51 +320,65 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
291
320
  if (references.size) {
292
321
  // Exact inverse rows restore the previous run identities. Retain the inverse's
293
322
  // own transient marker too, so reversing it restores that same ancestry again.
294
- const targets = all.filter(hunk => hunk.active && references.has(hunk.actId));
295
- let restored = targets.length ? pieces.slice() : null;
296
- const inverses = [], priorSplices = options.keepHunk ? [] : null;
297
- for (let index = targets.length - 1; restored && index >= 0; index--) {
298
- const target = targets[index], first = restored.indexOf(target.marker);
299
- if (first < 0) { restored = null; break; }
300
- let last = first;
301
- for (let at = first; at < restored.length; at++) if (owns(restored[at], target)) last = at;
302
- const pos = lengthOf(restored, first);
303
- const before = restored.slice(first, last + 1), replacement = [];
304
- for (const piece of before) {
305
- if (piece === target.marker) replacement.push(...target.before);
306
- else if (!owns(piece, target)) replacement.push(piece);
307
- }
308
- const id = tx.id + '#inverse:' + index, lineage = new Set([id]);
309
- for (const piece of before) for (const parent of piece.lineage || piece.mark?.lineage || []) lineage.add(parent);
310
- const hunk = {id, actId: tx.id, removed: textOf(before), inserted: textOf(replacement), before, reversedHunk: target.id,
311
- chosen: selected.has(tx.id), lineage: [...lineage], supersededBy: new Set(), active: true,
312
- restoredMarkers: new Set(replacement.filter(piece => !piece.text))};
313
- if (hunk.chosen && options.keepHunk) {
314
- const spliceIndex = splices.findIndex(row => row.pos <= pos && row.pos + row.removed.length >= pos);
315
- const keep = options.keepHunk({actId: tx.id, spliceIndex: Math.max(0, spliceIndex), hunkIndex: index,
316
- splice: {pos, removed: hunk.removed, inserted: hunk.inserted}, beforeSource: textOf(restored), actSource, priorSplices});
317
- if (typeof keep !== 'boolean') throw Error('hunk_selection');
318
- hunk.chosen = !keep;
323
+ const invert = targets => {
324
+ const restored = pieces.slice(), inverses = [], priorSplices = options.keepHunk ? [] : null;
325
+ for (let index = targets.length - 1; index >= 0; index--) {
326
+ const target = targets[index], first = restored.indexOf(target.marker);
327
+ if (first < 0) return null;
328
+ // Each run of the target's own pieces inverts alone: a later author's pieces between
329
+ // its runs stay in place, outside every inverse, so reversing it cannot move them.
330
+ const runs = [];
331
+ for (let at = first; at < restored.length; at++) if (owns(restored[at], target))
332
+ if (runs.at(-1)?.[1] === at) runs.at(-1)[1]++; else runs.push([at, at + 1]);
333
+ for (let run = runs.length - 1; run >= 0; run--) {
334
+ const [from, to] = runs[run], before = restored.slice(from, to), pos = lengthOf(restored, from);
335
+ const replacement = run ? [] : target.before;
336
+ const id = tx.id + '#inverse:' + index + (run ? '.' + run : ''), lineage = new Set([id]);
337
+ for (const piece of before) for (const parent of piece.lineage || piece.mark?.lineage || []) lineage.add(parent);
338
+ const hunk = {id, actId: tx.id, removed: textOf(before), inserted: textOf(replacement), before, reversedHunk: target.id,
339
+ chosen: selected.has(tx.id), lineage: [...lineage], supersededBy: new Set(), active: true,
340
+ restoredMarkers: new Set(replacement.filter(piece => !piece.text))};
341
+ if (hunk.chosen && options.keepHunk) {
342
+ const spliceIndex = splices.findIndex(row => row.pos <= pos && row.pos + row.removed.length >= pos);
343
+ const keep = options.keepHunk({actId: tx.id, spliceIndex: Math.max(0, spliceIndex), hunkIndex: index,
344
+ splice: {pos, removed: hunk.removed, inserted: hunk.inserted}, beforeSource: textOf(restored), actSource, priorSplices});
345
+ if (typeof keep !== 'boolean') throw Error('hunk_selection');
346
+ hunk.chosen = !keep;
347
+ }
348
+ hunk.kept = selected.has(tx.id) && !hunk.chosen;
349
+ // A semantic owner retaining this inverse also retains the exact ancestors
350
+ // it restored; effect reactivation must not change them behind that owner.
351
+ if (hunk.kept) for (let prior = target; prior; prior = byId.get(prior.reversedHunk)) keptInverseAncestors.add(prior.id);
352
+ hunk.marker = {mark: hunk};
353
+ const insertion = replacement.map(piece => piece.text ? {...piece,
354
+ lineage: [...new Set([...(piece.lineage || []), id])],
355
+ restoredBy: [...(piece.restoredBy || []), id]} : piece);
356
+ restored.splice(from, to - from, hunk.marker, ...insertion);
357
+ inverses.push(hunk); priorSplices?.push({pos, removed: hunk.removed, inserted: hunk.inserted});
358
+ }
319
359
  }
320
- hunk.kept = selected.has(tx.id) && !hunk.chosen;
321
- // A semantic owner retaining this inverse also retains the exact ancestors
322
- // it restored; effect reactivation must not change them behind that owner.
323
- if (hunk.kept) for (let prior = target; prior; prior = byId.get(prior.reversedHunk)) keptInverseAncestors.add(prior.id);
324
- hunk.marker = {mark: hunk};
325
- const insertion = replacement.map(piece => piece.text ? {...piece,
326
- lineage: [...new Set([...(piece.lineage || []), id])],
327
- restoredBy: [...(piece.restoredBy || []), id]} : piece);
328
- restored.splice(first, last + 1 - first, hunk.marker, ...insertion);
329
- inverses.push(hunk); priorSplices?.push({pos, removed: hunk.removed, inserted: hunk.inserted});
330
- }
331
- if (restored && replays(pieces, restored, splices)) {
332
- pieces = restored;
333
- for (const target of targets) { target.active = false; target.undoneBy = tx.id; }
360
+ return replays(pieces, restored, splices) ? {restored, inverses} : null;
361
+ };
362
+ // An inverse taken while a later edit had replaced some of the act's hunks restored only
363
+ // the rest. Those replaced hunks survive it: no later projection treats them as undone.
364
+ // Recorded splices decide. An author who stored no splice still inverts the one
365
+ // shared hunk; an empty inverse does not match that text change and leaves it.
366
+ const direct = all.filter(hunk => hunk.active && references.has(hunk.actId));
367
+ const targets = direct.length ? direct : all.filter(hunk => hunk.active && !hunk.reversedHunk && !hunk.derived &&
368
+ hunk.coverIds?.some(id => references.has(id)));
369
+ const free = targets.filter(hunk => ![...hunk.supersededBy].some(id => byId.get(id)?.active));
370
+ let found = targets.length ? invert(targets) : null, survivors = [];
371
+ if (!found && free.length && free.length < targets.length && (found = invert(free)))
372
+ survivors = targets.filter(hunk => !free.includes(hunk));
373
+ if (found) {
374
+ pieces = found.restored;
375
+ for (const target of targets) if (survivors.includes(target)) target.survived = true;
376
+ else { target.active = false; target.undoneBy = tx.id; }
334
377
  for (const piece of pieces) if (piece.mark) {
335
378
  piece.mark.active = true; delete piece.mark.undoneBy;
336
379
  }
337
- for (const hunk of inverses) { all.push(hunk); byId.set(hunk.id, hunk); }
338
- byAct.set(tx.id, inverses);
380
+ for (const hunk of found.inverses) { all.push(hunk); byId.set(hunk.id, hunk); }
381
+ byAct.set(tx.id, found.inverses);
339
382
  continue;
340
383
  }
341
384
  }
@@ -354,6 +397,7 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
354
397
  for (const [hunk, active, undoneBy] of priorStates) { hunk.active = active; hunk.undoneBy = undoneBy; }
355
398
  }
356
399
  }
400
+ if (!references.size) coverIdsFor(tx.id);
357
401
  const priorSplices = options.keepHunk ? [] : null;
358
402
  // An admitted codec finish replaces the complete embedded definition/reference.
359
403
  // Keep its inserted run whole, including byte-identical prefixes of a re-edit;
@@ -395,7 +439,8 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
395
439
  const lineage = new Set([id]);
396
440
  for (const piece of removed) for (const parent of piece.lineage || piece.mark?.lineage || []) lineage.add(parent);
397
441
  const hunk = {id, actId: tx.id, removed: row.removed, inserted: row.inserted, before: removed, derived,
398
- chosen, kept: selected.has(tx.id) && !chosen, lineage: [...lineage], supersededBy: new Set(), active: true};
442
+ chosen, kept: selected.has(tx.id) && !chosen, lineage: [...lineage], supersededBy: new Set(), active: true,
443
+ ...(coverIdsFor(tx.id) ? {coverIds: coverIdsFor(tx.id)} : {})};
399
444
  hunk.marker = {mark: hunk};
400
445
  all.push(hunk); byId.set(id, hunk);
401
446
  if (!byAct.has(tx.id)) byAct.set(tx.id, []);
@@ -414,9 +459,22 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
414
459
  let offset = 0;
415
460
  for (const piece of pieces) if (piece.text) { origins.set(piece, offset); offset += piece.text.length; }
416
461
  const superseded = [];
462
+ // Survival means an inverse left this hunk in place because a later independent
463
+ // edit still covered it. Once that edit is gone, deactivating its last owner through
464
+ // any inverse path withdraws every exposed hunk. Undoing only the later
465
+ // edit reveals the survived insertion and does not discard it.
466
+ for (const hunk of all) if (hunk.survived) {
467
+ const covered = [...hunk.supersededBy].some(id => {
468
+ const next = byId.get(id);
469
+ return next?.active && projectedEffects.active.has(next.actId) && !prepared.effects.references.get(next.actId)?.length;
470
+ });
471
+ const owners = hunk.coverIds?.length ? hunk.coverIds : [hunk.actId];
472
+ if (!covered && owners.some(id => prepared.effects.active.has(id)) && !owners.some(id => projectedEffects.active.has(id))) hunk.survived = false;
473
+ }
417
474
  for (let index = all.length - 1; index >= 0; index--) {
418
475
  const hunk = all[index];
419
- const inheritedInactive = !hunk.derived && !hunk.kept && !keptInverseAncestors.has(hunk.id) && !projectedEffects.active.has(hunk.actId) &&
476
+ if (coverHeld(hunk)) continue;
477
+ const inheritedInactive = !hunk.derived && !hunk.kept && !hunk.survived && !keptInverseAncestors.has(hunk.id) && !projectedEffects.active.has(hunk.actId) &&
420
478
  !prepared.effects.references.get(hunk.actId)?.length;
421
479
  if (!hunk.chosen && !inheritedInactive) continue;
422
480
  hunk.active = prepared.effects.active.has(hunk.actId);
@@ -432,9 +490,13 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
432
490
  restoreHunk(hunk);
433
491
  }
434
492
  // A hidden withdrawal can have no splice of its own. Reversing that withdrawal
435
- // reactivates the original hunk at its retained marker, never at a text search.
436
- reactivate(new Set(records.filter(record => !prepared.effects.active.has(record.transaction.id) &&
437
- projectedEffects.active.has(record.transaction.id)).map(record => record.transaction.id)), projectedEffects);
493
+ // reactivates the shared hunk through the inverse that still carries it.
494
+ // Withdrawing a later act reveals that same hunk while any original author remains.
495
+ const reactivated = new Set(records.filter(record => !prepared.effects.active.has(record.transaction.id) &&
496
+ projectedEffects.active.has(record.transaction.id)).map(record => record.transaction.id));
497
+ for (const hunk of all) if (!hunk.reversedHunk && !hunk.derived && hunk.coverIds?.length > 1 &&
498
+ hunk.coverIds.some(id => projectedEffects.active.has(id))) reactivated.add(hunk.actId);
499
+ reactivate(reactivated, projectedEffects);
438
500
  const after = textOf(pieces), changes = [];
439
501
  let cursor = 0, inserted = '';
440
502
  for (const piece of pieces) {
@@ -1,4 +1,5 @@
1
1
  // SPDX-License-Identifier: MIT
2
+ import {fields, scalar, plainPrototype} from './data.mjs';
2
3
 
3
4
  const _RAPIER_TRANSACTION_ACTOR_LIMIT = 160;
4
5
  const _RAPIER_TRANSACTION_OPERATION_LIMIT = 96;
@@ -32,32 +33,15 @@ function _rapierTransformSplices(text, splices, inverse = false) {
32
33
  }
33
34
 
34
35
  function _rapierPlainLedgerData(record) {
35
- try {
36
- const active = new Set(), pending = [[record, false]];
37
- while (pending.length) {
38
- const [value, leaving] = pending.pop();
39
- if (leaving) { active.delete(value); continue; }
40
- if (value === null || typeof value === 'boolean') continue;
41
- if (typeof value === 'string') { if (/[\uD800-\uDFFF]/u.test(value)) return false; continue; }
42
- if (typeof value === 'number') { if (!Number.isFinite(value) || Object.is(value, -0)) return false; continue; }
43
- if (!value || typeof value !== 'object' || active.has(value)) return false;
44
- const array = Array.isArray(value), proto = Object.getPrototypeOf(value);
45
- if (!array && proto !== null && (Object.getPrototypeOf(proto) !== null ||
46
- Object.getOwnPropertyDescriptor(proto, 'constructor')?.value?.name !== 'Object')) return false;
47
- const keys = Reflect.ownKeys(value);
48
- if (array && keys.length !== value.length + 1) return false;
49
- active.add(value); pending.push([value, true]);
50
- for (const key of keys) {
51
- if (array && key === 'length') continue;
52
- if (typeof key !== 'string' || ['__proto__', 'constructor', 'prototype'].includes(key) ||
53
- (array && (!Number.isSafeInteger(Number(key)) || Number(key) < 0 || Number(key) >= value.length || String(Number(key)) !== key))) return false;
54
- const field = Object.getOwnPropertyDescriptor(value, key);
55
- if (!field || !Object.hasOwn(field, 'value') || !field.enumerable) return false;
56
- pending.push([field.value, false]);
57
- }
58
- }
59
- return true;
60
- } catch (_) { return false; }
36
+ const active = new Set();
37
+ const walk = (value, depth) => {
38
+ if (scalar(value)) return;
39
+ const {descriptors, keys} = fields(value, depth, active, false);
40
+ active.add(value);
41
+ for (const key of keys) walk(descriptors[key].value, depth + 1);
42
+ active.delete(value);
43
+ };
44
+ try { walk(record, 0); return true; } catch (_) { return false; }
61
45
  }
62
46
 
63
47
  const _rapierMetadataValue = (key, value) => key === 'filename'
@@ -156,9 +140,16 @@ function _rapierRecordSplices(entry, ledger) {
156
140
  }
157
141
 
158
142
  // Source identifies a retained checkpoint even when host revision numbers differ.
143
+ // The History Slider compares now → earlier: that answer is the earlier → now walk, inverted.
159
144
  function journalOperations(before, after, journal) {
160
145
  if (typeof before !== 'string' || typeof after !== 'string' || !Array.isArray(journal)) return;
161
146
  if (before === after) return [];
147
+ const forward = journalWalk(before, after, journal);
148
+ if (forward) return forward;
149
+ return journalWalk(after, before, journal)?.reverse().map(({pos, removed, inserted}) => ({pos, removed: inserted, inserted: removed}));
150
+ }
151
+
152
+ function journalWalk(before, after, journal) {
162
153
  try {
163
154
  let text = after;
164
155
  const lookup = _rapierRecordIndex(journal), groups = [];
@@ -480,8 +471,7 @@ const _rapierPrepareHistoryRecords = (() => {
480
471
  // input later cannot retroactively certify an earlier view of its properties.
481
472
  frame.frozen = frame.immutable = Object.isFrozen(value);
482
473
  const array = Array.isArray(value), proto = Object.getPrototypeOf(value);
483
- if (!array && proto !== null && (Object.getPrototypeOf(proto) !== null ||
484
- Object.getOwnPropertyDescriptor(Object.getOwnPropertyDescriptor(proto, 'constructor')?.value || {}, 'name')?.value !== 'Object')) throw Error('history_data');
474
+ if (!array && !plainPrototype(proto)) throw Error('history_data');
485
475
  const descriptors = Object.getOwnPropertyDescriptors(value), keys = Reflect.ownKeys(descriptors);
486
476
  if (array && keys.length !== descriptors.length.value + 1) throw Error('history_data');
487
477
  for (const key of keys) {
@@ -588,7 +578,12 @@ function _rapierCheckLedgerRecord(record, prior, documentAuthority, maxDocumentB
588
578
  const own = Array.isArray(record.splices), navigation = !!(record.transaction?.reverts || record.transaction?.reapplies);
589
579
  if ((!own && !navigation) || (own && record.splices.length > 64) ||
590
580
  (record.transaction?.reverts && record.transaction?.reapplies)) return false;
591
- if (metadata === undefined || (!splices?.length && !Object.values(metadata || {}).some(pair => pair.before !== pair.after) && !semantic) ||
581
+ const metadataChanged = Object.values(metadata || {}).some(pair => pair.before !== pair.after);
582
+ // An identical concurrent replacement keeps its authored rows and an empty
583
+ // physical splice. The origin proof, not this gate, shows the effect is already present.
584
+ const coincidentWitness = !splices?.length && record.beforeHash === record.afterHash &&
585
+ record.authored && _rapierValidAuthored(record.authored) && record.authored.splices.some(row => row.removed.length);
586
+ if (metadata === undefined || (!splices?.length && !metadataChanged && !semantic && !coincidentWitness) ||
592
587
  (record.beforeHash === record.afterHash && splices?.length) ||
593
588
  (record.beforeHash !== record.afterHash && !splices?.length) ||
594
589
  ((record.transaction?.sourceTransactionId || record.transaction?.sourceTransactionIds?.length || navigation) && !semantic)) return false;
@@ -2,6 +2,7 @@
2
2
  // Paired source placement, shared by live editing and canonical-history proof.
3
3
  import {_rapierTransformSplices, _rapierValidAuthored, _rapierHistoryEffects} from './journal-records.mjs';
4
4
  import {projectInverseSource} from './inverse-source.mjs';
5
+ import {authoredBasis, authoredPlacement, authoredPlacements} from './authored-origin.mjs';
5
6
  import {sha256} from './hash.mjs';
6
7
  import {canonicalJSON} from './data.mjs';
7
8
 
@@ -74,6 +75,18 @@ export function transposeRecord(base, splices, id, records) {
74
75
  if (next === null) throw new RangeError('canonical_transport_invalid');
75
76
  current = next;
76
77
  }
78
+ // A concurrent act that already removed these same authored units and wrote the
79
+ // same replacement is one physical edit. This act stays in history with no second
80
+ // deletion. Pure insertions are never collapsed: each one is its own unit. An
81
+ // inverse of that act means the replacement is not still in force.
82
+ const sameRows = (left, right) => left.length === right.length && left.every((row, index) =>
83
+ row.pos === right[index].pos && row.removed === right[index].removed && row.inserted === right[index].inserted);
84
+ const matched = crossed.filter(record => sameRows(record.splices, splices));
85
+ const targetsOf = record => record.transaction.sourceTransactionIds || (record.transaction.sourceTransactionId ? [record.transaction.sourceTransactionId] : []);
86
+ const withdrawn = matched.some(record => crossed.some(other => targetsOf(other).includes(record.transaction.id) ||
87
+ other.transaction.reverts === record.transaction.id || other.transaction.reapplies === record.transaction.id));
88
+ if (splices.some(row => row.removed.length) && matched.length && !withdrawn)
89
+ return {splices: [], sourceIndices: [], against: crossed, text: current};
77
90
  const moved = [], sourceIndices = [];
78
91
  for (const [sourceIndex, row] of splices.entries()) {
79
92
  const submitted = _rapierTransformSplices(source, [row]);
@@ -120,49 +133,7 @@ export function transposeSource(base, splices, client, log) {
120
133
  return transposeRecord(base, splices, client, records);
121
134
  }
122
135
 
123
- // The frontier names only causal maxima. Linear history needs one predecessor,
124
- // and concurrent predecessors remain separate until an act observes both.
125
- function authoredFrontier(records, visit = null) {
126
- const dependencies = new Map(), frontier = new Set();
127
- for (const record of records) {
128
- const id = record.transaction.id, basis = record.authored?.basis;
129
- if (visit) visit(record, basis ?? [...frontier].sort());
130
- if (!basis) { dependencies.set(id, [...frontier]); frontier.clear(); }
131
- else {
132
- const pending = [...basis], seen = new Set();
133
- while (pending.length) {
134
- const prior = pending.pop();
135
- if (seen.has(prior)) continue;
136
- if (!dependencies.has(prior)) throw new RangeError('canonical_origin_invalid');
137
- seen.add(prior); frontier.delete(prior); pending.push(...dependencies.get(prior));
138
- }
139
- dependencies.set(id, basis);
140
- }
141
- frontier.add(id);
142
- }
143
- return [...frontier].sort();
144
- }
145
-
146
- export function authoredBasis(records) { return authoredFrontier(records); }
147
-
148
- export function authoredPlacements(base, records) {
149
- const origins = new Map(); let source = base;
150
- authoredFrontier(records, (record, basis) => {
151
- origins.set(record.transaction.id, record.authored ?? authoredPlacement(source, record.splices, basis));
152
- source = _rapierTransformSplices(source, record.splices);
153
- if (source === null) throw new RangeError('canonical_origin_invalid');
154
- });
155
- return origins;
156
- }
157
-
158
- // Immutable authored placement stays on its own act. Basis IDs refer only to this
159
- // canonical history; no source snapshot or duplicate history is stored.
160
- export function authoredPlacement(source, splices, basis) {
161
- const origin = {basis: basis.slice().sort(), source: sha256(source), splices: splices.map(row => ({...row}))};
162
- if (!_rapierValidAuthored(origin) || _rapierTransformSplices(source, splices) === null)
163
- throw new RangeError('canonical_origin_invalid');
164
- return origin;
165
- }
136
+ export {authoredBasis, authoredPlacement, authoredPlacements};
166
137
 
167
138
  // Reconstruct an authored view from the same records, then prove its exact physical
168
139
  // placement. Equality of resulting text alone cannot prove a repeated-text anchor.
@@ -233,6 +204,19 @@ function authoredReplay(base, records, append = null) {
233
204
  if (source === null) throw new RangeError('canonical_origin_invalid');
234
205
  }
235
206
  }
207
+ const coverKey = origin => origin?.splices.some(row => row.removed.length)
208
+ ? canonicalJSON({basis: origin.basis, splices: origin.splices}) : null;
209
+ const coverCounts = new Map();
210
+ for (const [id, origin] of origins) {
211
+ if (effects.references.get(id)?.length) continue;
212
+ const key = coverKey(origin);
213
+ if (key) coverCounts.set(key, (coverCounts.get(key) || 0) + 1);
214
+ }
215
+ // Two inverses can name different acts and still withdraw one physical replacement.
216
+ const sharesCover = id => (effects.references.get(id) || []).some(ref => {
217
+ const key = coverKey(origins.get(ref));
218
+ return !!key && coverCounts.get(key) > 1;
219
+ });
236
220
  const ordered = (ids, selected = null) => {
237
221
  const order = [], known = new Set(ids), waiting = new Map(), dependents = new Map(), ready = [];
238
222
  const rank = id => selected ? Number(selected.has(id)) : Number(origins.get(id).splices.some(row => row.removed.length));
@@ -351,10 +335,11 @@ function authoredReplay(base, records, append = null) {
351
335
  if (aligned.text !== text) throw new RangeError('canonical_origin_invalid');
352
336
  const crossed = aligned.records.slice(basisOrder.length);
353
337
  let moved;
354
- if (sharedInverses && effects.references.get(id)?.length) {
338
+ if (effects.references.get(id)?.length && (sharedInverses || sharesCover(id))) {
355
339
  // Each inverse retains its own targets. First prove the exact intention
356
340
  // in its causal source, then place that choice in the accepted effect
357
- // graph. Another inverse may already suppress the same original bytes.
341
+ // graph. Another inverse may already suppress the same original bytes,
342
+ // including when the two inverses name different authors of one replacement.
358
343
  const authored = projectInverse(basis.text, basis.records, id);
359
344
  if (!same(origin.splices, authored.splices) ||
360
345
  _rapierTransformSplices(basis.text, origin.splices) !== authored.after)
package/dist/page.mjs CHANGED
@@ -12,10 +12,13 @@ const USAGE = 'usage: rapier-html <document.md> [out.html] [--view draw|notes] [
12
12
  ' --drawing carries an SVG drawing alongside the document, opened on Draw as the page boots\n' +
13
13
  ' --compare carries the original text; the page opens showing its diff from the current document\n' +
14
14
  ' --by with --compare: the name shown on the diff with the time the page was made\n' +
15
- ' --return carries a one-use return URL from document.create_return; Share offers Send back\n' +
15
+ ' --return carries a one-use return URL from show {target:"return"}; Share offers Send back\n' +
16
16
  ' --return-expires-at carries the same mint\'s expiry; Share offers Save once the return expires\n' +
17
17
  ' -- everything after it is a filename, even one that starts with a dash\n' +
18
18
  ' --help this text';
19
+ // Damaged UTF-8 is refused, never carried as U+FFFD; a BOM is carried.
20
+ const text = async path => { try { return new TextDecoder('utf-8', {fatal: true, ignoreBOM: true}).decode(await readFile(path)); } catch (error) { throw error.code === 'ERR_ENCODING_INVALID_ENCODED_DATA' ? new Error(path + ' is not valid UTF-8') : error; } };
21
+
19
22
  export async function main(argv, pagesDir, {log = console.log} = {}) {
20
23
  const names = [];
21
24
  let drawingPath = null, basePath = null, by = null, view = null, address = null, expiry = null, onlyNames = false;
@@ -41,7 +44,7 @@ export async function main(argv, pagesDir, {log = console.log} = {}) {
41
44
  address = returnAddress(argv[++i]);
42
45
  }
43
46
  else if (arg === '--return-expires-at') {
44
- if (expiry !== null || !argv[i + 1] || argv[i + 1].startsWith('-')) throw new Error('--return-expires-at takes one ISO timestamp from document.create_return');
47
+ if (expiry !== null || !argv[i + 1] || argv[i + 1].startsWith('-')) throw new Error('--return-expires-at takes one ISO timestamp from show {target:"return"}');
45
48
  expiry = returnExpiresAt(argv[++i]);
46
49
  }
47
50
  else if (arg !== '--drawing') throw new Error('unknown option ' + arg + '\n' + USAGE);
@@ -50,22 +53,22 @@ export async function main(argv, pagesDir, {log = console.log} = {}) {
50
53
  }
51
54
  if (names.length < 1 || names.length > 2) throw new Error(USAGE);
52
55
  const [input, named] = names;
53
- const text = await readFile(input, 'utf8');
56
+ const source = await text(input);
54
57
  const page = await readFile(join(pagesDir, 'rapier.html'), 'utf8');
55
58
  const name = basename(input);
56
59
  const out = named || join(dirname(input), name.replace(/\.(md|markdown|txt)$/i, '') + '.rapier.html');
57
60
  const wrapOptions = {};
58
- if (drawingPath) { wrapOptions.drawing = await readFile(drawingPath, 'utf8'); wrapOptions.drawingName = basename(drawingPath); }
61
+ if (drawingPath) { wrapOptions.drawing = await text(drawingPath); wrapOptions.drawingName = basename(drawingPath); }
59
62
  if (by !== null && !basePath) throw new Error('--by labels a comparison: give its --compare file');
60
- if (basePath) { wrapOptions.base = await readFile(basePath, 'utf8'); wrapOptions.baseName = basename(basePath); wrapOptions.at = new Date().toISOString(); }
63
+ if (basePath) { wrapOptions.base = await text(basePath); wrapOptions.baseName = basename(basePath); wrapOptions.at = new Date().toISOString(); }
61
64
  if (by !== null) { wrapOptions.by = by; wrapOptions.at = new Date().toISOString(); }
62
65
  if (view) wrapOptions.view = view;
63
66
  if (address) wrapOptions.return = address;
64
67
  if (expiry) wrapOptions.return_expires_at = expiry;
65
68
  // Always a new file; 'wx' refuses any existing path, links included.
66
- try { await writeFile(out, wrap(page, text, name, wrapOptions), {flag: 'wx'}); }
69
+ try { await writeFile(out, wrap(page, source, name, wrapOptions), {flag: 'wx'}); }
67
70
  catch (error) { throw error.code === 'EEXIST' ? new Error(out + ' already exists, and rapier-html never overwrites a file: name a new one') : error; }
68
- log(out + ' (' + name + ', ' + Buffer.byteLength(text) + ' bytes of document' +
71
+ log(out + ' (' + name + ', ' + Buffer.byteLength(source) + ' bytes of document' +
69
72
  (view ? ', opens on ' + (view === 'draw' ? 'Draw' : 'Notes') : '') + (drawingPath ? ', ' + basename(drawingPath) + ' drawing' : '') + (basePath ? ', its diff from ' + basename(basePath) : '') + ')');
70
73
  return out;
71
74
  }