rapier-html 1.1.101 → 1.1.104

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,8 +1,8 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  // Transient projections and selective inverses of the existing canonical journal.
3
- import {_rapierTransformSplices, _rapierRecordSplices, _rapierRecordMetadata, _rapierValidMetadata, _rapierTransformMetadata, _rapierMetadataDelta, _rapierHistoryEffects, _rapierMetadataState, _rapierReplayMetadata} from './journal-records.mjs';
4
- import {projectInverseSource} from './inverse-source.mjs';
5
- import {textRoot, rootAfter} from './hash.mjs';
3
+ import {_rapierTransformSplices, _rapierRecordSplices, _rapierRecordMetadata, _rapierValidMetadata, _rapierTransformMetadata, _rapierMetadataDelta, _rapierHistoryEffects, _rapierMetadataState, _rapierReplayMetadata, _rapierPrepareHistoryRecords} from './journal-records.mjs';
4
+ import {projectInverseSource, HistorySource} from './inverse-source.mjs';
5
+ import {textRootParts, rootAfter} from './hash.mjs';
6
6
  import {authoredHistory} from './transport.mjs';
7
7
  import {plainData} from './data.mjs';
8
8
 
@@ -12,16 +12,16 @@ const historyBoundary = (source, at) => historyInteger(at) && at <= source.lengt
12
12
  (source.charCodeAt(at - 1) & 0xFC00) === 0xD800 && (source.charCodeAt(at) & 0xFC00) === 0xDC00);
13
13
 
14
14
  function historyAct(record, effects = null) {
15
- const tx = record.transaction;
16
- return {id: tx.id, actor: {...tx.actor}, createdAt: tx.createdAt, baseRevision: tx.baseRevision, revision: tx.revision,
17
- operation: tx.operation || 'document.change',
15
+ const tx = record.transaction, copy = value => value && typeof value === 'object' ? plainData(value) : value;
16
+ return {id: tx.id, actor: Object.fromEntries(Object.entries(tx.actor).map(([key, value]) => [key, copy(value)])), createdAt: tx.createdAt, baseRevision: tx.baseRevision, revision: tx.revision,
17
+ operation: copy(tx.operation) || 'document.change',
18
18
  ...(tx.turnId ? {turnId: tx.turnId} : {}),
19
- ...(tx.contribution ? {contribution: tx.contribution} : {}),
19
+ ...(tx.contribution ? {contribution: copy(tx.contribution)} : {}),
20
20
  ...(tx.sourceTransactionId ? {sourceTransactionId: tx.sourceTransactionId} : {}),
21
21
  ...(tx.sourceTransactionIds?.length ? {sourceTransactionIds: tx.sourceTransactionIds.slice()} : {}),
22
22
  ...(tx.reverts ? {reverts: tx.reverts} : {}), ...(tx.reapplies ? {reapplies: tx.reapplies} : {}),
23
- ...(record.changeSet && Object.hasOwn(record.changeSet, 'label') ? {label: record.changeSet.label} : {}),
24
- ...(Array.isArray(tx.affectedBlockIds) ? {affectedBlockIds: tx.affectedBlockIds.slice()} : {}),
23
+ ...(record.changeSet && Object.hasOwn(record.changeSet, 'label') ? {label: copy(record.changeSet.label)} : {}),
24
+ ...(Array.isArray(tx.affectedBlockIds) ? {affectedBlockIds: tx.affectedBlockIds.map(copy)} : {}),
25
25
  ...(record.metadata ? {metadata: plainData(record.metadata)} : {}),
26
26
  ...(effects ? {active: effects.active.has(tx.id)} : {})};
27
27
  }
@@ -74,76 +74,75 @@ export function pictureHistorySplices({source, records}, target, redo) {
74
74
  try { return plan(true); } catch (_) { return null; }
75
75
  }
76
76
 
77
- // Reading an admitted repair can recover pre-existing lone UTF-16 units. This is
78
- // not a document mutation; the forward replay and roots still validate every row.
79
- function historyPreviousSource(source, splices) {
80
- let value = source;
81
- for (let index = splices.length - 1; index >= 0; index--) {
82
- const row = splices[index];
83
- if (!row || !historyInteger(row.pos) || typeof row.removed !== 'string' || typeof row.inserted !== 'string' ||
84
- row.pos > value.length || row.inserted.length > value.length - row.pos ||
85
- value.slice(row.pos, row.pos + row.inserted.length) !== row.inserted) return null;
86
- value = value.slice(0, row.pos) + row.removed + value.slice(row.pos + row.inserted.length);
87
- }
88
- return value;
89
- }
90
-
91
- // This is a projection of the existing records. No source snapshots or provenance survive the call.
77
+ // Keep only the latest source observation. Inactive live documents must not retain
78
+ // every revision's source tree. The weak key also lets the latest input be collected.
79
+ // Mutable caller data is compared/captured before this cache can be consulted.
80
+ let preparedHistories = new WeakMap();
92
81
  function prepareHistory(input) {
93
82
  try {
94
83
  if (!input || typeof input.source !== 'string' || !Array.isArray(input.records) || !_rapierValidMetadata(input.metadata)) return historyFailure('history_invalid');
95
- const source = input.source, records = plainData(input.records), ids = new Set(), rows = [];
96
- const effects = _rapierHistoryEffects(records);
97
- if (!effects.ok) return historyFailure('history_invalid');
84
+ const source = input.source, metadata = {...input.metadata}, index = _rapierPrepareHistoryRecords(input.records);
85
+ if (!index.ok) return historyFailure('history_invalid');
86
+ const {records, effects} = index;
98
87
  const revision = input.revision ?? records.at(-1)?.transaction?.revision ?? 0;
99
- if (!historyInteger(revision)) return historyFailure('history_invalid');
100
- let prior = input.earliestRevision ?? records[0]?.transaction?.baseRevision ?? revision, complete = prior === 0;
101
- if (!historyInteger(prior)) return historyFailure('history_invalid');
102
- for (const record of records) {
103
- const tx = record?.transaction, actor = tx?.actor;
104
- if (!tx || typeof tx.id !== 'string' || !tx.id || ids.has(tx.id) || !historyInteger(tx.baseRevision) ||
88
+ let prior = input.earliestRevision ?? records[0]?.transaction?.baseRevision ?? revision;
89
+ if (!historyInteger(revision) || !historyInteger(prior)) return historyFailure('history_invalid');
90
+ const earliest = prior, suppliedComplete = input.complete !== false, cached = preparedHistories.get(records);
91
+ if (cached && cached.source === source && cached.revision === revision && cached.earliest === earliest &&
92
+ cached.suppliedComplete === suppliedComplete && cached.metadata.filename === metadata.filename && cached.metadata.docKind === metadata.docKind) return cached;
93
+ let complete = prior === 0;
94
+ const rows = [], byId = new Map();
95
+ for (const owned of index.rows) {
96
+ const {record, splices, metadata: effect} = owned, tx = record?.transaction, actor = tx?.actor;
97
+ if (!tx || typeof tx.id !== 'string' || !tx.id || byId.has(tx.id) || !historyInteger(tx.baseRevision) ||
105
98
  !historyInteger(tx.revision) || tx.revision !== tx.baseRevision + 1 || tx.baseRevision < prior ||
106
99
  !actor || !['human', 'agent', 'system'].includes(actor.kind) || typeof actor.id !== 'string' || !actor.id ||
107
100
  !(tx.createdAt === null || historyInteger(tx.createdAt)) ||
108
101
  (tx.turnId != null && (typeof tx.turnId !== 'string' || !tx.turnId || tx.turnId.length > 160))) return historyFailure('history_invalid');
109
102
  if (tx.baseRevision !== prior) complete = false;
110
- const splices = _rapierRecordSplices(record, records), metadata = _rapierRecordMetadata(record, records);
111
- if (!splices || metadata === undefined || (!splices.length &&
112
- !Object.values(metadata || {}).some(pair => pair.before !== pair.after) && !effects.references.get(tx.id)?.length)) return historyFailure('history_unavailable');
113
- rows.push({record, splices, metadata}); ids.add(tx.id); prior = tx.revision;
103
+ if (!splices || effect === undefined || (!splices.length &&
104
+ !Object.values(effect || {}).some(pair => pair.before !== pair.after) && !effects.references.get(tx.id)?.length)) return historyFailure('history_unavailable');
105
+ const row = {record, splices, metadata: effect};
106
+ rows.push(row); byId.set(tx.id, row); prior = tx.revision;
114
107
  }
115
108
  if (prior !== revision) return historyFailure('history_unavailable');
116
- let initialSource = source, initialMetadata = {...input.metadata};
117
- for (let index = rows.length - 1; index >= 0; index--) {
118
- initialSource = historyPreviousSource(initialSource, rows[index].splices);
119
- initialMetadata = _rapierTransformMetadata(initialMetadata, rows[index].metadata, true);
120
- if (initialSource === null || !initialMetadata) return historyFailure('history_invalid');
109
+ let initial = new HistorySource(source), initialMetadata = {...metadata};
110
+ for (let at = rows.length - 1; at >= 0; at--) {
111
+ const row = rows[at];
112
+ row.afterSource = initial; row.afterMetadata = initialMetadata;
113
+ initial = initial.before(row.splices);
114
+ initialMetadata = _rapierTransformMetadata(initialMetadata, row.metadata, true);
115
+ if (!initial || !initialMetadata) return historyFailure('history_invalid');
116
+ row.beforeSource = initial; row.beforeMetadata = initialMetadata;
121
117
  }
122
- const earliestRevision = rows[0]?.record.transaction.baseRevision ?? revision;
123
- let replay = initialSource, root = earliestRevision === 0 ? textRoot(initialSource) : null, rootRevision = earliestRevision;
118
+ const earliestRevision = records[0]?.transaction.baseRevision ?? revision;
119
+ let root = earliestRevision === 0 ? textRootParts(initial.parts()) : null, rootRevision = earliestRevision;
124
120
  for (const {record, splices} of rows) {
125
121
  const hashed = Object.hasOwn(record, 'beforeHash') || Object.hasOwn(record, 'afterHash');
126
122
  if (hashed && (![record.beforeHash, record.afterHash].every(value =>
127
123
  typeof value === 'string' && /^\d+:[a-z0-9]+:[a-z0-9]+$/.test(value)) ||
128
- (splices.length ? record.beforeHash === record.afterHash : record.beforeHash !== record.afterHash)))
129
- return historyFailure('history_invalid');
130
- // Roots bind revision zero to the full source. A retained suffix has only a path root;
131
- // omitted neutral pairs change that root, so a stated revision gap begins a new chain.
124
+ (splices.length ? record.beforeHash === record.afterHash : record.beforeHash !== record.afterHash))) return historyFailure('history_invalid');
125
+ // Revision gaps begin a new path root; a continuous revision-zero prefix
126
+ // still binds every untouched source byte through the initial text root.
132
127
  if (record.transaction.baseRevision !== rootRevision) root = null;
133
128
  if (hashed) {
134
129
  if (root !== null && record.beforeHash !== root) return historyFailure('history_invalid');
135
130
  root = record.beforeHash;
136
131
  }
137
- replay = _rapierTransformSplices(replay, splices);
138
- if (replay === null) return historyFailure('history_invalid');
139
- if (root !== null) for (const row of splices) root = rootAfter(root, row);
132
+ if (root !== null) for (const splice of splices) root = rootAfter(root, splice);
140
133
  if (hashed && root !== record.afterHash) return historyFailure('history_invalid');
141
134
  rootRevision = record.transaction.revision;
142
135
  }
143
- if (records.some(row => Object.hasOwn(row, 'authored'))) authoredHistory(initialSource, rows.map(({record, splices}) => ({...record, splices})));
144
- const metadata = _rapierReplayMetadata(initialMetadata, records);
145
- if (replay !== source || !metadata || ['filename', 'docKind'].some(key => metadata[key] !== input.metadata[key])) return historyFailure('history_invalid');
146
- return {ok: true, source, initialSource, metadata, initialMetadata, effects, rows, revision, earliestRevision, complete: input.complete !== false && complete && earliestRevision === 0};
136
+ let initialText;
137
+ const initialSource = () => initialText ??= initial.slice();
138
+ if (records.some(row => Object.hasOwn(row, 'authored'))) authoredHistory(initialSource(), rows.map(({record, splices}) => ({...record, splices})));
139
+ const replayedMetadata = index.replayMetadata(initialMetadata);
140
+ if (!replayedMetadata || ['filename', 'docKind'].some(key => replayedMetadata[key] !== metadata[key])) return historyFailure('history_invalid');
141
+ const resolvedRecords = Object.freeze(rows.map(({record, splices}) => record.splices === splices ? record : Object.freeze({...record, splices})));
142
+ const prepared = {ok: true, source, records: resolvedRecords, get initialSource() { return initialSource(); }, initial, metadata, initialMetadata, effects, rows, byId,
143
+ revision, earliest, suppliedComplete, earliestRevision, complete: suppliedComplete && complete && earliestRevision === 0};
144
+ preparedHistories = new WeakMap([[records, prepared]]);
145
+ return prepared;
147
146
  } catch (_) { return historyFailure('history_invalid'); }
148
147
  }
149
148
 
@@ -152,23 +151,17 @@ export function replayHistory(input) {
152
151
  if (!prepared.ok) return prepared;
153
152
  const {source, initialSource, metadata, initialMetadata, revision, earliestRevision, complete, rows, effects} = prepared;
154
153
  const acts = rows.filter(({record}) => !effects.pictures.has(record.transaction.id)).map(({record}) => historyAct(record, effects));
155
- return {ok: true, source, initialSource, metadata, initialMetadata, revision, earliestRevision, complete, acts,
154
+ return {ok: true, source, initialSource, metadata: {...metadata}, initialMetadata: {...initialMetadata}, revision, earliestRevision, complete, acts,
156
155
  effectiveActIds: effects.effectiveActIds.slice(), groups: groupHistoryActs(acts)};
157
156
  }
158
157
 
159
158
  export function sourceBefore(input, actId) {
160
159
  const prepared = prepareHistory(input);
161
160
  if (!prepared.ok) return prepared;
162
- let source = prepared.source, metadata = {...prepared.metadata};
163
- for (let index = prepared.rows.length - 1; index >= 0; index--) {
164
- const {record, splices} = prepared.rows[index];
165
- source = historyPreviousSource(source, splices);
166
- if (source === null) return historyFailure('history_invalid');
167
- metadata = _rapierTransformMetadata(metadata, prepared.rows[index].metadata, true);
168
- if (record.transaction.id === actId) return {ok: true, source, metadata, revision: record.transaction.baseRevision,
169
- act: historyAct(record, prepared.effects), complete: prepared.complete, earliestRevision: prepared.earliestRevision};
170
- }
171
- return historyFailure('act_unavailable');
161
+ const row = prepared.byId.get(actId);
162
+ if (!row) return historyFailure('act_unavailable');
163
+ return {ok: true, source: row.beforeSource.slice(), metadata: {...row.beforeMetadata}, revision: row.record.transaction.baseRevision,
164
+ act: historyAct(row.record, prepared.effects), complete: prepared.complete, earliestRevision: prepared.earliestRevision};
172
165
  }
173
166
 
174
167
  function historyRangeBefore(range, splice) {
@@ -186,21 +179,23 @@ function historyRangeBefore(range, splice) {
186
179
 
187
180
  function projectHistoryRange(prepared, from, to, actId) {
188
181
  if (!historyBoundary(prepared.source, from) || !historyBoundary(prepared.source, to) || to < from) return historyFailure('range_invalid');
189
- let text = prepared.source, range = {from, to}, before = null;
182
+ let range = {from, to}, before = null;
190
183
  const acts = [];
191
184
  for (let index = prepared.rows.length - 1; index >= 0; index--) {
192
- const {record, splices} = prepared.rows[index], after = {...range, source: text.slice(range.from, range.to)};
185
+ const {record, splices, beforeSource, afterSource} = prepared.rows[index], afterRange = range;
193
186
  let touched = false;
194
187
  for (let at = splices.length - 1; at >= 0; at--) {
195
188
  const mapped = historyRangeBefore(range, splices[at]); touched ||= mapped.touched;
196
189
  range = {from: mapped.from, to: mapped.to};
197
190
  }
198
- text = historyPreviousSource(text, splices);
199
- if (text === null) return historyFailure('history_invalid');
200
- const previous = {...range, source: text.slice(range.from, range.to)};
201
- if (record.transaction.id === actId) before = previous;
202
- if (!prepared.effects.pictures.has(record.transaction.id) && (touched || record.metadata || prepared.effects.references.get(record.transaction.id)?.length))
203
- acts.push({...historyAct(record, prepared.effects), before: previous, after});
191
+ const include = !prepared.effects.pictures.has(record.transaction.id) &&
192
+ (touched || record.metadata || prepared.effects.references.get(record.transaction.id)?.length);
193
+ if (include || record.transaction.id === actId) {
194
+ const previous = {...range, source: beforeSource.slice(range.from, range.to)};
195
+ if (record.transaction.id === actId) before = previous;
196
+ if (include) acts.push({...historyAct(record, prepared.effects), before: previous,
197
+ after: {...afterRange, source: afterSource.slice(afterRange.from, afterRange.to)}});
198
+ }
204
199
  }
205
200
  if (actId != null && !before) return historyFailure('act_unavailable');
206
201
  acts.reverse();
@@ -220,10 +215,47 @@ export function historyProjections(input, ranges) {
220
215
  if (!prepared.ok) return prepared;
221
216
  if (!Array.isArray(ranges)) return historyFailure('range_invalid');
222
217
  return {ok: true, projections: ranges.map(({from = 0, to = prepared.source.length, actId} = {}) =>
223
- structuredClone(projectHistoryRange(prepared, from, to, actId)))};
218
+ projectHistoryRange(prepared, from, to, actId))};
219
+ }
220
+
221
+ // Block intervals stay ordered under reverse splices. A fixed balanced tree can
222
+ // shift an untouched suffix lazily and visit only intervals crossing the edit.
223
+ function historyBlockIndex(projected) {
224
+ const items = projected.map(block => ({block, range: {from: block.from, to: block.to}, shift: 0}))
225
+ .sort((a, b) => a.range.from - b.range.from);
226
+ const refresh = node => {
227
+ node.minFrom = node.left?.minFrom ?? node.range.from;
228
+ node.maxTo = Math.max(node.range.to, node.left?.maxTo ?? -1, node.right?.maxTo ?? -1);
229
+ };
230
+ const build = (from, to, parent = null) => {
231
+ if (from === to) return null;
232
+ const mid = Math.floor((from + to) / 2), node = items[mid];
233
+ node.parent = parent; node.left = build(from, mid, node); node.right = build(mid + 1, to, node);
234
+ refresh(node); return node;
235
+ };
236
+ const root = build(0, items.length);
237
+ const shift = (node, delta) => {
238
+ if (!node || !delta) return;
239
+ node.range = {from: node.range.from + delta, to: node.range.to + delta};
240
+ node.minFrom += delta; node.maxTo += delta; node.shift += delta;
241
+ };
242
+ const move = (node, splice, touch) => {
243
+ if (!node || node.maxTo < splice.pos) return;
244
+ const end = splice.pos + splice.inserted.length;
245
+ if (node.minFrom > end) { shift(node, splice.removed.length - splice.inserted.length); return; }
246
+ if (node.shift) { shift(node.left, node.shift); shift(node.right, node.shift); node.shift = 0; }
247
+ const mapped = historyRangeBefore(node.range, splice);
248
+ if (mapped.touched) touch(node);
249
+ node.range = {from: mapped.from, to: mapped.to};
250
+ move(node.left, splice, touch); move(node.right, splice, touch); refresh(node);
251
+ };
252
+ return {move: (splice, touch) => move(root, splice, touch), range(node) {
253
+ let delta = 0;
254
+ for (let parent = node.parent; parent; parent = parent.parent) delta += parent.shift;
255
+ return {from: node.range.from + delta, to: node.range.to + delta};
256
+ }};
224
257
  }
225
258
 
226
- // Project all reading places in one replay. Typing only invalidates this transient view.
227
259
  export function historyPlaces(input, blocks) {
228
260
  const prepared = prepareHistory(input);
229
261
  if (!prepared.ok) return prepared;
@@ -231,47 +263,43 @@ export function historyPlaces(input, blocks) {
231
263
  !historyBoundary(prepared.source, block.from) || !historyBoundary(prepared.source, block.to) || block.to < block.from))
232
264
  return historyFailure('range_invalid');
233
265
  const projected = blocks.map(block => ({id: block.id, from: block.from, to: block.to,
234
- source: prepared.source.slice(block.from, block.to), range: {from: block.from, to: block.to}, acts: []}));
235
- let text = prepared.source;
266
+ source: prepared.source.slice(block.from, block.to), acts: []}));
267
+ const ranges = historyBlockIndex(projected);
236
268
  for (let index = prepared.rows.length - 1; index >= 0; index--) {
237
- const {record, splices} = prepared.rows[index], before = historyPreviousSource(text, splices);
238
- if (before === null) return historyFailure('history_invalid');
239
- for (const block of projected) {
240
- const after = {...block.range, source: text.slice(block.range.from, block.range.to)};
241
- let touched = false;
242
- for (let at = splices.length - 1; at >= 0; at--) {
243
- const mapped = historyRangeBefore(block.range, splices[at]);
244
- block.range = {from: mapped.from, to: mapped.to}; touched ||= mapped.touched;
245
- }
246
- const previous = {...block.range, source: before.slice(block.range.from, block.range.to)};
247
- if (touched && previous.source !== after.source && !prepared.effects.pictures.has(record.transaction.id)) block.acts.push({...historyAct(record), before: previous, after});
248
- }
249
- text = before;
250
- }
251
- const acts = prepared.rows.filter(({record}) => !prepared.effects.pictures.has(record.transaction.id)).map(({record}) => historyAct(record));
252
- const present = new Map(), effects = new Map();
253
- for (const act of acts) {
254
- const changes = [];
255
- const set = (id, value) => {
256
- const prior = present.get(id) === true;
257
- present.set(id, value); changes.push({id, before: prior, after: value});
258
- };
259
- if (act.reapplies) {
260
- for (const change of effects.get(act.reapplies) || [{id: act.reapplies, after: true}]) set(change.id, change.after);
261
- } else {
262
- const ids = act.sourceTransactionIds || (act.sourceTransactionId ? [act.sourceTransactionId] : act.reverts ? [act.reverts] : []);
263
- for (const id of ids) {
264
- for (const change of (effects.get(id) || [{id, before: false, after: true}]).slice().reverse()) {
265
- if (present.get(change.id) === change.after) set(change.id, change.before);
269
+ const {record, splices, beforeSource, afterSource} = prepared.rows[index], touched = new Map();
270
+ for (let at = splices.length - 1; at >= 0; at--) ranges.move(splices[at], node => {
271
+ if (touched.has(node) || prepared.effects.pictures.has(record.transaction.id)) return;
272
+ // Earlier splices in this reverse walk only shifted this interval. Recover
273
+ // its coordinates at the record's after-state before recording the first touch.
274
+ let after = {...node.range};
275
+ for (let prior = at + 1; prior < splices.length; prior++) {
276
+ const row = splices[prior];
277
+ if (after.from >= row.pos + row.removed.length) {
278
+ const delta = row.inserted.length - row.removed.length;
279
+ after = {from: after.from + delta, to: after.to + delta};
266
280
  }
267
281
  }
282
+ touched.set(node, after);
283
+ });
284
+ for (const [node, afterRange] of touched) {
285
+ const beforeRange = ranges.range(node), previous = {...beforeRange, source: beforeSource.slice(beforeRange.from, beforeRange.to)},
286
+ after = {...afterRange, source: afterSource.slice(afterRange.from, afterRange.to)};
287
+ if (previous.source !== after.source) node.block.acts.push({...historyAct(record), before: previous, after});
268
288
  }
269
- set(act.id, true); effects.set(act.id, changes);
270
289
  }
271
- for (const block of projected) { block.acts.reverse(); delete block.range; }
290
+
291
+ const acts = prepared.rows.filter(({record}) => !prepared.effects.pictures.has(record.transaction.id)).map(({record}) => historyAct(record));
292
+ // Native Redo consumes its Undo as navigation while keeping that receipt in
293
+ // the reading history. All material withdrawals use the canonical blockers,
294
+ // including independent inverses holding the same original act suppressed.
295
+ const reversed = new Set();
296
+ for (const {record} of prepared.rows) if (prepared.effects.active.has(record.transaction.id) && !record.transaction.reapplies)
297
+ for (const id of prepared.effects.references.get(record.transaction.id) || []) reversed.add(id);
298
+
299
+ for (const block of projected) block.acts.reverse();
272
300
  return {ok: true, source: prepared.source, initialSource: prepared.initialSource, revision: prepared.revision,
273
301
  earliestRevision: prepared.earliestRevision, complete: prepared.complete, acts, blocks: projected,
274
- reversed: acts.filter(act => present.get(act.id) !== true).map(act => act.id)};
302
+ reversed: acts.filter(act => reversed.has(act.id)).map(act => act.id)};
275
303
  }
276
304
 
277
305
  // Runs retain only exact replacement ancestry. A later replacement owns an ambiguous hunk whole.
@@ -282,26 +310,26 @@ export function selectiveUndo(input, actIds, options = {}) {
282
310
  if (!options || typeof options !== 'object' || Array.isArray(options) ||
283
311
  (options.keepHunk !== undefined && typeof options.keepHunk !== 'function') ||
284
312
  (options.splitSplice !== undefined && typeof options.splitSplice !== 'function')) return historyFailure('act_invalid');
285
- const requested = new Set(actIds), available = new Set(prepared.rows.map(({record}) => record.transaction.id));
313
+ const requested = new Set(actIds), available = prepared.byId;
286
314
  if ([...requested].some(id => !available.has(id))) return historyFailure('act_unavailable');
287
315
  const selected = new Set([...requested].filter(id => prepared.effects.active.has(id)));
288
- const records = prepared.rows.map(({record, splices}) => ({...record, splices}));
316
+ const records = prepared.records;
289
317
  let inverseId = 'planned-inverse'; while (available.has(inverseId)) inverseId += ':';
290
318
  const inverse = {splices: [], transaction: {id: inverseId, sourceTransactionIds: [...selected]}};
291
319
  const projectedEffects = selected.size ? _rapierHistoryEffects([...records, inverse]) : prepared.effects;
292
320
  const afterMetadata = _rapierMetadataState(prepared.initialMetadata, records, projectedEffects);
293
321
  if (!afterMetadata) return historyFailure('history_invalid');
294
322
  const metadata = _rapierMetadataDelta(prepared.metadata, afterMetadata), effectChanged = selected.size > 0;
295
- const authoredEffectChanged = records.some(record => !prepared.effects.references.get(record.transaction.id)?.length &&
323
+ const sourceEffectChanged = records.some(record => record.splices.length && !prepared.effects.references.get(record.transaction.id)?.length &&
296
324
  prepared.effects.active.has(record.transaction.id) !== projectedEffects.active.has(record.transaction.id));
297
- if (!authoredEffectChanged) return {ok: true, splices: [], after: prepared.source, actIds: [...requested],
325
+ if (!sourceEffectChanged) return {ok: true, splices: [], after: prepared.source, actIds: [...requested],
298
326
  sourceTransactionIds: [...selected], metadata, afterMetadata, effectChanged, unchanged: !metadata && !effectChanged,
299
327
  superseded: [], overlaps: []};
300
328
  try {
301
329
  const transported = records.some(row => row.authored) ? authoredHistory(prepared.initialSource, records, selected) : null;
302
330
  const rows = transported ? transported.records.map(record => ({record, splices: record.splices,
303
331
  sourceIndices: transported.sourceIndices.get(record.transaction.id)})) : prepared.rows;
304
- const result = projectInverseSource({...prepared, rows}, selected, projectedEffects, options);
332
+ const result = projectInverseSource(transported ? {...prepared, rows, records: transported.records} : prepared, selected, projectedEffects, options);
305
333
  if (!result.ok) return result;
306
334
  return {...result, actIds: [...requested], sourceTransactionIds: [...selected], metadata, afterMetadata,
307
335
  effectChanged, unchanged: result.after === prepared.source && !metadata && !effectChanged};
@@ -9,6 +9,137 @@ const historyBoundary = (source, at) => Number.isSafeInteger(at) && at >= 0 && !
9
9
  !(at > 0 && at < source.length && (source.charCodeAt(at - 1) & 0xFC00) === 0xD800 &&
10
10
  (source.charCodeAt(at) & 0xFC00) === 0xDC00);
11
11
 
12
+ // Persistent AVL pieces retain original strings and coordinates. A revision costs
13
+ // only the edited paths; reading a range visits its leaves, never earlier bodies.
14
+ const sourceLeaf = (text, from = 0, to = text.length) => to > from
15
+ ? {text, from, length: to - from, height: 1} : null;
16
+ const sourceHeight = node => node?.height || 0;
17
+ const sourceNode = (left, right) => !left ? right : !right ? left
18
+ : {left, right, length: left.length + right.length, height: 1 + Math.max(left.height, right.height)};
19
+ function sourceBalance(left, right) {
20
+ if (sourceHeight(left) > sourceHeight(right) + 1) {
21
+ if (sourceHeight(left.left) >= sourceHeight(left.right)) return sourceNode(left.left, sourceNode(left.right, right));
22
+ return sourceNode(sourceNode(left.left, left.right.left), sourceNode(left.right.right, right));
23
+ }
24
+ if (sourceHeight(right) > sourceHeight(left) + 1) {
25
+ if (sourceHeight(right.right) >= sourceHeight(right.left)) return sourceNode(sourceNode(left, right.left), right.right);
26
+ return sourceNode(sourceNode(left, right.left.left), sourceNode(right.left.right, right.right));
27
+ }
28
+ return sourceNode(left, right);
29
+ }
30
+ function sourceJoin(left, right) {
31
+ if (!left || !right) return left || right;
32
+ if (left.height > right.height + 1) return sourceBalance(left.left, sourceJoin(left.right, right));
33
+ if (right.height > left.height + 1) return sourceBalance(sourceJoin(left, right.left), right.right);
34
+ return sourceNode(left, right);
35
+ }
36
+ function sourceSplit(node, at) {
37
+ if (!node || at === 0) return [null, node];
38
+ if (at === node.length) return [node, null];
39
+ if (node.text !== undefined) return [sourceLeaf(node.text, node.from, node.from + at), sourceLeaf(node.text, node.from + at, node.from + node.length)];
40
+ if (at < node.left.length) {
41
+ const [left, right] = sourceSplit(node.left, at);
42
+ return [left, sourceJoin(right, node.right)];
43
+ }
44
+ const [left, right] = sourceSplit(node.right, at - node.left.length);
45
+ return [sourceJoin(node.left, left), right];
46
+ }
47
+ function* sourceParts(node, from, to) {
48
+ if (!node || from >= to) return;
49
+ if (node.text !== undefined) {
50
+ yield node.from + from === 0 && node.from + to === node.text.length ? node.text : node.text.slice(node.from + from, node.from + to); return;
51
+ }
52
+ if (from < node.left.length) yield* sourceParts(node.left, from, Math.min(to, node.left.length));
53
+ if (to > node.left.length) yield* sourceParts(node.right, Math.max(0, from - node.left.length), to - node.left.length);
54
+ }
55
+ function* sourceLeaves(node) {
56
+ if (!node) return;
57
+ if (node.text !== undefined) { yield node; return; }
58
+ yield* sourceLeaves(node.left); yield* sourceLeaves(node.right);
59
+ }
60
+ export class HistorySource {
61
+ #root;
62
+ constructor(text, root = sourceLeaf(text)) { this.#root = root; }
63
+ static fromParts(parts) {
64
+ const leaves = Array.from(parts, text => sourceLeaf(text)).filter(Boolean);
65
+ const build = (from, to) => from === to ? null : to - from === 1 ? leaves[from]
66
+ : sourceNode(build(from, Math.floor((from + to) / 2)), build(Math.floor((from + to) / 2), to));
67
+ return new HistorySource('', build(0, leaves.length));
68
+ }
69
+ get length() { return this.#root?.length || 0; }
70
+ charCodeAt(at) {
71
+ if (at < 0 || at >= this.length) return NaN;
72
+ let node = this.#root;
73
+ while (node.text === undefined) {
74
+ if (at < node.left.length) node = node.left;
75
+ else { at -= node.left.length; node = node.right; }
76
+ }
77
+ return node.text.charCodeAt(node.from + at);
78
+ }
79
+ parts(from = 0, to = this.length) { return sourceParts(this.#root, from, to); }
80
+ slice(from = 0, to = this.length) {
81
+ const root = this.#root;
82
+ if (!root || from >= to) return '';
83
+ if (root.text !== undefined) return root.from + from === 0 && root.from + to === root.text.length
84
+ ? root.text : root.text.slice(root.from + from, root.from + to);
85
+ return Array.from(this.parts(from, to)).join('');
86
+ }
87
+ matches(from, text) {
88
+ if (from < 0 || from + text.length > this.length) return false;
89
+ let at = 0;
90
+ for (const part of this.parts(from, from + text.length)) {
91
+ if (!text.startsWith(part, at)) return false;
92
+ at += part.length;
93
+ }
94
+ return at === text.length;
95
+ }
96
+ sameParts(parts) {
97
+ const leaves = sourceLeaves(this.#root);
98
+ let leaf = leaves.next().value, offset = 0;
99
+ for (const text of parts) for (let at = 0; at < text.length;) {
100
+ if (!leaf) return false;
101
+ const length = Math.min(leaf.length - offset, text.length - at), from = leaf.from + offset;
102
+ // Untouched runs share their string and coordinates. Only changed fragments
103
+ // require a text comparison; no full-body join is needed to prove the plan.
104
+ if ((leaf.text !== text || from !== at) && leaf.text.slice(from, from + length) !== text.slice(at, at + length)) return false;
105
+ offset += length; at += length;
106
+ if (offset === leaf.length) { leaf = leaves.next().value; offset = 0; }
107
+ }
108
+ return !leaf;
109
+ }
110
+ replace(pos, length, text) {
111
+ const [left, rest] = sourceSplit(this.#root, pos), [, right] = sourceSplit(rest, length);
112
+ return new HistorySource('', sourceJoin(sourceJoin(left, sourceLeaf(text)), right));
113
+ }
114
+ // Undoing an admitted repair is observation: its after-state can join a scalar
115
+ // across the deleted run. Prove the forward boundaries on the reconstructed view.
116
+ before(splices) {
117
+ let source = this;
118
+ for (let index = splices.length - 1; index >= 0; index--) {
119
+ const row = splices[index];
120
+ if (!row || !Number.isSafeInteger(row.pos) || row.pos < 0 || Object.is(row.pos, -0) ||
121
+ typeof row.removed !== 'string' || typeof row.inserted !== 'string' ||
122
+ /[\uD800-\uDFFF]/u.test(row.removed) || /[\uD800-\uDFFF]/u.test(row.inserted) || !source.matches(row.pos, row.inserted)) return null;
123
+ source = source.replace(row.pos, row.inserted.length, row.removed);
124
+ if (!historyBoundary(source, row.pos) || !historyBoundary(source, row.pos + row.removed.length)) return null;
125
+ }
126
+ return source;
127
+ }
128
+ apply(splices, inverse = false) {
129
+ if (!Array.isArray(splices)) return null;
130
+ let source = this;
131
+ for (let index = 0; index < splices.length; index++) {
132
+ const row = splices[inverse ? splices.length - 1 - index : index];
133
+ const removed = inverse ? row?.inserted : row?.removed, inserted = inverse ? row?.removed : row?.inserted;
134
+ if (typeof removed !== 'string' || typeof inserted !== 'string' ||
135
+ /[\uD800-\uDFFF]/u.test(removed) || /[\uD800-\uDFFF]/u.test(inserted) ||
136
+ !historyBoundary(source, row.pos) || !historyBoundary(source, row.pos + removed.length) || !source.matches(row.pos, removed)) return null;
137
+ source = source.replace(row.pos, removed.length, inserted);
138
+ }
139
+ return source;
140
+ }
141
+ }
142
+
12
143
  function minimalHistorySplice(before, after, pos = 0) {
13
144
  let first = 0, a = before.length, b = after.length;
14
145
  while (first < a && first < b && before[first] === after[first]) first++;
@@ -36,14 +167,18 @@ function historyHunks(row) {
36
167
  // Rows are already proved source placements, optionally carrying sourceIndices from
37
168
  // causal projection. Multiple inverse blockers can change history with no source edit.
38
169
  export function projectInverseSource(prepared, selected, projectedEffects, options = {}) {
39
- const records = prepared.rows.map(({record, splices}) => ({...record, splices}));
170
+ const records = prepared.records || prepared.rows.map(({record, splices}) => record.splices === splices ? record : {...record, splices});
40
171
  if (!records.some(record => !prepared.effects.references.get(record.transaction.id)?.length &&
41
172
  prepared.effects.active.has(record.transaction.id) !== projectedEffects.active.has(record.transaction.id)))
42
173
  return {ok: true, splices: [], after: prepared.source, superseded: [], overlaps: []};
43
174
  try {
44
175
  let pieces = prepared.initialSource ? [{text: prepared.initialSource, owner: null, lineage: []}] : [];
45
176
  const all = [], byId = new Map(), byAct = new Map(), processed = [], keptInverseAncestors = new Set();
46
- const textOf = rows => rows.map(row => row.text || '').join('');
177
+ const texts = rows => rows.map(row => row.text || '');
178
+ const textOf = rows => texts(rows).join('');
179
+ const lengthOf = (rows, to = rows.length) => { let size = 0; for (let i = 0; i < to; i++) size += rows[i].text?.length || 0; return size; };
180
+ const sourceOf = rows => HistorySource.fromParts(texts(rows));
181
+ const replays = (before, after, splices) => sourceOf(before).apply(splices)?.sameParts(texts(after)) === true;
47
182
  const boundary = pos => {
48
183
  let offset = 0;
49
184
  for (let index = 0; index < pieces.length; index++) {
@@ -68,20 +203,24 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
68
203
  };
69
204
  const owns = (piece, hunk) => piece === hunk.marker || piece.owner === hunk.id ||
70
205
  piece.restoredBy?.includes(hunk.id) || hunk.restoredMarkers?.has(piece);
71
- const effectiveBefore = (hunk, effects, visiting = new Set()) => {
72
- if (visiting.has(hunk.id)) return null;
73
- visiting.add(hunk.id); let text = '';
74
- for (const piece of hunk.before) {
206
+ const effectiveBefore = (hunk, effects) => {
207
+ const active = new Set([hunk.id]), pending = [{hunk, at: 0, parts: []}];
208
+ while (pending.length) {
209
+ const frame = pending[pending.length - 1];
210
+ if (frame.at === frame.hunk.before.length) {
211
+ const text = frame.parts.join(''); pending.pop(); active.delete(frame.hunk.id);
212
+ if (!pending.length) return text;
213
+ pending[pending.length - 1].parts.push(text); continue;
214
+ }
215
+ const piece = frame.hunk.before[frame.at++];
75
216
  if (!piece.text) continue;
76
217
  const owner = byId.get(piece.owner);
77
218
  if (owner && !effects.active.has(owner.actId) && !owner.kept) {
78
- if (piece.text !== owner.inserted) return null;
79
- const prior = effectiveBefore(owner, effects, new Set(visiting));
80
- if (prior === null) return null;
81
- text += prior;
82
- } else text += piece.text;
219
+ if (piece.text !== owner.inserted || active.has(owner.id)) return null;
220
+ active.add(owner.id); pending.push({hunk: owner, at: 0, parts: []});
221
+ } else frame.parts.push(piece.text);
83
222
  }
84
- return text;
223
+ return null;
85
224
  };
86
225
  const restoreHunk = hunk => {
87
226
  if (!pieces.includes(hunk.marker)) throw Error('reactivation');
@@ -111,9 +250,9 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
111
250
  hunk.active = true; delete hunk.undoneBy;
112
251
  continue;
113
252
  }
114
- const pos = textOf(pieces.slice(0, marker)).length, current = textOf(pieces);
253
+ const pos = lengthOf(pieces, marker);
115
254
  const removed = effectiveBefore(hunk, effects);
116
- if (removed === null || current.slice(pos, pos + removed.length) !== removed) throw Error('reactivation');
255
+ if (removed === null || !sourceOf(pieces).matches(pos, removed)) throw Error('reactivation');
117
256
  boundary(pos + removed.length);
118
257
  const start = boundary(pos), end = boundary(pos + removed.length);
119
258
  const at = start.last, count = Math.max(0, end.first - at);
@@ -160,7 +299,7 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
160
299
  if (first < 0) { restored = null; break; }
161
300
  let last = first;
162
301
  for (let at = first; at < restored.length; at++) if (owns(restored[at], target)) last = at;
163
- const pos = textOf(restored.slice(0, first)).length;
302
+ const pos = lengthOf(restored, first);
164
303
  const before = restored.slice(first, last + 1), replacement = [];
165
304
  for (const piece of before) {
166
305
  if (piece === target.marker) replacement.push(...target.before);
@@ -189,7 +328,7 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
189
328
  restored.splice(first, last + 1 - first, hunk.marker, ...insertion);
190
329
  inverses.push(hunk); priorSplices?.push({pos, removed: hunk.removed, inserted: hunk.inserted});
191
330
  }
192
- if (restored && textOf(restored) === _rapierTransformSplices(textOf(pieces), splices)) {
331
+ if (restored && replays(pieces, restored, splices)) {
193
332
  pieces = restored;
194
333
  for (const target of targets) { target.active = false; target.undoneBy = tx.id; }
195
334
  for (const piece of pieces) if (piece.mark) {
@@ -209,7 +348,7 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
209
348
  const before = pieces, priorStates = all.map(hunk => [hunk, hunk.active, hunk.undoneBy]); pieces = pieces.slice();
210
349
  try {
211
350
  reactivate(activated, current);
212
- if (textOf(pieces) === _rapierTransformSplices(textOf(before), splices)) continue;
351
+ if (replays(before, pieces, splices)) continue;
213
352
  } catch (_) {}
214
353
  pieces = before;
215
354
  for (const [hunk, active, undoneBy] of priorStates) { hunk.active = active; hunk.undoneBy = undoneBy; }
@@ -270,7 +409,7 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
270
409
  }
271
410
  }
272
411
  }
273
- if (textOf(pieces) !== prepared.source) throw Error('head');
412
+ if (!new HistorySource(prepared.source).sameParts(texts(pieces))) throw Error('head');
274
413
  const origins = new Map();
275
414
  let offset = 0;
276
415
  for (const piece of pieces) if (piece.text) { origins.set(piece, offset); offset += piece.text.length; }
@@ -310,7 +449,7 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
310
449
  const removed = prepared.source.slice(cursor);
311
450
  if (removed !== inserted) changes.push(minimalHistorySplice(removed, inserted, cursor));
312
451
  const splices = changes.length <= 64 ? changes.reverse() : [minimalHistorySplice(prepared.source, after)];
313
- if (_rapierTransformSplices(prepared.source, splices) !== after) throw Error('plan');
452
+ if (!new HistorySource(prepared.source).apply(splices)?.sameParts([after])) throw Error('plan');
314
453
  return {ok: true, splices, after, superseded, overlaps: superseded.filter(row => row.reason === 'later_edit')};
315
454
  } catch (_) { return historyFailure('history_invalid'); }
316
455
  }