rapier-html 1.1.102 → 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.
- package/BUILD.json +16 -16
- package/README.md +10 -10
- package/dist/ledger/LICENSE +2 -2
- package/dist/ledger/carried.mjs +9 -7
- package/dist/ledger/data.mjs +73 -13
- package/dist/ledger/diff.mjs +288 -196
- package/dist/ledger/format.mjs +166 -53
- package/dist/ledger/hash.mjs +75 -18
- package/dist/ledger/history.mjs +143 -115
- package/dist/ledger/inverse-source.mjs +158 -19
- package/dist/ledger/journal-records.mjs +292 -67
- package/dist/rapier.html +42 -344
- package/package.json +1 -1
package/dist/ledger/history.mjs
CHANGED
|
@@ -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 {
|
|
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:
|
|
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.
|
|
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
|
-
//
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
|
|
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,
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
rows.push(
|
|
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
|
|
117
|
-
for (let
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
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 =
|
|
123
|
-
let
|
|
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
|
-
|
|
130
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
144
|
-
const
|
|
145
|
-
if (
|
|
146
|
-
|
|
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
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
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],
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
acts.push({...historyAct(record, prepared.effects), before: previous,
|
|
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
|
-
|
|
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),
|
|
235
|
-
|
|
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],
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
-
|
|
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 =>
|
|
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 =
|
|
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.
|
|
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
|
|
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 (!
|
|
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}) =>
|
|
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
|
|
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
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
80
|
-
|
|
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
|
|
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 =
|
|
253
|
+
const pos = lengthOf(pieces, marker);
|
|
115
254
|
const removed = effectiveBefore(hunk, effects);
|
|
116
|
-
if (removed === null ||
|
|
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 =
|
|
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 &&
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
}
|