rapier-markdown-kit 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.
Files changed (60) hide show
  1. package/LICENSE +8 -4
  2. package/README.md +50 -7
  3. package/dist/images/gif.mjs +49 -0
  4. package/dist/images/playback.mjs +103 -0
  5. package/dist/kit/conformance/03-wrap-around-silhouette.md +1 -1
  6. package/dist/kit/conformance/04-wrap-box.md +1 -1
  7. package/dist/kit/conformance/05-behind.md +1 -1
  8. package/dist/kit/conformance/06-front.md +1 -1
  9. package/dist/kit/conformance/07-rotate-raster.md +1 -1
  10. package/dist/kit/conformance/08-drawing-rotation.md +1 -1
  11. package/dist/kit/conformance/09-drawing-ring-interior.expected.json +18 -18
  12. package/dist/kit/conformance/09-drawing-ring-interior.md +1 -1
  13. package/dist/kit/conformance/10-both-sides.md +1 -1
  14. package/dist/kit/conformance/11-neighbour-skips-image.md +1 -1
  15. package/dist/kit/conformance/{12-heading-barrier.expected.json → 12-rule-barrier.expected.json} +1 -1
  16. package/dist/kit/conformance/{12-heading-barrier.md → 12-rule-barrier.md} +2 -2
  17. package/dist/kit/conformance/README.md +19 -28
  18. package/dist/kit/conformance/run.mjs +10 -8
  19. package/dist/kit/ledger/authored-origin.mjs +49 -0
  20. package/dist/kit/ledger/authorship.mjs +18 -9
  21. package/dist/kit/ledger/carried.mjs +10 -5
  22. package/dist/kit/ledger/data.mjs +7 -4
  23. package/dist/kit/ledger/format.mjs +37 -11
  24. package/dist/kit/ledger/hash.mjs +31 -15
  25. package/dist/kit/ledger/history.mjs +48 -10
  26. package/dist/kit/ledger/inverse-source.mjs +111 -49
  27. package/dist/kit/ledger/journal-records.mjs +24 -29
  28. package/dist/kit/ledger/merge.mjs +51 -15
  29. package/dist/kit/ledger/text-merge.mjs +75 -29
  30. package/dist/kit/ledger/transport.mjs +30 -45
  31. package/dist/kit/pdf-vectors.mjs +6 -2
  32. package/dist/kit/print-capitals.mjs +0 -1
  33. package/dist/kit/rapier-ledger.mjs +4 -2
  34. package/dist/kit/render-ink.mjs +1 -1
  35. package/dist/kit/render-markdown.mjs +155 -31
  36. package/dist/kit/render-print.mjs +173 -61
  37. package/dist/kit/render-sanitize.mjs +25 -15
  38. package/dist/kit/render-styles.mjs +3 -3
  39. package/dist/kit/render.mjs +37 -115
  40. package/dist/layout/model.mjs +10 -8
  41. package/dist/layout/paragraph-fit.mjs +1 -15
  42. package/dist/layout/typography.mjs +2 -1
  43. package/dist/packages/rapier-diff/concerns.mjs +163 -0
  44. package/dist/packages/rapier-diff/diff.mjs +68 -0
  45. package/dist/packages/rapier-diff/index.mjs +4 -701
  46. package/dist/packages/rapier-diff/matching.mjs +157 -0
  47. package/dist/packages/rapier-diff/merge.mjs +226 -0
  48. package/dist/packages/rapier-diff/moves.mjs +130 -0
  49. package/dist/packages/rapier-diff/structure.mjs +50 -0
  50. package/dist/packages/rapier-diff/tokens.mjs +72 -0
  51. package/dist/packages/rapier-diff/walk.mjs +219 -0
  52. package/dist/spec/document-fonts.mjs +1 -1
  53. package/dist/spec/document-settings.mjs +11 -164
  54. package/dist/spec/html-blocks.mjs +618 -79
  55. package/dist/spec/md-assets.mjs +29 -6
  56. package/dist/spec/md-layout.mjs +9 -1
  57. package/dist/spec/md-marks.mjs +25 -147
  58. package/dist/spec/md-source.mjs +69 -26
  59. package/package.json +1 -1
  60. package/style.css +7 -51
@@ -2,6 +2,8 @@
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 {authoredPlacements} from './authored-origin.mjs';
6
+ import {canonicalJSON} from './data.mjs';
5
7
  import {diff} from '../../packages/rapier-diff/index.mjs';
6
8
 
7
9
  const historyFailure = reason => ({ok: false, reason});
@@ -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;
@@ -3,7 +3,7 @@ import {readLedger, exportLedger, rootAfter} from './format.mjs';
3
3
  import {_rapierTransformSplices, _rapierTransformMetadata, _rapierMetadataDelta, _rapierHistoryEffects, _rapierMetadataState} from './journal-records.mjs';
4
4
  import {mergeText} from './text-merge.mjs';
5
5
  import {transposeAuthored, authoredPlacements} from './transport.mjs';
6
- import {parseWill} from '../../agent/will.mjs';
6
+ import {parseWill, stripOneTerminator} from '../../agent/will.mjs';
7
7
  import {sha256} from './hash.mjs';
8
8
  import {canonicalJSON, fault} from './data.mjs';
9
9
  // The same interval transport used by the agent kernel, now shared with the public ledger.
@@ -46,6 +46,16 @@ function actFingerprint(record, effects) {
46
46
  metadata: references.length ? null : Object.fromEntries(Object.entries(record.metadata || {}).map(([key, pair]) => [key, pair.after]))}));
47
47
  }
48
48
  const mergeIdentity = (localDigest, incomingDigest) => 'merge:' + sha256(canonicalJSON([localDigest, incomingDigest]));
49
+ // One physical replacement can carry two authored acts when both removed the same
50
+ // units and wrote the same text. An empty list is the act that does not repeat it.
51
+ function coincidentEffect(origin, left, right) {
52
+ const authored = origin?.splices;
53
+ if (!authored?.some(row => row.removed.length)) return false;
54
+ const same = (rows, expected) => rows.length === expected.length && rows.every((row, index) =>
55
+ row.pos === expected[index].pos && row.removed === expected[index].removed && row.inserted === expected[index].inserted);
56
+ const lists = [left, right];
57
+ return lists.every(rows => !rows.length || same(rows, authored)) && lists.some(rows => same(rows, authored));
58
+ }
49
59
  function checkpointList(read) {
50
60
  const list = [{root: read.ledger.start.root, text: read.ledger.start.text, metadata: read.ledger.start.metadata,
51
61
  revision: read.ledger.start.revision, identity: null, index: 0}], records = read.ledger.records;
@@ -58,10 +68,18 @@ function checkpointList(read) {
58
68
  }
59
69
  return list;
60
70
  }
71
+ // Keep and append regions with their intents, and the kept text: what the other copy may not change unseen.
61
72
  function kept(text) {
62
73
  const will = parseWill(text);
63
74
  if (will.fault) return {fault: will.fault, text};
64
- return will.regions.filter(region => region.law === 'keep').map(region => text.slice(region.start, region.end));
75
+ return will.regions.filter(region => region.law !== 'edit').map(({law, intent, start, end}) =>
76
+ law === 'keep' ? {law, intent, text: text.slice(start, end)} : {law, intent});
77
+ }
78
+ // Every append region of `from` still begins its counterpart in `to`.
79
+ function appended(from, to) {
80
+ const regions = text => parseWill(text).regions.filter(region => region.law === 'append');
81
+ const was = regions(from), now = regions(to), core = (text, region) => stripOneTerminator(text.slice(region.start, region.end));
82
+ return was.length === now.length && was.every((region, index) => core(to, now[index]).startsWith(core(from, region)));
65
83
  }
66
84
  // A retained suffix can reuse a prefix only when an existing ledger proves its
67
85
  // exact source, metadata, revision and path root. Export then rechecks the whole
@@ -104,7 +122,11 @@ export function merge(first, second) {
104
122
  conflicts: [{kind: 'identity', code: 'act_order_conflict', actId: record.transaction.id}],
105
123
  review: {required: true, base: ours.text, proposed: theirs.text}};
106
124
  if (local && (canonicalJSON(local.splices) !== canonicalJSON(record.splices) ||
107
- canonicalJSON(local.metadata || {}) !== canonicalJSON(record.metadata || {}))) unproved.add(record.transaction.id);
125
+ canonicalJSON(local.metadata || {}) !== canonicalJSON(record.metadata || {}))) {
126
+ const metadataSame = canonicalJSON(local.metadata || {}) === canonicalJSON(record.metadata || {});
127
+ if (!(metadataSame && coincidentEffect(localOrigins.get(local.transaction.id), local.splices, record.splices)))
128
+ unproved.add(record.transaction.id);
129
+ }
108
130
  }
109
131
  const a = checkpointList(ours), b = checkpointList(theirs);
110
132
  // A synthetic conflict act has one identity derived from both original envelopes.
@@ -190,7 +212,10 @@ export function merge(first, second) {
190
212
  const local = localIds.get(record.transaction.id);
191
213
  if (local) mappedIds.set(record.transaction.id, local.transaction.id);
192
214
  }
193
- const keepDiffers = canonicalJSON(kept(ours.text)) !== canonicalJSON(kept(theirs.text));
215
+ // Kept text arriving from the other copy waits for the person; this copy's own kept edits merge like any other.
216
+ const incomingKept = canonicalJSON(kept(theirs.text));
217
+ const keepDiffers = incomingKept !== canonicalJSON(kept(ours.text)) && incomingKept !== canonicalJSON(kept(common.text)) ||
218
+ !appended(common.text, theirs.text) && !appended(ours.text, theirs.text);
194
219
  let conflicted = keepDiffers && unseen.length > 0, incomingSource = common.text;
195
220
  if (!conflicted) for (const record of incoming) {
196
221
  const beforeSource = incomingSource;
@@ -237,20 +262,31 @@ export function merge(first, second) {
237
262
  return settled(theirs);
238
263
  }
239
264
  }
240
- let conflicts = [];
241
265
  if (conflicted) {
242
266
  if (unseen.length !== incoming.length) return refused('history', 'record_transport_conflict');
243
- if (incomingFields.size || unseen.some(record => incomingEffects.references.get(record.transaction.id)?.length))
244
- return metadataConflict('record_transport_conflict', {fields: [...incomingFields]});
245
- // Restart from the original local head: partially transported records never leak into a conflict result.
246
- records.splice(ours.ledger.records.length); text = ours.text; metadata = {...ours.metadata}; root = ours.root;
267
+ if (incomingFields.size) return metadataConflict('record_transport_conflict', {fields: [...incomingFields]});
268
+ // A display of both alternatives is not an accepted history. The caller's envelope
269
+ // stays whole; neither original act is replaced by a synthetic copy.
247
270
  const merged = mergeText(keepDiffers ? null : common.text, ours.text, theirs.text, {oursId: 'copy-' + ours.ledger.sha256.slice(0, 16), theirsId: 'copy-' + theirs.ledger.sha256.slice(0, 16)});
248
- conflicts = merged.conflicts;
249
- // Text equality cannot replace the unproved original acts with a clean receipt.
250
- if (!conflicts.length) return refused('history', 'record_transport_conflict');
251
- if (merged.text !== text) append([{pos: 0, removed: text, inserted: merged.text}], {transaction: {
252
- id: mergeIdentity(ours.ledger.sha256, theirs.ledger.sha256), actor: {kind: 'system', id: 'merge'}, transport: 'platform', operation: 'document.merge', requestId: null,
253
- affectedBlockIds: [], createdAt: null}});
271
+ if (!merged.conflicts.length) return refused('history', 'record_transport_conflict');
272
+ return {text: ours.text, metadata: ours.metadata, ledger: ours.ledger, clean: false,
273
+ conflicts: merged.conflicts.map(row => ({...row, code: 'text_overlap_unpreserved'})),
274
+ review: {required: true, base: ours.text, proposed: theirs.text}};
275
+ }
276
+ let conflicts = [];
277
+ // The act that keeps the physical splice needs the same authored origin as its
278
+ // witness. Copy it onto this result only; the caller's record stays untouched.
279
+ if (records.some(row => !row.splices.length && row.authored?.splices?.some(splice => splice.removed.length))) {
280
+ const origins = authoredPlacements(ours.ledger.start.text, records);
281
+ for (let index = 0; index < records.length; index++) {
282
+ const record = records[index];
283
+ if (record.authored) continue;
284
+ const origin = origins.get(record.transaction.id);
285
+ if (!origin?.splices.some(splice => splice.removed.length)) continue;
286
+ const key = canonicalJSON(origin);
287
+ if (records.some(other => other.transaction.id !== record.transaction.id && canonicalJSON(origins.get(other.transaction.id)) === key))
288
+ records[index] = {...record, authored: origin};
289
+ }
254
290
  }
255
291
  const ledger = exportLedger({text, metadata, records, documentAuthority: ours.ledger.documentAuthority, root,
256
292
  revision: records.at(-1)?.transaction.revision ?? ours.revision, complete: ours.complete});
@@ -151,39 +151,81 @@ function textUnits(text) {
151
151
  return out;
152
152
  }
153
153
 
154
- function textEdits(base, side) {
155
- const n = base.length, m = side.length;
156
- if ((n + 1) * (m + 1) > 1000000) return null;
157
- const width = m + 1, score = new Uint32Array((n + 1) * width);
154
+ const TEXT_WINDOW_CELLS = 1000000, TEXT_CELLS = 4000000;
155
+ // One window's authored matches, or false when two optimal alignments disagree on any of them.
156
+ function textWindowMatches(base, side, b0, b1, s0, s1, out, budget) {
157
+ const n = b1 - b0, m = s1 - s0, cells = (n + 1) * (m + 1);
158
+ if (cells > TEXT_WINDOW_CELLS || (budget.cells -= cells) < 0) return false;
159
+ // Only authored units score; a blank line matches only beside a matched neighbour.
160
+ const width = m + 1, score = new Uint32Array(cells), content = i => !textSpace(base[b0 + i]), same = (i, j) => base[b0 + i] === side[s0 + j];
158
161
  for (let i = n - 1; i >= 0; i--) for (let j = m - 1; j >= 0; j--) {
159
162
  const p = i * width + j;
160
- score[p] = base[i] === side[j] ? score[p + width + 1] + 1 : Math.max(score[p + width], score[p + 1]);
163
+ score[p] = content(i) && same(i, j) ? score[p + width + 1] + 1 : Math.max(score[p + width], score[p + 1]);
164
+ }
165
+ const meaningful = [];
166
+ for (let i = 0, j = 0; i < n && j < m;) {
167
+ const p = i * width + j; if (!score[p]) break;
168
+ if (content(i) && same(i, j) && score[p] === score[p + width + 1] + 1) meaningful.push([i++, j++]);
169
+ else if (score[p + width] >= score[p + 1]) i++; else j++;
161
170
  }
162
- const walk = () => {
163
- const pairs = []; let i = 0, j = 0;
164
- while (i < n && j < m) {
165
- const value = score[i * width + j]; if (!value) break;
166
- if (base[i] === side[j]) { pairs.push([i++, j++]); continue; }
167
- if (score[(i + 1) * width + j] >= score[i * width + j + 1]) i++; else j++;
168
- }
169
- return pairs;
170
- };
171
- const pairs = walk(), meaningful = pairs.filter(([i]) => !textSpace(base[i]));
172
171
  // Every optimal path must agree on every authored anchor, not just two greedy walks.
173
- const seen = new Uint32Array(score.length); seen.fill(0xffffffff); seen[0] = 0;
172
+ const seen = new Uint32Array(cells); seen.fill(0xffffffff); seen[0] = 0;
174
173
  for (let i = 0; i <= n; i++) for (let j = 0; j <= m; j++) {
175
174
  const p = i * width + j, at = seen[p]; if (at === 0xffffffff) continue;
176
175
  const visit = (next, count) => { if (seen[next] !== 0xffffffff && seen[next] !== count) return false; seen[next] = count; return true; };
177
- if (i < n && score[p + width] === score[p] && !visit(p + width, at)) return null;
178
- if (j < m && score[p + 1] === score[p] && !visit(p + 1, at)) return null;
179
- if (i < n && j < m && base[i] === side[j] && score[p] === score[p + width + 1] + 1) {
180
- const content = !textSpace(base[i]);
181
- if (content && (meaningful[at]?.[0] !== i || meaningful[at]?.[1] !== j)) return null;
182
- if (!visit(p + width + 1, at + (content ? 1 : 0))) return null;
176
+ if (i < n && score[p + width] === score[p] && !visit(p + width, at)) return false;
177
+ if (j < m && score[p + 1] === score[p] && !visit(p + 1, at)) return false;
178
+ if (i < n && j < m && content(i) && same(i, j) && score[p] === score[p + width + 1] + 1) {
179
+ if (meaningful[at]?.[0] !== i || meaningful[at]?.[1] !== j) return false;
180
+ if (!visit(p + width + 1, at + 1)) return false;
183
181
  }
184
182
  }
185
- if (seen[n * width + m] !== meaningful.length) return null;
186
- const edits = []; let i = 0, j = 0;
183
+ if (seen[n * width + m] !== meaningful.length) return false;
184
+ for (const [i, j] of meaningful) out.push([b0 + i, s0 + j]);
185
+ return true;
186
+ }
187
+
188
+ // A window too large to prove whole splits only at authored units found once on each side of it:
189
+ // such a unit names its own occurrence. The strict rule above still decides every window between them.
190
+ function textMatches(base, side, b0, b1, s0, s1, out, budget) {
191
+ if ((b1 - b0 + 1) * (s1 - s0 + 1) <= TEXT_WINDOW_CELLS) return textWindowMatches(base, side, b0, b1, s0, s1, out, budget);
192
+ const once = (units, from, to) => {
193
+ const found = new Map();
194
+ for (let k = from; k < to; k++) if (!textSpace(units[k])) found.set(units[k], found.has(units[k]) ? -1 : k);
195
+ return found;
196
+ };
197
+ const inBase = once(base, b0, b1), pairs = [];
198
+ for (const [unit, j] of once(side, s0, s1)) { const i = inBase.get(unit); if (j >= 0 && i >= 0) pairs.push([i, j]); }
199
+ // The longest run of them in the same order on both sides.
200
+ const tails = [], prev = new Int32Array(pairs.length);
201
+ for (let k = 0; k < pairs.length; k++) {
202
+ let lo = 0, hi = tails.length;
203
+ while (lo < hi) { const mid = (lo + hi) >>> 1; if (pairs[tails[mid]][0] < pairs[k][0]) lo = mid + 1; else hi = mid; }
204
+ prev[k] = lo ? tails[lo - 1] : -1; tails[lo] = k;
205
+ }
206
+ if (!tails.length) return false;
207
+ const chain = [];
208
+ for (let k = tails[tails.length - 1]; k >= 0; k = prev[k]) chain.push(pairs[k]);
209
+ let i = b0, j = s0;
210
+ for (const [bi, sj] of chain.reverse()) {
211
+ if (!textMatches(base, side, i, bi, j, sj, out, budget)) return false;
212
+ out.push([bi, sj]); i = bi + 1; j = sj + 1;
213
+ }
214
+ return textMatches(base, side, i, b1, j, s1, out, budget);
215
+ }
216
+
217
+ function textEdits(base, side) {
218
+ const n = base.length, m = side.length, meaningful = [];
219
+ if (!textMatches(base, side, 0, n, 0, m, meaningful, {cells: TEXT_CELLS})) return null;
220
+ const pairs = [];
221
+ let i = 0, j = 0;
222
+ for (let k = 0; k < meaningful.length; k++) {
223
+ const [bi, sj] = meaningful[k], [ni, nj] = meaningful[k + 1] || [n, m];
224
+ if (bi > i && sj > j && textSpace(base[bi - 1]) && base[bi - 1] === side[sj - 1]) pairs.push([bi - 1, sj - 1]);
225
+ pairs.push([bi, sj]); i = bi + 1; j = sj + 1;
226
+ if (i < ni && j < nj && textSpace(base[i]) && base[i] === side[j]) pairs.push([i++, j++]);
227
+ }
228
+ const edits = []; i = 0; j = 0;
187
229
  for (const [bi, sj] of [...pairs, [n, m]]) {
188
230
  if (i !== bi || j !== sj) edits.push({start: i, end: bi, text: side.slice(j, sj).join('')});
189
231
  i = bi + 1; j = sj + 1;
@@ -234,11 +276,13 @@ function textEnvelope(id, variants) {
234
276
  return out + '<!-- /note-conflict:v1 -->\n';
235
277
  }
236
278
 
237
- function textDeclarations(text) {
238
- text = textUnits(text).filter(unit => !textEnvelopeAt(unit)).join('');
279
+ // Takes the text's units, scanned once by the caller; a text without conflict envelopes is not scanned again.
280
+ function textDeclarations(units) {
281
+ const kept = units.filter(unit => !textEnvelopeAt(unit)).map(unit =>
282
+ textFence(unit.split(/\r\n|\r|\n/, 1)[0].replace(/^\ufeff/, '')) ? '\n' : unit), text = kept.join('');
239
283
  // A definition can move a destination/title onto the next unindented line, or span its label.
240
284
  const definitions = text.match(/^\ufeff? {0,3}\[(?:\\.|[^\]\\])+\]:[^\r\n]*(?:(?:\r\n|\r|\n)(?![ ]*(?:\r\n|\r|\n|$))[^\r\n]*)*/gm) || [];
241
- const continuations = textUnits(text).filter(unit => /^\ufeff? {0,3}\[\^/.test(unit));
285
+ const continuations = (kept.length === units.length ? kept : textUnits(text)).filter(unit => /^\ufeff? {0,3}\[\^/.test(unit));
242
286
  return [...definitions, ...continuations, ...(text.match(/<!--[\s\S]*?(?:-->|$)/g) || [])].join('\0');
243
287
  }
244
288
 
@@ -284,10 +328,12 @@ export function mergeText(base, ours, theirs, options = {}) {
284
328
  conflicts.push({kind, base: before, variants, start, end: start + block.length, block});
285
329
  text += block;
286
330
  };
331
+ const [units, oursUnits, theirsUnits] = base === null ? [] : [base, ours, theirs].map(textUnits);
332
+ const declared = base !== null && textDeclarations(units);
287
333
  if (base === null) conflict(null, ours, theirs, 'no-ancestor');
288
- else if (textDeclarations(ours) !== textDeclarations(base) || textDeclarations(theirs) !== textDeclarations(base)) conflict(base, ours, theirs, 'document-bindings');
334
+ else if (textDeclarations(oursUnits) !== declared || textDeclarations(theirsUnits) !== declared) conflict(base, ours, theirs, 'document-bindings');
289
335
  else {
290
- const units = textUnits(base), a = textEdits(units, textUnits(ours)), b = textEdits(units, textUnits(theirs));
336
+ const a = textEdits(units, oursUnits), b = textEdits(units, theirsUnits);
291
337
  if (!a || !b) conflict(base, ours, theirs, 'ambiguous-alignment-or-budget');
292
338
  else {
293
339
  const changes = [...a.map(e => ({...e, side: 0})), ...b.map(e => ({...e, side: 1}))].sort((x, y) => x.start - y.start || x.end - y.end || x.side - y.side);