rapier-html 1.2.8 → 1.2.9

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,17 +1,13 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  import {words, letter, within, prev, next} from './tokens.mjs';
3
3
 
4
- // Exact source records for a range: lines, then words, then characters, or structural parts when supplied.
5
4
  export function walker(c) {
6
5
  const {before, after, spend, safe, budget, raw, chars, code, segmenter, tokens, matches} = c;
7
6
  function record(a0, a1, b0, b1, unit, part, coarse = false) {
8
7
  if (a1 - a0 === b1 - b0 && before.slice(a0, a1) === after.slice(b0, b1)) return;
9
- // Internal records: before, after, coarse, concern, transported fragments.
10
8
  c.result.push([[a0, a1], [b0, b1], coarse, [a0 === a1 ? 2 : b0 === b1 ? 0 : 1, part?.kind || unit, part?.name]]);
11
9
  }
12
10
 
13
- // A line deletion or insertion holding whole named parts and only blank text besides is one record per part,
14
- // cut at line starts, so each keeps its own name (and its callers' breaks).
15
11
  function name(from, ownersA, ownersB) {
16
12
  for (let r = from; r < c.result.length; r++) {
17
13
  const [[a0, a1], [b0, b1]] = c.result[r], side = a0 === a1 ? 1 : b0 === b1 ? 0 : -1;
@@ -34,7 +30,6 @@ export function walker(c) {
34
30
  }
35
31
  }
36
32
 
37
- // A search step scans at most one fixed-size source chunk.
38
33
  function common(a, b, end, step) {
39
34
  let n = 0;
40
35
  while (n < end && spend()) {
@@ -95,7 +90,6 @@ export function walker(c) {
95
90
  }
96
91
  } else if (level < 2 && raw !== 'line') lines(x.slice(ai, i + 1), y.slice(bi, j + 1), context);
97
92
  else if (level === 2 && chars && !raw && i - ai <= 1 && j - bi <= 1 && x1 - x0 + y1 - y0 < 100) {
98
- // A changed token is replaced whole unless the caller asks for the characters inside it.
99
93
  walk(x0, x1, y0, y1, 3, null, null, context);
100
94
  } else record(x0, x1, y0, y1, unit, context);
101
95
  }
@@ -108,12 +102,9 @@ export function walker(c) {
108
102
  }
109
103
  }
110
104
 
111
- // Unequal lines pair in order by shared words. A line sharing under a quarter of its words with every candidate is
112
- // replaced whole: an inserted line never borrows a neighbor's words, and a rewrite never reads as stray fragments.
113
105
  function lines(x, y, context) {
114
106
  const n = x.length - 1, m = y.length - 1;
115
107
  if (n * m > 4096 || !spend(n * m + x[n] - x[0] + y[m] - y[0])) return row(x[0], x[n], y[0], y[m], context);
116
- // A renamed name's sites count as shared words.
117
108
  if (!c.renames) {
118
109
  c.renames = [[], []];
119
110
  for (const [p, old] of c.aliases || []) if (before.slice(...old.nameAt) !== after.slice(...p.nameAt)) {
@@ -141,7 +132,6 @@ export function walker(c) {
141
132
  }
142
133
  let k = 0, l = 0;
143
134
  for (const [p, q] of [...pairs, [n, m]]) {
144
- // A merge compares one line against one line by character, whatever they share.
145
135
  if (raw === 'char' && p - k === 1 && q - l === 1) row(x[k], x[p], y[l], y[q], context);
146
136
  else if (k < p || l < q) record(x[k], x[p], y[l], y[q], 'line', context);
147
137
  if (p < n) row(x[p], x[p + 1], y[q], y[q + 1], context);
@@ -149,7 +139,6 @@ export function walker(c) {
149
139
  }
150
140
  }
151
141
 
152
- // A table row with the same number of cells on both sides changes cell by cell; a character diff stays in its line.
153
142
  function row(a0, a1, b0, b1, context) {
154
143
  if (raw === 'char') return walk(a0, a1, b0, b1, 3, null, null, context);
155
144
  const cells = (text, a, b) => /^[ \t]*\|/.test(text.slice(a, b)) && [...text.slice(a, b).matchAll(/(?<!\\)\|/g)].map(m => a + m.index);
@@ -158,7 +147,6 @@ export function walker(c) {
158
147
  for (let k = 0; k <= p.length; k++) walk(k ? p[k - 1] + 1 : a0, p[k] ?? a1, k ? q[k - 1] + 1 : b0, q[k] ?? b1, 2, null, null, context);
159
148
  }
160
149
 
161
- // Surviving source pieces keep the occurrence witnessed by the host.
162
150
  function operations(rows) {
163
151
  if (!Array.isArray(rows)) return null;
164
152
  const pieces = [[0, before.length]];
@@ -8,8 +8,6 @@ import {canonicalJSON, canonicalParts, plainData, documentText, utf8Length, exac
8
8
  export {textRoot, rootAfter};
9
9
  const digest = value => sha256Parts(canonicalParts(value));
10
10
  const rootShape = value => typeof value === 'string' && /^\d+:[a-z0-9]+:[a-z0-9]+$/.test(value);
11
- // Structural/history admission is shared by the reader and the producer. The producer
12
- // proves source replay by reversing exact admitted splices; it need not replay its output.
13
11
  function* checkedRecords(ledger, prepared) {
14
12
  if (!prepared.ok || ledger.records.length !== prepared.records.length) throw fault('record chain differs');
15
13
  let root = ledger.start.root, revision = ledger.start.revision;
@@ -27,16 +25,9 @@ function* checkedRecords(ledger, prepared) {
27
25
  }
28
26
  if (ledger.head.revision !== revision || ledger.head.root !== root) throw fault('history does not describe this document');
29
27
  }
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
28
  const proofs = new WeakMap();
34
29
  function readLedgerState(input, expectedText, expectedMetadata) {
35
30
  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.
40
31
  if (!exactKeys(ledger, ['format','documentAuthority','start','records','head','complete','sha256']) || ledger.format !== 'rapier-ledger/2' ||
41
32
  typeof ledger.documentAuthority !== 'string' || !ledger.documentAuthority || ledger.documentAuthority.length > 256 ||
42
33
  typeof ledger.complete !== 'boolean' || !Array.isArray(ledger.records) ||
@@ -81,7 +72,6 @@ function proveLedger(ledger) {
81
72
  if (ledger.head.revision !== revision || ledger.head.root !== root || ledger.head.sha256 !== sha256(text)) throw fault('history does not describe this document');
82
73
  return {text, metadata, root, revision, prepared, source};
83
74
  }
84
- // A stored copy may hold the head text alone. Its start is that text with every record reversed; readLedger proves it.
85
75
  export function ledgerWithStart(input, text) {
86
76
  let source = new HistorySource(documentText(text));
87
77
  for (let i = input.records.length - 1; i >= 0; i--) {
@@ -129,11 +119,8 @@ function* sourceFacts(text) {
129
119
  }
130
120
  function* produceLedger({text, metadata, records, documentAuthority, revision, root, complete = false, earlier = records} = {}) {
131
121
  if (!_rapierValidMetadata(metadata)) throw fault('document metadata required');
132
- // Metadata has two admitted scalar fields. Keep its insertion order in the
133
- // emitted envelope while capturing it before the first cooperative boundary.
134
122
  metadata = {...metadata};
135
123
  if (!Array.isArray(records) || !Array.isArray(earlier)) throw fault('records required');
136
- // Inspect before touching properties: exporting must not call accessors on an alleged record.
137
124
  const prepared = _rapierPrepareHistoryRecords(records, earlier);
138
125
  if (!prepared.ok) throw fault('navigation target was not retained');
139
126
  const headFacts = yield* sourceFacts(text);
@@ -153,8 +140,6 @@ function* produceLedger({text, metadata, records, documentAuthority, revision, r
153
140
  startSource = startSource.apply(expanded[i].splices, true);
154
141
  startMetadata = _rapierTransformMetadata(startMetadata, expanded[i].metadata, true);
155
142
  if (startSource === null || !startMetadata) throw fault('history does not reverse from this document');
156
- // Whole scalars and exact splice boundaries were just checked. Count the changed
157
- // bytes, rather than re-encoding every reconstructed whole-document revision.
158
143
  for (const row of expanded[i].splices) bytes += utf8Length(row.removed) - utf8Length(row.inserted);
159
144
  if (bytes > 64 * 1024 * 1024) throw fault('document exceeds the editor text limit');
160
145
  yield;
@@ -174,8 +159,6 @@ function* produceLedger({text, metadata, records, documentAuthority, revision, r
174
159
  const payload = {format: 'rapier-ledger/2', documentAuthority,
175
160
  start: {text: startText, revision: startRevision, root: startRoot, sha256: startSHA, metadata: startMetadata},
176
161
  records: expanded, head: {revision, root, sha256: headSHA, metadata: {...metadata}}, complete: complete === true && startRevision === 0 && expanded.every((row, i) => !i || row.transaction.baseRevision === expanded[i - 1].transaction.revision)};
177
- // Expanded rows are the exact canonical projection of this owned preparation.
178
- // Keep its navigation/effect proof instead of admitting another copied graph.
179
162
  for (const _record of checkedRecords(payload, prepared)) yield;
180
163
  if (expanded.some(row => Object.hasOwn(row, 'authored'))) {
181
164
  try { authoredHistory(startText, expanded); } catch (_) { throw fault('authored origin differs'); }
@@ -191,7 +174,6 @@ function finish(producer) {
191
174
  return step.value;
192
175
  }
193
176
  export function exportLedger(input) { return finish(produceLedger(input)).ledger; }
194
- // Its returned facts were computed while admitting/hashing the exact head, never supplied.
195
177
  export async function prepareLedgerAsync(input, {yield: yieldWork, signal} = {}) {
196
178
  const producer = produceLedger(input);
197
179
  let step;
@@ -199,7 +181,6 @@ export async function prepareLedgerAsync(input, {yield: yieldWork, signal} = {})
199
181
  signal?.throwIfAborted();
200
182
  step = producer.next();
201
183
  if (!step.done && yieldWork) {
202
- // The caller owns scheduling and its clock; ledger admission reads neither.
203
184
  const pending = yieldWork();
204
185
  if (pending) await pending;
205
186
  }
@@ -207,7 +188,6 @@ export async function prepareLedgerAsync(input, {yield: yieldWork, signal} = {})
207
188
  signal?.throwIfAborted();
208
189
  return step.value;
209
190
  }
210
- // Derived navigation, never a second history. A target older than start is intentionally unavailable.
211
191
  export function historyEnvelope(input, segmentIdentity = [], expectedText, expectedMetadata) {
212
192
  const {value: {ledger, text, metadata}, prepared, source} = readLedgerState(input, expectedText, expectedMetadata);
213
193
  const {byId, effects: {pictures}} = prepared;
@@ -235,8 +215,6 @@ export function historyEnvelope(input, segmentIdentity = [], expectedText, expec
235
215
  }
236
216
  if (ledger.complete) {
237
217
  if (ledger.records.length && !branch.length) throw fault('complete history lost its branch');
238
- // Literal admitted splices are reversible. Proving the actual head back to
239
- // the exact start also proves its forward active branch, without replaying it.
240
218
  if (traversed.length !== ledger.start.text.length || !traversed.matches(0, ledger.start.text) ||
241
219
  canonicalJSON(traversedMetadata) !== canonicalJSON(ledger.start.metadata)) throw fault('complete branch differs');
242
220
  }
@@ -246,8 +224,6 @@ export function historyEnvelope(input, segmentIdentity = [], expectedText, expec
246
224
  traversedMetadata = _rapierTransformMetadata(traversedMetadata, byId.get(branch[i]).metadata);
247
225
  if (traversed === null || !traversedMetadata) throw fault('redo branch does not replay');
248
226
  }
249
- // Picture folding may relocate finished definitions while navigating. Keep its
250
- // distinct forward proof; the literal-only case above needs no second traversal.
251
227
  if (ledger.complete && pictures.size) {
252
228
  let active = new HistorySource(ledger.start.text), activeMetadata = {...ledger.start.metadata};
253
229
  for (let i = 0; i < branch.length; i++) {
@@ -270,7 +246,6 @@ export function agentActorId(door, agent) {
270
246
  return door + '/' + agent.name;
271
247
  }
272
248
 
273
- // A writer changing representation records that change, rather than attaching an unrelated head.
274
249
  export function replaceLedgerText(input, text, {actor = {kind: 'system', id: 'rapier'}, operation = 'document.replace', at} = {}) {
275
250
  const current = readLedger(input); documentText(text);
276
251
  if (current.text === text) return current.ledger;
@@ -1,9 +1,7 @@
1
1
  // SPDX-License-Identifier: MIT
2
- // The editor's path-dependent root, plus synchronous SHA-256 for file readers (including offline wrappers).
3
2
  export function textRoot(text) {
4
3
  return textRootParts([text]);
5
4
  }
6
- // The same UTF-16 root over shared source pieces, without joining a historical body.
7
5
  export function textRootParts(parts) {
8
6
  let fnv = 0x811c9dc5, a = 1, b = 0, length = 0;
9
7
  for (const text of parts) {
@@ -30,7 +28,6 @@ const K = new Uint32Array([
30
28
  0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2,
31
29
  ]);
32
30
  const rotate = (x, n) => (x >>> n) | (x << (32 - n));
33
- // One bounded SHA-256 compression state for file bytes and encoded source text.
34
31
  export function sha256State() {
35
32
  const h = new Uint32Array([0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19]);
36
33
  const w = new Uint32Array(64), block = new Uint8Array(64);
@@ -82,14 +79,11 @@ export function sha256State() {
82
79
  get bytes() { return bytes; },
83
80
  };
84
81
  }
85
- // UTF-16 chunks share that byte state. Retain a trailing high surrogate so a
86
- // caller can split a character between updates without changing its UTF-8 digest.
87
82
  export function createSHA256() {
88
83
  const hash = sha256State(), encoded = new Uint8Array(65536), encoder = new TextEncoder();
89
84
  let high = '', finished = false;
90
85
  const write = text => {
91
86
  for (let at = 0; at < text.length;) {
92
- // Bound both the substring and byte allocation; encodeInto never cuts a scalar.
93
87
  let end = Math.min(text.length, at + 32768);
94
88
  if (end < text.length && (text.charCodeAt(end - 1) & 0xfc00) === 0xd800 && (text.charCodeAt(end) & 0xfc00) === 0xdc00) end--;
95
89
  const part = encoder.encodeInto(text.slice(at, end), encoded);
@@ -117,4 +111,14 @@ export function sha256Parts(parts) {
117
111
  for (const part of parts) hash.update(part);
118
112
  return hash.digest();
119
113
  }
120
- export function sha256(text) { return createSHA256().update(text).digest(); }
114
+ const LONG_TEXT = 65536, DIGESTS = new Map();
115
+ export function sha256(text) {
116
+ if (typeof text !== 'string' || text.length < LONG_TEXT) return createSHA256().update(text).digest();
117
+ let digest = DIGESTS.get(text);
118
+ if (digest === undefined) {
119
+ digest = createSHA256().update(text).digest();
120
+ if (DIGESTS.size >= 4) DIGESTS.delete(DIGESTS.keys().next().value);
121
+ } else DIGESTS.delete(text);
122
+ DIGESTS.set(text, digest);
123
+ return digest;
124
+ }
@@ -1,5 +1,4 @@
1
1
  // SPDX-License-Identifier: MIT
2
- // Transient projections and selective inverses of the existing canonical journal.
3
2
  import {_rapierTransformSplices, _rapierValidMetadata, _rapierTransformMetadata, _rapierMetadataDelta, _rapierHistoryEffects, _rapierMetadataState, _rapierPrepareHistoryRecords} from './journal-records.mjs';
4
3
  import {projectInverseSource, HistorySource} from './inverse-source.mjs';
5
4
  import {textRootParts, rootAfter} from './hash.mjs';
@@ -41,16 +40,12 @@ export function groupHistoryActs(acts) {
41
40
  return groups;
42
41
  }
43
42
 
44
- // A picture's codec replacement travels with its original navigation step.
45
- // Match every complete replacement exactly before moving any source bytes.
46
43
  export function pictureHistorySplices(input, target, redo) {
47
44
  const {records} = input;
48
45
  const id = target?.transaction?.id, at = records.indexOf(target);
49
46
  if (!id || at < 0 || target.transaction.operation === 'document.finish-picture' || !Array.isArray(target.splices)) return null;
50
47
  const later = records.slice(at + 1).filter(row => row?.transaction?.operation === 'document.finish-picture' && Array.isArray(row.splices) && row.splices.length);
51
48
  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
49
  const source = input.source;
55
50
  const own = new Set(later.filter(row => row.transaction.sourceTransactionId === id));
56
51
  const step = redo ? target.splices : target.splices.slice().reverse().map(row => ({pos: row.pos, removed: row.inserted, inserted: row.removed}));
@@ -78,9 +73,6 @@ export function pictureHistorySplices(input, target, redo) {
78
73
  try { return plan(true); } catch (_) { return null; }
79
74
  }
80
75
 
81
- // Keep only the latest source observation. Inactive live documents must not retain
82
- // every revision's source tree. The weak key also lets the latest input be collected.
83
- // Mutable caller data is compared/captured before this cache can be consulted.
84
76
  let preparedHistories = new WeakMap();
85
77
  function prepareHistory(input) {
86
78
  try {
@@ -123,7 +115,6 @@ function prepareHistory(input) {
123
115
  row.beforeSource = initial; row.beforeMetadata = initialMetadata;
124
116
  }
125
117
  const earliestRevision = records[0]?.transaction.baseRevision ?? revision;
126
- // The window's owner may name its root (the source store mints it once per text); otherwise hash the initial text.
127
118
  let root = earliestRevision === 0 ? (typeof input.earliestRoot === 'string' && input.earliestRoot || textRootParts(initial.parts())) : null,
128
119
  rootRevision = earliestRevision;
129
120
  for (const {record, splices} of rows) {
@@ -131,8 +122,6 @@ function prepareHistory(input) {
131
122
  if (hashed && (![record.beforeHash, record.afterHash].every(value =>
132
123
  typeof value === 'string' && /^\d+:[a-z0-9]+:[a-z0-9]+$/.test(value)) ||
133
124
  (splices.length ? record.beforeHash === record.afterHash : record.beforeHash !== record.afterHash))) return historyFailure('history_invalid');
134
- // Revision gaps begin a new path root; a continuous revision-zero prefix
135
- // still binds every untouched source byte through the initial text root.
136
125
  if (record.transaction.baseRevision !== rootRevision) root = null;
137
126
  if (hashed) {
138
127
  if (root !== null && record.beforeHash !== root) return historyFailure('history_invalid');
@@ -155,12 +144,18 @@ function prepareHistory(input) {
155
144
  } catch (_) { return historyFailure('history_invalid'); }
156
145
  }
157
146
 
147
+ function settle(result, value) {
148
+ Object.defineProperty(result, 'initialSource', {value, writable: true, enumerable: true, configurable: true});
149
+ return value;
150
+ }
151
+
158
152
  export function replayHistory(input) {
159
153
  const prepared = prepareHistory(input);
160
154
  if (!prepared.ok) return prepared;
161
- const {source, initialSource, metadata, initialMetadata, revision, earliestRevision, complete, rows, effects} = prepared;
155
+ const {source, metadata, initialMetadata, revision, earliestRevision, complete, rows, effects} = prepared;
162
156
  const acts = rows.filter(({record}) => !effects.pictures.has(record.transaction.id)).map(({record}) => historyAct(record, effects));
163
- return {ok: true, source, initialSource, metadata: {...metadata}, initialMetadata: {...initialMetadata}, revision, earliestRevision, complete, acts,
157
+ return {ok: true, source, get initialSource() { return settle(this, prepared.initialSource); }, set initialSource(value) { settle(this, value); },
158
+ metadata: {...metadata}, initialMetadata: {...initialMetadata}, revision, earliestRevision, complete, acts,
164
159
  effectiveActIds: effects.effectiveActIds.slice(), groups: groupHistoryActs(acts)};
165
160
  }
166
161
 
@@ -218,7 +213,6 @@ export function historyProjection(input, {from = 0, to = input?.source?.length,
218
213
  return projectHistoryRange(prepared, from, to, actId);
219
214
  }
220
215
 
221
- // Several ranges share one proved source snapshot for this call.
222
216
  export function historyProjections(input, ranges) {
223
217
  const prepared = prepareHistory(input);
224
218
  if (!prepared.ok) return prepared;
@@ -227,8 +221,6 @@ export function historyProjections(input, ranges) {
227
221
  projectHistoryRange(prepared, from, to, actId))};
228
222
  }
229
223
 
230
- // Block intervals stay ordered under reverse splices. A fixed balanced tree can
231
- // shift an untouched suffix lazily and visit only intervals crossing the edit.
232
224
  function historyBlockIndex(projected) {
233
225
  const items = projected.map(block => ({block, range: {from: block.from, to: block.to}, shift: 0}))
234
226
  .sort((a, b) => a.range.from - b.range.from);
@@ -278,8 +270,6 @@ export function historyPlaces(input, blocks) {
278
270
  const {record, splices, beforeSource, afterSource} = prepared.rows[index], touched = new Map();
279
271
  for (let at = splices.length - 1; at >= 0; at--) ranges.move(splices[at], node => {
280
272
  if (touched.has(node) || prepared.effects.pictures.has(record.transaction.id)) return;
281
- // Earlier splices in this reverse walk only shifted this interval. Recover
282
- // its coordinates at the record's after-state before recording the first touch.
283
273
  let after = {...node.range};
284
274
  for (let prior = at + 1; prior < splices.length; prior++) {
285
275
  const row = splices[prior];
@@ -290,37 +280,31 @@ export function historyPlaces(input, blocks) {
290
280
  }
291
281
  touched.set(node, after);
292
282
  });
293
- // Blocks one splice covers share its range (a whole-document delete maps every block to all it removed): slice each range once.
294
283
  const slices = new Map(), cut = (source, side, from, to) => {
295
284
  const key = side + from + ':' + to;
296
285
  if (!slices.has(key)) slices.set(key, source.slice(from, to));
297
286
  return slices.get(key);
298
287
  };
299
- // One record's act is read once; the blocks it touched share its fields (a whole-document delete touches every block).
300
288
  let act = null;
301
289
  for (const [node, afterRange] of touched) {
302
290
  const beforeRange = ranges.range(node), previous = {...beforeRange, source: cut(beforeSource, 'b', beforeRange.from, beforeRange.to)},
303
291
  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
292
  if (previous.source !== after.source || afterRange.from === afterRange.to) node.block.acts.push({...(act ||= historyAct(record)), before: previous, after});
306
293
  }
307
294
  }
308
295
 
309
296
  const acts = prepared.rows.filter(({record}) => !prepared.effects.pictures.has(record.transaction.id)).map(({record}) => historyAct(record));
310
- // Native Redo consumes its Undo as navigation while keeping that receipt in
311
- // the reading history. All material withdrawals use the canonical blockers,
312
- // including independent inverses holding the same original act suppressed.
313
297
  const reversed = new Set();
314
298
  for (const {record} of prepared.rows) if (prepared.effects.active.has(record.transaction.id) && !record.transaction.reapplies)
315
299
  for (const id of prepared.effects.references.get(record.transaction.id) || []) reversed.add(id);
316
300
 
317
301
  for (const block of projected) block.acts.reverse();
318
- return {ok: true, source: prepared.source, initialSource: prepared.initialSource, revision: prepared.revision,
302
+ return {ok: true, source: prepared.source, get initialSource() { return settle(this, prepared.initialSource); },
303
+ set initialSource(value) { settle(this, value); }, revision: prepared.revision,
319
304
  earliestRevision: prepared.earliestRevision, complete: prepared.complete, acts, blocks: projected,
320
305
  reversed: acts.filter(act => reversed.has(act.id)).map(act => act.id)};
321
306
  }
322
307
 
323
- // Runs retain only exact replacement ancestry. A later replacement owns an ambiguous hunk whole.
324
308
  export function selectiveUndo(input, actIds, options = {}) {
325
309
  const prepared = prepareHistory(input);
326
310
  if (!prepared.ok) return prepared;
@@ -340,9 +324,6 @@ export function selectiveUndo(input, actIds, options = {}) {
340
324
  const metadata = _rapierMetadataDelta(prepared.metadata, afterMetadata), effectChanged = selected.size > 0;
341
325
  const sourceEffectChanged = records.some(record => record.splices.length && !prepared.effects.references.get(record.transaction.id)?.length &&
342
326
  prepared.effects.active.has(record.transaction.id) !== projectedEffects.active.has(record.transaction.id));
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
327
  let coverVisibilityChanged = false;
347
328
  if (!sourceEffectChanged && selected.size) {
348
329
  let origins = null;
@@ -1,6 +1,4 @@
1
1
  // SPDX-License-Identifier: MIT
2
- // Exact source projection shared by canonical history and causal transport.
3
- // Callers admit and order the records first; this projection retains no state.
4
2
  import {_rapierTransformSplices, _rapierHistoryEffects} from './journal-records.mjs';
5
3
  import {authoredPlacements} from './authored-origin.mjs';
6
4
  import {canonicalJSON} from './data.mjs';
@@ -11,8 +9,6 @@ const historyBoundary = (source, at) => Number.isSafeInteger(at) && at >= 0 && !
11
9
  !(at > 0 && at < source.length && (source.charCodeAt(at - 1) & 0xFC00) === 0xD800 &&
12
10
  (source.charCodeAt(at) & 0xFC00) === 0xDC00);
13
11
 
14
- // Persistent AVL pieces retain original strings and coordinates. A revision costs
15
- // only the edited paths; reading a range visits its leaves, never earlier bodies.
16
12
  const sourceLeaf = (text, from = 0, to = text.length) => to > from
17
13
  ? {text, from, length: to - from, height: 1} : null;
18
14
  const sourceHeight = node => node?.height || 0;
@@ -101,8 +97,6 @@ export class HistorySource {
101
97
  for (const text of parts) for (let at = 0; at < text.length;) {
102
98
  if (!leaf) return false;
103
99
  const length = Math.min(leaf.length - offset, text.length - at), from = leaf.from + offset;
104
- // Untouched runs share their string and coordinates. Only changed fragments
105
- // require a text comparison; no full-body join is needed to prove the plan.
106
100
  if ((leaf.text !== text || from !== at) && leaf.text.slice(from, from + length) !== text.slice(at, at + length)) return false;
107
101
  offset += length; at += length;
108
102
  if (offset === leaf.length) { leaf = leaves.next().value; offset = 0; }
@@ -113,8 +107,6 @@ export class HistorySource {
113
107
  const [left, rest] = sourceSplit(this.#root, pos), [, right] = sourceSplit(rest, length);
114
108
  return new HistorySource('', sourceJoin(sourceJoin(left, sourceLeaf(text)), right));
115
109
  }
116
- // Undoing an admitted repair is observation: its after-state can join a scalar
117
- // across the deleted run. Prove the forward boundaries on the reconstructed view.
118
110
  before(splices) {
119
111
  let source = this;
120
112
  for (let index = splices.length - 1; index >= 0; index--) {
@@ -151,8 +143,6 @@ function minimalHistorySplice(before, after, pos = 0) {
151
143
  return {pos: pos + first, removed: before.slice(first, a), inserted: after.slice(first, b)};
152
144
  }
153
145
 
154
- // Split recorded replacements only at exact unchanged word or line tokens.
155
- // Shared letters inside changed words do not establish surviving ownership.
156
146
  function historyHunks(row) {
157
147
  if (row.removed === row.inserted) return [];
158
148
  if (!row.removed || !row.inserted) return [{...row}];
@@ -166,8 +156,6 @@ function historyHunks(row) {
166
156
  }
167
157
 
168
158
 
169
- // Rows are already proved source placements, optionally carrying sourceIndices from
170
- // causal projection. Multiple inverse blockers can change history with no source edit.
171
159
  export function projectInverseSource(prepared, selected, projectedEffects, options = {}) {
172
160
  const records = prepared.records || prepared.rows.map(({record, splices}) => record.splices === splices ? record : {...record, splices});
173
161
  if (!records.some(record => !prepared.effects.references.get(record.transaction.id)?.length &&
@@ -179,8 +167,6 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
179
167
  const recordsForCover = prepared.records || prepared.rows.map(({record, splices}) => record.splices === splices ? record : {...record, splices});
180
168
  const coverGroups = new Map();
181
169
  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
170
  if (typeof prepared.initialSource === 'string' && recordsForCover.some(record => record.authored?.splices?.some(row => row.removed.length))) {
185
171
  try { coverOrigins = authoredPlacements(prepared.initialSource, recordsForCover); } catch { coverOrigins = null; }
186
172
  }
@@ -254,9 +240,6 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
254
240
  return next && effects.active.has(next.actId) && !prepared.effects.references.get(next.actId)?.length;
255
241
  });
256
242
  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
243
  const wanted = new Set(actIds);
261
244
  for (const id of actIds) for (const hunk of all) {
262
245
  if (hunk.reversedHunk || hunk.derived || !hunk.coverIds?.includes(id) || wanted.has(hunk.actId)) continue;
@@ -269,8 +252,6 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
269
252
  (!hunk.inserted && hunk.active)) continue;
270
253
  const marker = pieces.indexOf(hunk.marker);
271
254
  if (marker < 0) {
272
- // A zero-row withdrawal can release source still carried by another
273
- // inverse's marker. Its exact ancestry also preserves later inserted runs.
274
255
  const inverse = all.findLast(prior => prior.reversedHunk === hunk.id && pieces.includes(prior.marker));
275
256
  if (!inverse) throw Error('reactivation');
276
257
  const runs = all.filter(prior => prior.actId === inverse.actId && prior.reversedHunk === hunk.id && pieces.includes(prior.marker));
@@ -302,8 +283,6 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
302
283
  processed.push(record);
303
284
  const picture = prepared.effects.pictures.get(tx.id);
304
285
  if (picture) {
305
- // The new codec bytes keep the exact inserted run's ownership. Do not
306
- // cross a later edit, combine authors, or create another reversible act.
307
286
  for (const row of splices) {
308
287
  boundary(row.pos + row.removed.length);
309
288
  const start = boundary(row.pos), end = boundary(row.pos + row.removed.length);
@@ -318,15 +297,11 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
318
297
  continue;
319
298
  }
320
299
  if (references.size) {
321
- // Exact inverse rows restore the previous run identities. Retain the inverse's
322
- // own transient marker too, so reversing it restores that same ancestry again.
323
300
  const invert = targets => {
324
301
  const restored = pieces.slice(), inverses = [], priorSplices = options.keepHunk ? [] : null;
325
302
  for (let index = targets.length - 1; index >= 0; index--) {
326
303
  const target = targets[index], first = restored.indexOf(target.marker);
327
304
  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
305
  const runs = [];
331
306
  for (let at = first; at < restored.length; at++) if (owns(restored[at], target))
332
307
  if (runs.at(-1)?.[1] === at) runs.at(-1)[1]++; else runs.push([at, at + 1]);
@@ -346,8 +321,6 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
346
321
  hunk.chosen = !keep;
347
322
  }
348
323
  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
324
  if (hunk.kept) for (let prior = target; prior; prior = byId.get(prior.reversedHunk)) keptInverseAncestors.add(prior.id);
352
325
  hunk.marker = {mark: hunk};
353
326
  const insertion = replacement.map(piece => piece.text ? {...piece,
@@ -359,10 +332,6 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
359
332
  }
360
333
  return replays(pieces, restored, splices) ? {restored, inverses} : null;
361
334
  };
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
335
  const direct = all.filter(hunk => hunk.active && references.has(hunk.actId));
367
336
  const targets = direct.length ? direct : all.filter(hunk => hunk.active && !hunk.reversedHunk && !hunk.derived &&
368
337
  hunk.coverIds?.some(id => references.has(id)));
@@ -399,16 +368,12 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
399
368
  }
400
369
  if (!references.size) coverIdsFor(tx.id);
401
370
  const priorSplices = options.keepHunk ? [] : null;
402
- // An admitted codec finish replaces the complete embedded definition/reference.
403
- // Keep its inserted run whole, including byte-identical prefixes of a re-edit;
404
- // the previous picture stays in `before` for Undo, not as mixed live ownership.
405
371
  const hunks = row => pictureInsertions.get(tx.id)?.some(text => row.inserted.includes(text)) ? [{...row}] : historyHunks(row);
406
372
  for (let rowIndex = 0; rowIndex < splices.length; rowIndex++) {
407
373
  const original = splices[rowIndex];
408
374
  let rows;
409
375
  if (options.splitSplice) {
410
376
  const beforeSource = textOf(pieces), parts = options.splitSplice({actId: tx.id, beforeSource, splice: original});
411
- // Semantic boundaries only refine this act's exact bytes, never its history.
412
377
  if (!Array.isArray(parts) || _rapierTransformSplices(beforeSource, parts) !== _rapierTransformSplices(beforeSource, [original]))
413
378
  throw Error('hunk_projection');
414
379
  rows = parts.flatMap(hunks);
@@ -421,14 +386,11 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
421
386
  const start = boundary(row.pos), end = boundary(row.pos + row.removed.length);
422
387
  let at = start.last, count = Math.max(0, end.first - at);
423
388
  if (!row.removed.length) {
424
- // A new insertion at a replacement's left boundary stays before that replacement.
425
389
  const marker = pieces.slice(start.first, start.last).findIndex(item => item.mark?.inserted.length);
426
390
  at = marker < 0 ? start.last : start.first + marker; count = 0;
427
391
  }
428
392
  const removed = pieces.slice(at, at + count);
429
393
  if (textOf(removed) !== row.removed) throw Error('replay');
430
- // A semantic owner may retain a drawing hunk while prose in that same act undoes.
431
- // The predicate only selects transient hunks; every original row still replays.
432
394
  if (chosen && options.keepHunk) {
433
395
  const keep = options.keepHunk({actId: tx.id, spliceIndex: rowIndex, hunkIndex: index,
434
396
  splice: {pos: row.pos, removed: row.removed, inserted: row.inserted}, beforeSource: textOf(pieces), actSource, priorSplices});
@@ -459,10 +421,6 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
459
421
  let offset = 0;
460
422
  for (const piece of pieces) if (piece.text) { origins.set(piece, offset); offset += piece.text.length; }
461
423
  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
424
  for (const hunk of all) if (hunk.survived) {
467
425
  const covered = [...hunk.supersededBy].some(id => {
468
426
  const next = byId.get(id);
@@ -486,12 +444,8 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
486
444
  reason: hunk.active ? 'later_edit' : 'already_undone'});
487
445
  continue;
488
446
  }
489
- // Restoring an inverse can restore a deletion marker without any owned text.
490
447
  restoreHunk(hunk);
491
448
  }
492
- // A hidden withdrawal can have no splice of its own. Reversing that withdrawal
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
449
  const reactivated = new Set(records.filter(record => !prepared.effects.active.has(record.transaction.id) &&
496
450
  projectedEffects.active.has(record.transaction.id)).map(record => record.transaction.id));
497
451
  for (const hunk of all) if (!hunk.reversedHunk && !hunk.derived && hunk.coverIds?.length > 1 &&