rapier-markdown-kit 1.2.3 → 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 (39) hide show
  1. package/LICENSE +3 -2
  2. package/README.md +16 -4
  3. package/dist/images/gif.mjs +3 -1
  4. package/dist/images/playback.mjs +6 -4
  5. package/dist/kit/conformance/run.mjs +1 -1
  6. package/dist/kit/ledger/authored-origin.mjs +49 -0
  7. package/dist/kit/ledger/authorship.mjs +18 -9
  8. package/dist/kit/ledger/data.mjs +7 -4
  9. package/dist/kit/ledger/format.mjs +25 -8
  10. package/dist/kit/ledger/history.mjs +31 -5
  11. package/dist/kit/ledger/inverse-source.mjs +111 -49
  12. package/dist/kit/ledger/journal-records.mjs +24 -29
  13. package/dist/kit/ledger/merge.mjs +48 -13
  14. package/dist/kit/ledger/text-merge.mjs +75 -29
  15. package/dist/kit/ledger/transport.mjs +30 -45
  16. package/dist/kit/pdf-vectors.mjs +2 -1
  17. package/dist/kit/print-capitals.mjs +0 -1
  18. package/dist/kit/rapier-ledger.mjs +2 -0
  19. package/dist/kit/render-markdown.mjs +10 -1
  20. package/dist/kit/render-print.mjs +43 -7
  21. package/dist/kit/render-sanitize.mjs +19 -12
  22. package/dist/kit/render-styles.mjs +3 -3
  23. package/dist/kit/render.mjs +5 -40
  24. package/dist/layout/paragraph-fit.mjs +1 -15
  25. package/dist/layout/typography.mjs +2 -1
  26. package/dist/packages/rapier-diff/concerns.mjs +163 -0
  27. package/dist/packages/rapier-diff/diff.mjs +68 -0
  28. package/dist/packages/rapier-diff/index.mjs +4 -744
  29. package/dist/packages/rapier-diff/matching.mjs +157 -0
  30. package/dist/packages/rapier-diff/merge.mjs +226 -0
  31. package/dist/packages/rapier-diff/moves.mjs +130 -0
  32. package/dist/packages/rapier-diff/structure.mjs +50 -0
  33. package/dist/packages/rapier-diff/tokens.mjs +72 -0
  34. package/dist/packages/rapier-diff/walk.mjs +219 -0
  35. package/dist/spec/html-blocks.mjs +3 -3
  36. package/dist/spec/md-layout.mjs +1 -1
  37. package/dist/spec/md-marks.mjs +1 -1
  38. package/dist/spec/md-source.mjs +15 -7
  39. package/package.json +1 -1
package/LICENSE CHANGED
@@ -35,8 +35,9 @@ own MIT notices. The edit-ledger module (`kit/rapier-ledger.mjs`, `kit/ledger/*.
35
35
  its editor compatibility import (`editor/journal-records.mjs`) are also covered. The
36
36
  record validator/replayer and text-only conflict merger were moved, not copied,
37
37
  into that module; the Notes index merger and the rest of the agent/editor remain
38
- AGPL-3.0-only. The wrapper's carrier-staging helper
39
- (`skills/rapier-html/package-files.mjs`) is MIT. The kit's MIT-marked facades,
38
+ AGPL-3.0-only. The page wrapper (`skills/rapier-html/wrap.mjs`, `skills/rapier-html/page.mjs`,
39
+ `skills/rapier-html/return-address.mjs`) and its carrier-staging helper
40
+ (`skills/rapier-html/package-files.mjs`) are MIT. The kit's MIT-marked facades,
40
41
  `kit/render-entry.mjs`, `kit/render*.mjs`, `kit/export-fonts.mjs`, `kit/print-capitals.mjs`,
41
42
  `kit/pdf-vectors.mjs`, `kit/pdf-glyphs.mjs`, `kit/stage.mjs` and MIT-marked conformance
42
43
  modules are covered too. These names describe the Rapier-owned files carrying the
package/README.md CHANGED
@@ -623,7 +623,9 @@ the authorship projection recomputed from the ledger must agree exactly.
623
623
  text. Runs are `[start, end, actorKind, actorId, at]`, in order, nonoverlapping and
624
624
  covering every current character. A start without earlier authorship is attributed
625
625
  to `system/unattributed`, time zero. Insertions take the inserting record's actor
626
- and time; deleted runs disappear. Thus the projection contains **no removed text**.
626
+ and time, except text an Undo or Redo puts back: a splice inserting exactly the text
627
+ that a splice of the referenced act removed keeps the runs that text had. Deleted
628
+ runs disappear. Thus the projection contains **no removed text**.
627
629
  An authorship-only import becomes a clearly incomplete insertion ledger; future
628
630
  attribution is again derived from that one ledger, never a mutable blame database.
629
631
 
@@ -669,9 +671,19 @@ editor's restore reader can replay the result. Both merge orders yield the same
669
671
  text for independently edited nonoverlapping ranges; their ordered ledgers need
670
672
  not be identical. Full and conflict receipts make repeated imports idempotent.
671
673
 
672
- Overlaps fall back to the existing text three-way merge. Unresolved alternatives
673
- are carried in the existing `note-conflict:v1` envelope, never silently selected.
674
- Different Will `keep` regions require conflict review even when one copy was
674
+ An identical replacement converges with both original acts retained and one physical
675
+ splice. The format identifier stays `rapier-ledger/2`. That unchanged identifier is not
676
+ backward compatibility with an older reader. There is no `rapier-ledger/3`, no
677
+ compatibility reader, and no migration. An older reader rejects the empty physical
678
+ witness with `ledger_invalid` and does not mutate the ledger. Mixed-version sync throws
679
+ `notes_history_unavailable` (`the note and its canonical history cannot be proved`).
680
+ The older participant's store snapshot and remote object count stay unchanged. That
681
+ throw is not a per-note `held` or `caughtUp` result. The same format string is not
682
+ interoperability, and the ledger is not rewritten into the older shape. Reactivating
683
+ any author of that replacement checks the shared text again; undoing that author
684
+ withdraws it once no author remains. A different overlap returns `text_overlap_unpreserved` and the caller's exact
685
+ ledger, including the text merge's alternatives for review, and does not mint a synthetic
686
+ act. Different Will `keep` regions require conflict review even when one copy was
675
687
  unchanged. `review.required` names an unresolved result; a UI must not auto-accept
676
688
  it. Rapier offers carried-copy imports from Compare through its existing review,
677
689
  checks the live document again at commit, and installs the proven ledger before
@@ -39,7 +39,9 @@ export function inspectGIF(bytes) {
39
39
  at += 9; if (packed & 128) at += 3 * (2 << (packed & 7));
40
40
  need(1); if (bytes[at] < 2 || bytes[at] > 8) fail('Invalid GIF code size');
41
41
  at++; blocks(); frames++;
42
- duration += control ? Math.max(20, (control[4] + control[5] * 256) * 10) : 100;
42
+ // A delay of 0 or 1 centisecond shows for 100 ms, as the browser draws it (images/playback.mjs times frames the same).
43
+ const delay = control ? control[4] + control[5] * 256 : 0;
44
+ duration += delay > 1 ? delay * 10 : 100;
43
45
  if (frames > 240 || width * height * frames > 96000000 || duration > 120000) fail('GIF animation exceeds the frame, work or duration budget');
44
46
  first ||= {control, image: bytes.subarray(start, at)}; control = null;
45
47
  }
@@ -31,8 +31,9 @@ export function createMediaPlayback(win, inspectGIF) {
31
31
  })();
32
32
  admitted.catch(error => {if (!closed) {state.status = 'admission-failed'; state.error = String(error.message).slice(0, 160); changed(state);}});
33
33
  // A decoder closes only after its in-flight decode settles; Chrome 155's JPEG XL decoder fails when closed mid-decode.
34
+ // Its budget place goes with it: a poster or a finished loop holds no decoder.
34
35
  let inflight = null;
35
- const closeDecoder = () => {ticket++; win.clearTimeout(timer); timer = 0; const old = decoder; decoder = null; if (old) inflight ? inflight.then(() => old.close()) : old.close();};
36
+ const closeDecoder = () => {ticket++; win.clearTimeout(timer); timer = 0; active.delete(canvas); const old = decoder; decoder = null; if (old) inflight ? inflight.then(() => old.close()) : old.close();};
36
37
  const settle = async promise => {
37
38
  let timeout;
38
39
  try {return await Promise.race([promise, new Promise((_, reject) => {timeout = win.setTimeout(() => reject(new Error('Animation decode timeout')), 5000);})]);}
@@ -72,7 +73,8 @@ export function createMediaPlayback(win, inspectGIF) {
72
73
  if (canvas.height !== h) canvas.height = h;
73
74
  const ctx = canvas.getContext('2d'); ctx.clearRect(0, 0, w, h); ctx.drawImage(frame, 0, 0);
74
75
  if (!state.first && next === 0) state.first = canvas.toDataURL('image/png');
75
- const delay = Math.max(20, (frame.duration || 100000) / 1000);
76
+ // A frame of 10 ms or less shows for 100 ms, as the browser draws an <img> (images/gif.mjs counts the same).
77
+ const delay = frame.duration > 10000 ? Math.max(20, frame.duration / 1000) : 100;
76
78
  duration += delay; if (duration > 120000) throw new Error('Animation exceeds the duration budget');
77
79
  state.status = 'ready'; state.frame = next; changed(state);
78
80
  if (single) {paused = true; closeDecoder(); return;}
@@ -93,8 +95,8 @@ export function createMediaPlayback(win, inspectGIF) {
93
95
  async first() {if (!state.first && !closed) {single = true; next = 0; paused = false; try {await step();} finally {single = false; paused = true; closeDecoder(); if (held) {held = false; this.suspend();}}} return state.first;},
94
96
  // Pause keeps the decoder and its frame position; only suspension and removal release it.
95
97
  pause() {paused = true; ticket++; win.clearTimeout(timer); timer = 0;},
96
- suspend() {if (single) {held = true; return;} paused = true; closeDecoder(); canvas.width = canvas.height = 0; active.delete(canvas); state.status = 'suspended'; changed(state);},
97
- close() {closed = true; paused = true; closeDecoder(); canvas.width = canvas.height = 0; active.delete(canvas);},
98
+ suspend() {if (single) {held = true; return;} paused = true; closeDecoder(); canvas.width = canvas.height = 0; state.status = 'suspended'; changed(state);},
99
+ close() {closed = true; paused = true; closeDecoder(); canvas.width = canvas.height = 0;},
98
100
  get paused() {return paused;}};
99
101
  }
100
102
  return {raster, inspectGIF, encode, source: '(' + createMediaPlayback.toString() + ')(window, ' + inspectGIF.toString() + ')'};
@@ -115,7 +115,7 @@ console.log(`A row's own hits/misses can undercount its real coverage: several f
115
115
  console.log(`segment cache (agent/vendor/pretext/measurement.js) serves a repeated (font, segment) query straight from that cache without calling`);
116
116
  console.log(`measureText again at all -- real data, just not counted against the SECOND document to ask for it. Two named, separate reasons a row still`);
117
117
  console.log(`reads CLOSE rather than AGREE, none of them "close enough": (a) no real trace exists at all for 01-inline/02-width-x-align/05-behind/06-front`);
118
- console.log(`-- their picture pushes no flow obstacle, so layout/interchange.js's own prepare() never calls Pretext for this paragraph during export (see`);
118
+ console.log(`-- their picture pushes no flow obstacle, so kit/render.mjs's exported projector's prepare() never calls Pretext for this paragraph during export (see`);
119
119
  console.log(`measurer.mjs) -- the from-scratch character-sum approximation disagrees with real shaping by a bounded, named amount; (b) 03/11/13 DO run`);
120
120
  console.log(`Pretext for real (hits > 0, 13-rtl-text at 100% -- every query answered from the real trace) yet still carry a small, constant left-edge delta`);
121
121
  console.log(`(9.64px today, a constant from before this measurer existed) -- a picture-obstacle geometry fact, not a text one: these`);
@@ -0,0 +1,49 @@
1
+ // SPDX-License-Identifier: MIT
2
+ // Immutable authored origins shared by causal transport and inverse projection.
3
+ import {_rapierTransformSplices, _rapierValidAuthored} from './journal-records.mjs';
4
+ import {sha256} from './hash.mjs';
5
+
6
+ // The frontier names only causal maxima. Linear history needs one predecessor,
7
+ // and concurrent predecessors remain separate until an act observes both.
8
+ function authoredFrontier(records, visit = null) {
9
+ const dependencies = new Map(), frontier = new Set();
10
+ for (const record of records) {
11
+ const id = record.transaction.id, basis = record.authored?.basis;
12
+ if (visit) visit(record, basis ?? [...frontier].sort());
13
+ if (!basis) { dependencies.set(id, [...frontier]); frontier.clear(); }
14
+ else {
15
+ const pending = [...basis], seen = new Set();
16
+ while (pending.length) {
17
+ const prior = pending.pop();
18
+ if (seen.has(prior)) continue;
19
+ if (!dependencies.has(prior)) throw new RangeError('canonical_origin_invalid');
20
+ seen.add(prior); frontier.delete(prior); pending.push(...dependencies.get(prior));
21
+ }
22
+ dependencies.set(id, basis);
23
+ }
24
+ frontier.add(id);
25
+ }
26
+ return [...frontier].sort();
27
+ }
28
+
29
+ export function authoredBasis(records) { return authoredFrontier(records); }
30
+
31
+ export function authoredPlacements(base, records) {
32
+ const origins = new Map(); let source = base;
33
+ authoredFrontier(records, (record, basis) => {
34
+ origins.set(record.transaction.id, record.authored ?? authoredPlacement(source, record.splices, basis));
35
+ source = _rapierTransformSplices(source, record.splices);
36
+ if (source === null) throw new RangeError('canonical_origin_invalid');
37
+ });
38
+ return origins;
39
+ }
40
+
41
+ // Immutable authored placement stays on its own act. Basis IDs refer only to this
42
+ // canonical history; no source snapshot or duplicate history is stored.
43
+ export function authoredPlacement(source, splices, basis) {
44
+ const origin = {basis: basis.slice().sort(), source: sha256(source), splices: splices.map(row => ({...row}))};
45
+ if (!_rapierValidAuthored(origin) || _rapierTransformSplices(source, splices) === null)
46
+ throw new RangeError('canonical_origin_invalid');
47
+ return origin;
48
+ }
49
+
@@ -1,6 +1,6 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  import {readLedger, exportLedger, textRoot, rootAfter} from './format.mjs';
3
- import {_rapierTransformSplices} from './journal-records.mjs';
3
+ import {_rapierTransformSplices, _rapierHistoryEffects} from './journal-records.mjs';
4
4
  import {sha256} from './hash.mjs';
5
5
  import {plainData, documentText, nonnegative, exactKeys, fault} from './data.mjs';
6
6
  function coalesce(runs) {
@@ -14,16 +14,25 @@ function coalesce(runs) {
14
14
  return out;
15
15
  }
16
16
  export function authorship(input) {
17
- const {ledger, text} = readLedger(input);
17
+ const {ledger, text} = readLedger(input), references = _rapierHistoryEffects(ledger.records).references, removed = new Map();
18
18
  let runs = ledger.start.text ? [[0, ledger.start.text.length, 'system', 'unattributed', 0]] : [];
19
- for (const record of ledger.records) for (const row of record.splices) {
20
- const cut = row.pos + row.removed.length, delta = row.inserted.length - row.removed.length, before = [], after = [];
21
- for (const run of runs) {
22
- if (run[0] < row.pos) before.push([run[0], Math.min(run[1], row.pos), ...run.slice(2)]);
23
- if (run[1] > cut) after.push([Math.max(run[0], cut) + delta, run[1] + delta, ...run.slice(2)]);
19
+ for (const record of ledger.records) {
20
+ // An Undo or Redo puts back text the act it references removed: that text keeps the runs it had.
21
+ const tx = record.transaction, gone = [], kept = (references?.get(tx.id) || []).flatMap(id => removed.get(id) || []);
22
+ for (const row of record.splices) {
23
+ const cut = row.pos + row.removed.length, delta = row.inserted.length - row.removed.length, before = [], after = [], inside = [];
24
+ for (const run of runs) {
25
+ if (run[0] < row.pos) before.push([run[0], Math.min(run[1], row.pos), ...run.slice(2)]);
26
+ if (run[1] > cut) after.push([Math.max(run[0], cut) + delta, run[1] + delta, ...run.slice(2)]);
27
+ if (run[0] < cut && run[1] > row.pos) inside.push([Math.max(run[0], row.pos) - row.pos, Math.min(run[1], cut) - row.pos, ...run.slice(2)]);
28
+ }
29
+ if (row.removed) gone.push({text: row.removed, runs: inside});
30
+ const at = row.inserted ? kept.findIndex(entry => entry.text === row.inserted) : -1;
31
+ const put = at < 0 ? [[row.pos, row.pos + row.inserted.length, tx.actor.kind, tx.actor.id, tx.createdAt]]
32
+ : kept.splice(at, 1)[0].runs.map(run => [run[0] + row.pos, run[1] + row.pos, ...run.slice(2)]);
33
+ runs = coalesce([...before, ...put, ...after]);
24
34
  }
25
- const tx = record.transaction;
26
- runs = coalesce([...before, [row.pos, row.pos + row.inserted.length, tx.actor.kind, tx.actor.id, tx.createdAt], ...after]);
35
+ removed.set(tx.id, gone);
27
36
  }
28
37
  return {format: 'rapier-authorship/1', sha256: sha256(text), runs};
29
38
  }
@@ -1,10 +1,13 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  // Only plain JSON data enters the public format. Accessors and toJSON are never executed.
3
3
  export function fault(message) { return Object.assign(new Error('Ledger: ' + message), {code: 'ledger_invalid'}); }
4
- function fields(value, depth, active, sorted = true) {
4
+ // A plain object's prototype is null or Object's own. Both lookups read descriptors, so no getter runs.
5
+ export const plainPrototype = proto => proto === null || Object.getPrototypeOf(proto) === null &&
6
+ Object.getOwnPropertyDescriptor(Object.getOwnPropertyDescriptor(proto, 'constructor')?.value || {}, 'name')?.value === 'Object';
7
+ export function fields(value, depth, active, sorted = true) {
5
8
  if (!value || typeof value !== 'object' || depth > 64 || active.has(value)) throw fault('not plain JSON data');
6
- const array = Array.isArray(value), proto = Object.getPrototypeOf(value);
7
- if (!array && proto !== null && (Object.getPrototypeOf(proto) !== null || Object.getOwnPropertyDescriptor(proto, 'constructor')?.value?.name !== 'Object')) throw fault('not a plain object');
9
+ const array = Array.isArray(value);
10
+ if (!array && !plainPrototype(Object.getPrototypeOf(value))) throw fault('not a plain object');
8
11
  const descriptors = Object.getOwnPropertyDescriptors(value), keys = Reflect.ownKeys(descriptors);
9
12
  for (const key of keys) {
10
13
  if (array && key === 'length') continue;
@@ -14,7 +17,7 @@ function fields(value, depth, active, sorted = true) {
14
17
  if (array && (keys.length !== value.length + 1 || keys.some(k => k !== 'length' && (!/^(0|[1-9]\d*)$/.test(k) || +k >= value.length)))) throw fault('sparse array');
15
18
  return {array, descriptors, keys: array ? keys.filter(key => key !== 'length') : sorted ? keys.sort() : keys};
16
19
  }
17
- function scalar(value) {
20
+ export function scalar(value) {
18
21
  if (value === null || typeof value === 'boolean') return true;
19
22
  if (typeof value === 'string') { if (/[\uD800-\uDFFF]/u.test(value)) throw fault('unpaired surrogate'); return true; }
20
23
  return typeof value === 'number' && Number.isFinite(value) && !Object.is(value, -0);
@@ -27,12 +27,34 @@ function* checkedRecords(ledger, prepared) {
27
27
  }
28
28
  if (ledger.head.revision !== revision || ledger.head.root !== root) throw fault('history does not describe this document');
29
29
  }
30
+ // A ledger object read again is proved once: a boot reads one stored history through several records. The proof holds only
31
+ // while that object still carries the exact proved data (its start text compared whole, the rest as canonical JSON), so a
32
+ // changed byte is proved again, and refused. The proof lives as long as the object does.
33
+ const proofs = new WeakMap();
30
34
  function readLedgerState(input, expectedText, expectedMetadata) {
31
35
  const ledger = plainData(input);
36
+ // The identifier rapier-ledger/2 is unchanged and is not backward compatible. This reader
37
+ // admits an empty physical splice whose authored rows name the same replacement. A reader
38
+ // from before that rule rejects the chain. Mixed-version sync throws notes_history_unavailable
39
+ // and leaves the older store unchanged; that refusal is not a held or caughtUp result.
32
40
  if (!exactKeys(ledger, ['format','documentAuthority','start','records','head','complete','sha256']) || ledger.format !== 'rapier-ledger/2' ||
33
41
  typeof ledger.documentAuthority !== 'string' || !ledger.documentAuthority || ledger.documentAuthority.length > 256 ||
34
42
  typeof ledger.complete !== 'boolean' || !Array.isArray(ledger.records) ||
35
43
  !exactKeys(ledger.start, ['text','revision','root','sha256','metadata']) || !exactKeys(ledger.head, ['revision','root','sha256','metadata'])) throw fault('unsupported or invalid envelope');
44
+ const shell = canonicalJSON({...ledger, start: {...ledger.start, text: ''}});
45
+ let proof = typeof input === 'object' ? proofs.get(input) : undefined;
46
+ if (!proof || proof.shell !== shell || proof.startText !== ledger.start.text) {
47
+ proof = {shell, startText: ledger.start.text, ...proveLedger(ledger)};
48
+ if (typeof input === 'object') proofs.set(input, proof);
49
+ }
50
+ proofs.set(ledger, proof);
51
+ const {text, metadata, root, revision, prepared, source} = proof;
52
+ if (expectedMetadata !== undefined && (!_rapierValidMetadata(expectedMetadata) || canonicalJSON(metadata) !== canonicalJSON(expectedMetadata)))
53
+ throw fault('metadata does not describe this document');
54
+ if (expectedText !== undefined && text !== expectedText) throw fault('history does not describe this document');
55
+ return {value: {ledger, text, metadata: {...metadata}, root, revision, complete: ledger.complete}, prepared, source};
56
+ }
57
+ function proveLedger(ledger) {
36
58
  const {sha256: claimed, ...payload} = ledger;
37
59
  if (claimed !== digest(payload)) throw fault('envelope checksum differs');
38
60
  let text = documentText(ledger.start.text), root = ledger.start.root, revision = ledger.start.revision;
@@ -55,12 +77,9 @@ function readLedgerState(input, expectedText, expectedMetadata) {
55
77
  try { authoredHistory(ledger.start.text, ledger.records); } catch (_) { throw fault('authored origin differs'); }
56
78
  }
57
79
  const metadata = prepared.replayMetadata(ledger.start.metadata);
58
- if (!metadata || canonicalJSON(metadata) !== canonicalJSON(ledger.head.metadata) ||
59
- (expectedMetadata !== undefined && (!_rapierValidMetadata(expectedMetadata) || canonicalJSON(metadata) !== canonicalJSON(expectedMetadata))))
60
- throw fault('metadata does not describe this document');
61
- if (ledger.head.revision !== revision || ledger.head.root !== root || ledger.head.sha256 !== sha256(text) ||
62
- (expectedText !== undefined && text !== expectedText)) throw fault('history does not describe this document');
63
- return {value: {ledger, text, metadata, root, revision, complete: ledger.complete}, prepared, source};
80
+ if (!metadata || canonicalJSON(metadata) !== canonicalJSON(ledger.head.metadata)) throw fault('metadata does not describe this document');
81
+ if (ledger.head.revision !== revision || ledger.head.root !== root || ledger.head.sha256 !== sha256(text)) throw fault('history does not describe this document');
82
+ return {text, metadata, root, revision, prepared, source};
64
83
  }
65
84
  // A stored copy may hold the head text alone. Its start is that text with every record reversed; readLedger proves it.
66
85
  export function ledgerWithStart(input, text) {
@@ -172,9 +191,7 @@ function finish(producer) {
172
191
  return step.value;
173
192
  }
174
193
  export function exportLedger(input) { return finish(produceLedger(input)).ledger; }
175
- // Both synchronous public export and cooperative recovery use the same producer.
176
194
  // Its returned facts were computed while admitting/hashing the exact head, never supplied.
177
- export function prepareLedger(input) { return finish(produceLedger(input)); }
178
195
  export async function prepareLedgerAsync(input, {yield: yieldWork, signal} = {}) {
179
196
  const producer = produceLedger(input);
180
197
  let step;
@@ -3,8 +3,8 @@
3
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);
@@ -104,8 +104,11 @@ function prepareHistory(input) {
104
104
  !(tx.createdAt === null || historyInteger(tx.createdAt)) ||
105
105
  (tx.turnId != null && (typeof tx.turnId !== 'string' || !tx.turnId || tx.turnId.length > 160))) return historyFailure('history_invalid');
106
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);
107
109
  if (!splices || effect === undefined || (!splices.length &&
108
- !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');
109
112
  const row = {record, splices, metadata: effect};
110
113
  rows.push(row); byId.set(tx.id, row); prior = tx.revision;
111
114
  }
@@ -293,10 +296,13 @@ export function historyPlaces(input, blocks) {
293
296
  if (!slices.has(key)) slices.set(key, source.slice(from, to));
294
297
  return slices.get(key);
295
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;
296
301
  for (const [node, afterRange] of touched) {
297
302
  const beforeRange = ranges.range(node), previous = {...beforeRange, source: cut(beforeSource, 'b', beforeRange.from, beforeRange.to)},
298
303
  after = {...afterRange, source: cut(afterSource, 'a', afterRange.from, afterRange.to)};
299
- if (previous.source !== after.source) node.block.acts.push({...historyAct(record), before: previous, after});
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});
300
306
  }
301
307
  }
302
308
 
@@ -334,7 +340,27 @@ export function selectiveUndo(input, actIds, options = {}) {
334
340
  const metadata = _rapierMetadataDelta(prepared.metadata, afterMetadata), effectChanged = selected.size > 0;
335
341
  const sourceEffectChanged = records.some(record => record.splices.length && !prepared.effects.references.get(record.transaction.id)?.length &&
336
342
  prepared.effects.active.has(record.transaction.id) !== projectedEffects.active.has(record.transaction.id));
337
- 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],
338
364
  sourceTransactionIds: [...selected], metadata, afterMetadata, effectChanged, unchanged: !metadata && !effectChanged,
339
365
  superseded: [], overlaps: []};
340
366
  try {
@@ -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) {