rapier-html 1.2.3 → 1.2.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/BUILD.json +18 -18
- package/README.md +67 -87
- package/dist/ledger/LICENSE +3 -2
- package/dist/ledger/authored-origin.mjs +49 -0
- package/dist/ledger/authorship.mjs +18 -9
- package/dist/ledger/data.mjs +7 -4
- package/dist/ledger/diff/concerns.mjs +163 -0
- package/dist/ledger/diff/diff.mjs +68 -0
- package/dist/ledger/diff/index.mjs +5 -0
- package/dist/ledger/diff/matching.mjs +157 -0
- package/dist/ledger/diff/merge.mjs +226 -0
- package/dist/ledger/diff/moves.mjs +130 -0
- package/dist/ledger/diff/structure.mjs +50 -0
- package/dist/ledger/diff/tokens.mjs +72 -0
- package/dist/ledger/diff/walk.mjs +219 -0
- package/dist/ledger/format.mjs +25 -8
- package/dist/ledger/history.mjs +31 -5
- package/dist/ledger/inverse-source.mjs +112 -50
- package/dist/ledger/journal-records.mjs +24 -29
- package/dist/ledger/transport.mjs +30 -45
- package/dist/page.mjs +10 -7
- package/dist/rapier.html +140 -139
- package/dist/return-address.mjs +2 -2
- package/dist/wrap.mjs +1 -1
- package/package.json +1 -1
- package/dist/ledger/diff.mjs +0 -745
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
// Space-free scripts (Thai, Lao, Myanmar, Khmer, kana, Han) have no word separator; the platform dictionary finds words.
|
|
3
|
+
export const SPACELESS = /[\u0e00-\u0eff\u1000-\u109f\u1780-\u17ff\u3040-\u30ff\u3400-\u9fff\uf900-\ufaff]/;
|
|
4
|
+
export const words = (segmenter, text) => [...text.matchAll(/[\p{L}\p{N}\p{M}_]+/gu)].flatMap(word => SPACELESS.test(word[0])
|
|
5
|
+
? [...segmenter().segment(word[0])].map(s => [word.index + s.index, word.index + s.index + s.segment.length])
|
|
6
|
+
: [[word.index, word.index + word[0].length]]);
|
|
7
|
+
export const wordSegmenter = () => { let segmenter; return () => segmenter ||= new Intl.Segmenter('en', {granularity: 'word'}); };
|
|
8
|
+
|
|
9
|
+
// Segment only requested non-ASCII boundaries; observations never share a global document cache.
|
|
10
|
+
export function boundaries() {
|
|
11
|
+
const segments = Object.create(null);
|
|
12
|
+
let segmenter, last = [];
|
|
13
|
+
return function safe(text, at) {
|
|
14
|
+
if (!at || at === text.length) return true;
|
|
15
|
+
const a = text.charCodeAt(at - 1), b = text.charCodeAt(at);
|
|
16
|
+
if ((a & 0xfc00) === 0xd800 && (b & 0xfc00) === 0xdc00 || a === 13 && b === 10) return false;
|
|
17
|
+
if (a < 0x300 && b < 0x300) return true;
|
|
18
|
+
// Retreats probe one long cluster many times; answer inside the last cluster without the segmenter.
|
|
19
|
+
if (last[0] === text && at > last[1] && at < last[2]) return false;
|
|
20
|
+
const found = (segments[text] ||= (segmenter ||= new Intl.Segmenter('en', {granularity: 'grapheme'})).segment(text)).containing(at);
|
|
21
|
+
last = [text, found.index, found.index + found.segment.length];
|
|
22
|
+
return found.index === at;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Letters are code points: an astral letter is one letter, never two lone surrogates.
|
|
27
|
+
const pair = (text, at) => (text.charCodeAt(at - 1) & 0xfc00) === 0xd800 && (text.charCodeAt(at) & 0xfc00) === 0xdc00;
|
|
28
|
+
export const prev = (text, at) => text.slice(Math.max(0, at - 1 - pair(text, at - 1)), at), next = (text, at) => text.slice(at, at + 1 + pair(text, at + 1));
|
|
29
|
+
export const letter = c => c && /[\p{L}\p{M}\p{N}_]/u.test(c), within = (text, at) => letter(prev(text, at)) && letter(next(text, at));
|
|
30
|
+
|
|
31
|
+
// Tokens at a level: 0 parts, 1 lines, 2 words, spaces and marks, 3 characters.
|
|
32
|
+
export function tokenizer(c) {
|
|
33
|
+
const {spend, safe, segmenter, raw, aliases} = c;
|
|
34
|
+
return function tokens(text, start, end, level, parts) {
|
|
35
|
+
// Each source boundary is stored once, alongside comparison keys and optional owners.
|
|
36
|
+
const keys = [], at = [start], owners = [];
|
|
37
|
+
const out = [keys, at, owners];
|
|
38
|
+
const add = (a, b, part, value) => {
|
|
39
|
+
if (b <= a) return;
|
|
40
|
+
if (!part && owners[keys.length - 1]?.name && !/\S/.test(text.slice(a, b))) at[at.length - 1] = b;
|
|
41
|
+
else {
|
|
42
|
+
at.push(b); if (part) owners[keys.length] = part;
|
|
43
|
+
const key = part?.name ? '\0' + part.kind + '\0' + (aliases?.get(part)?.name || part.name) : value ?? text.slice(a, b);
|
|
44
|
+
keys.push(level === 1 && !raw ? key.trimEnd() : key);
|
|
45
|
+
}
|
|
46
|
+
};
|
|
47
|
+
if (parts?.length && level === 0) {
|
|
48
|
+
let at = start;
|
|
49
|
+
for (const part of parts) {
|
|
50
|
+
if (!spend()) return null;
|
|
51
|
+
if (part.start < at || part.end <= part.start || part.end > end) continue;
|
|
52
|
+
if (at < part.start) add(at, part.start);
|
|
53
|
+
add(part.start, part.end, part); at = part.end;
|
|
54
|
+
}
|
|
55
|
+
add(at, end); return out;
|
|
56
|
+
}
|
|
57
|
+
const pattern = level === 1 ? raw ? /[^\n]*\n|[^\n]+$/g : /[^\n]*\n(?:\r?\n)*|[^\n]+$/g : level === 2 ? /[\p{L}\p{M}\p{N}_]+|[^\S\r\n]+|\r\n|[^]/gu : /[^]/gu;
|
|
58
|
+
pattern.lastIndex = start;
|
|
59
|
+
let match;
|
|
60
|
+
while (pattern.lastIndex < end && (match = pattern.exec(text))) {
|
|
61
|
+
if (!spend()) return null;
|
|
62
|
+
const a = match.index, b = Math.min(end, pattern.lastIndex);
|
|
63
|
+
if (keys.length && !safe(text, a)) {
|
|
64
|
+
at[at.length - 1] = b; keys[keys.length - 1] = text.slice(at[at.length - 2], b);
|
|
65
|
+
} else if (level === 2 && b - a > 1 && SPACELESS.test(match[0])) {
|
|
66
|
+
for (const [c, d] of words(segmenter, text.slice(a, b))) { if (!spend()) return null; add(a + c, a + d); }
|
|
67
|
+
} else add(a, b, undefined, b === pattern.lastIndex ? match[0] : undefined);
|
|
68
|
+
if (b === end) break;
|
|
69
|
+
}
|
|
70
|
+
return out;
|
|
71
|
+
};
|
|
72
|
+
}
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// SPDX-License-Identifier: MIT
|
|
2
|
+
import {words, letter, within, prev, next} from './tokens.mjs';
|
|
3
|
+
|
|
4
|
+
// Exact source records for a range: lines, then words, then characters, or structural parts when supplied.
|
|
5
|
+
export function walker(c) {
|
|
6
|
+
const {before, after, spend, safe, budget, raw, chars, code, segmenter, tokens, matches} = c;
|
|
7
|
+
function record(a0, a1, b0, b1, unit, part, coarse = false) {
|
|
8
|
+
if (a1 - a0 === b1 - b0 && before.slice(a0, a1) === after.slice(b0, b1)) return;
|
|
9
|
+
// Internal records: before, after, coarse, concern, transported fragments.
|
|
10
|
+
c.result.push([[a0, a1], [b0, b1], coarse, [a0 === a1 ? 2 : b0 === b1 ? 0 : 1, part?.kind || unit, part?.name]]);
|
|
11
|
+
}
|
|
12
|
+
|
|
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
|
+
function name(from, ownersA, ownersB) {
|
|
16
|
+
for (let r = from; r < c.result.length; r++) {
|
|
17
|
+
const [[a0, a1], [b0, b1]] = c.result[r], side = a0 === a1 ? 1 : b0 === b1 ? 0 : -1;
|
|
18
|
+
if (side < 0) continue;
|
|
19
|
+
const [s0, s1] = c.result[r][side], text = side ? after : before;
|
|
20
|
+
const parts = (side ? ownersB : ownersA).filter(part => part?.name && part.start >= s0 && part.end <= s1);
|
|
21
|
+
if (!parts.length || !spend(s1 - s0)) continue;
|
|
22
|
+
let blank = text.slice(s0, parts[0].start);
|
|
23
|
+
for (let k = 1; k <= parts.length; k++) blank += text.slice(parts[k - 1].end, parts[k]?.start ?? s1);
|
|
24
|
+
if (/\S/.test(blank)) continue;
|
|
25
|
+
const rows = parts.map((part, k) => {
|
|
26
|
+
const start = k ? Math.max(parts[k - 1].end, text.lastIndexOf('\n', part.start - 1) + 1) : s0;
|
|
27
|
+
return [start, part];
|
|
28
|
+
});
|
|
29
|
+
c.result.splice(r, 1, ...rows.map(([start, part], k) => {
|
|
30
|
+
const end = rows[k + 1]?.[0] ?? s1, here = [side ? [a0, a0] : [start, end], side ? [start, end] : [b0, b0]];
|
|
31
|
+
return [here[0], here[1], false, [side ? 2 : 0, part.kind, part.name]];
|
|
32
|
+
}));
|
|
33
|
+
r += rows.length - 1;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// A search step scans at most one fixed-size source chunk.
|
|
38
|
+
function common(a, b, end, step) {
|
|
39
|
+
let n = 0;
|
|
40
|
+
while (n < end && spend()) {
|
|
41
|
+
const stop = Math.min(end, n + 256);
|
|
42
|
+
while (n < stop && before.charCodeAt(a) === after.charCodeAt(b)) { n++; a += step; b += step; }
|
|
43
|
+
if (n < stop) break;
|
|
44
|
+
}
|
|
45
|
+
return n;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function walk(a0, a1, b0, b1, level, ap, bp, context) {
|
|
49
|
+
if (a1 - a0 === b1 - b0 && before.slice(a0, a1) === after.slice(b0, b1)) return;
|
|
50
|
+
if (!spend()) { record(a0, a1, b0, b1, 'block', context, true); return; }
|
|
51
|
+
const structural = level <= 0 && level > -128 && (ap?.length || bp?.length);
|
|
52
|
+
if (!structural && raw !== 'word') {
|
|
53
|
+
let n = common(a0, b0, Math.min(a1 - a0, b1 - b0), 1);
|
|
54
|
+
while (n && (!safe(before, a0 + n) || !safe(after, b0 + n) || !raw && level < 3 && (within(before, a0 + n) || within(after, b0 + n)))) {
|
|
55
|
+
if (!spend()) { n = 0; break; } n--;
|
|
56
|
+
}
|
|
57
|
+
if (level === 1 && n) {
|
|
58
|
+
const next = Math.max(0, before.lastIndexOf('\n', a0 + n - 1) + 1 - a0);
|
|
59
|
+
spend(Math.ceil((n - next) / 256)); n = next;
|
|
60
|
+
}
|
|
61
|
+
a0 += n; b0 += n;
|
|
62
|
+
n = common(a1 - 1, b1 - 1, Math.min(a1 - a0, b1 - b0), -1);
|
|
63
|
+
while (n && (!safe(before, a1 - n) || !safe(after, b1 - n) || !raw && level < 3 && (within(before, a1 - n) || within(after, b1 - n)))) {
|
|
64
|
+
if (!spend()) { n = 0; break; } n--;
|
|
65
|
+
}
|
|
66
|
+
if (level === 1) while (n && (before[a1 - n - 1] !== '\n' || after[b1 - n - 1] !== '\n' || before[a1 - n] === '\n' || after[b1 - n] === '\n')) { if (!spend()) { n = 0; break; } n--; }
|
|
67
|
+
a1 -= n; b1 -= n;
|
|
68
|
+
if (budget.left < 0) { record(a0, a1, b0, b1, 'block', context, true); return; }
|
|
69
|
+
}
|
|
70
|
+
let unit = level >= 3 ? 'char' : level === 2 ? 'word' : 'line';
|
|
71
|
+
if (!raw && level === 2 && a1 - a0 + b1 - b0 <= 4 && [...before.slice(a0, a1), ...after.slice(b0, b1)].length <= 2 &&
|
|
72
|
+
letter(prev(before, a0) || prev(after, b0)) && letter(next(before, a1) || next(after, b1))) unit = 'char';
|
|
73
|
+
if (!structural && (a0 === a1 || b0 === b1)) { record(a0, a1, b0, b1, unit, context); return; }
|
|
74
|
+
const left = tokens(before, a0, a1, structural ? 0 : Math.max(1, level), ap);
|
|
75
|
+
const right = tokens(after, b0, b1, structural ? 0 : Math.max(1, level), bp);
|
|
76
|
+
if (!left || !right) { record(a0, a1, b0, b1, unit, context, true); return; }
|
|
77
|
+
const [a, x, ownersA] = left, [b, y, ownersB] = right, pairs = matches(a, b);
|
|
78
|
+
let ai = 0, bi = 0;
|
|
79
|
+
for (let i = 0; i <= a.length; i++) {
|
|
80
|
+
const j = i === a.length ? b.length : pairs[i];
|
|
81
|
+
if (j === undefined) continue;
|
|
82
|
+
const x0 = x[ai], x1 = x[i], y0 = y[bi], y1 = y[j];
|
|
83
|
+
if (ai < i || bi < j) {
|
|
84
|
+
if (budget.left < 0 || pairs.coarse?.has(ai)) record(x0, x1, y0, y1, unit, context, true);
|
|
85
|
+
else if (structural && (ai === i || bi === j)) {
|
|
86
|
+
for (let k = ai; k < i; k++) record(x[k], x[k + 1], y0, y0, unit, ownersA[k] || context);
|
|
87
|
+
for (let k = bi; k < j; k++) record(x1, x1, y[k], y[k + 1], unit, ownersB[k] || context);
|
|
88
|
+
} else if (structural) {
|
|
89
|
+
const p = ownersA[ai], q = ownersB[bi];
|
|
90
|
+
if (i - ai === 1 && j - bi === 1 && p && q && p.kind === q.kind) walk(x0, x1, y0, y1, level - 1, p.children, q.children, q);
|
|
91
|
+
else {
|
|
92
|
+
const from = c.result.length;
|
|
93
|
+
walk(x0, x1, y0, y1, 1, null, null, context);
|
|
94
|
+
name(from, ownersA.slice(ai, i), ownersB.slice(bi, j));
|
|
95
|
+
}
|
|
96
|
+
} else if (level < 2 && raw !== 'line') lines(x.slice(ai, i + 1), y.slice(bi, j + 1), context);
|
|
97
|
+
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
|
+
walk(x0, x1, y0, y1, 3, null, null, context);
|
|
100
|
+
} else record(x0, x1, y0, y1, unit, context);
|
|
101
|
+
}
|
|
102
|
+
if (i < a.length) {
|
|
103
|
+
const p = ownersA[i], q = ownersB[j];
|
|
104
|
+
if ((p || q || level <= 1 && !raw) && before.slice(x[i], x[i + 1]) !== after.slice(y[j], y[j + 1]))
|
|
105
|
+
walk(x[i], x[i + 1], y[j], y[j + 1], p || q ? level - 1 : 3, p?.children, q?.children, q || p || context);
|
|
106
|
+
}
|
|
107
|
+
ai = i + 1; bi = j + 1;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
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 neighbour's words, and a rewrite never reads as stray fragments.
|
|
113
|
+
function lines(x, y, context) {
|
|
114
|
+
const n = x.length - 1, m = y.length - 1;
|
|
115
|
+
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
|
+
if (!c.renames) {
|
|
118
|
+
c.renames = [[], []];
|
|
119
|
+
for (const [p, old] of c.aliases || []) if (before.slice(...old.nameAt) !== after.slice(...p.nameAt)) {
|
|
120
|
+
c.renames[0].push(old.nameAt, ...c.la.filter(l => l.key === old.key).map(l => l.at));
|
|
121
|
+
c.renames[1].push(p.nameAt, ...c.lb.filter(l => l.key === p.key).map(l => l.at));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
const weight = (side, a, b) => {
|
|
125
|
+
const counts = new Map(), text = side ? after : before;
|
|
126
|
+
for (const [c, d] of words(segmenter, text.slice(a, b))) counts.set(text.slice(a + c, a + d), (counts.get(text.slice(a + c, a + d)) || 0) + d - c);
|
|
127
|
+
return [counts, [...counts.values()].reduce((n, v) => n + v, 0), c.renames[side].reduce((n, at) => n + Math.max(0, Math.min(b, at[1]) - Math.max(a, at[0])), 0)];
|
|
128
|
+
};
|
|
129
|
+
const left = x.slice(0, -1).map((at, k) => weight(0, at, x[k + 1])), right = y.slice(0, -1).map((at, k) => weight(1, at, y[k + 1]));
|
|
130
|
+
const best = Array.from({length: n + 1}, () => new Float64Array(m + 1)), pairs = [];
|
|
131
|
+
for (let k = 1; k <= n; k++) for (let l = 1; l <= m; l++) {
|
|
132
|
+
const [a, sa, ra] = left[k - 1], [b, sb, rb] = right[l - 1];
|
|
133
|
+
let shared = sa || sb ? Math.max(ra, rb) : 0.5;
|
|
134
|
+
for (const [key, v] of a) shared += Math.min(v, b.get(key) || 0);
|
|
135
|
+
best[k][l] = Math.max(best[k - 1][l], best[k][l - 1], shared * 4 > Math.max(sa, sb) && best[k - 1][l - 1] + shared);
|
|
136
|
+
}
|
|
137
|
+
for (let k = n, l = m; k && l;) {
|
|
138
|
+
if (best[k][l] === best[k - 1][l]) k--;
|
|
139
|
+
else if (best[k][l] === best[k][l - 1]) l--;
|
|
140
|
+
else pairs.unshift([--k, --l]);
|
|
141
|
+
}
|
|
142
|
+
let k = 0, l = 0;
|
|
143
|
+
for (const [p, q] of [...pairs, [n, m]]) {
|
|
144
|
+
// A merge compares one line against one line by character, whatever they share.
|
|
145
|
+
if (raw === 'char' && p - k === 1 && q - l === 1) row(x[k], x[p], y[l], y[q], context);
|
|
146
|
+
else if (k < p || l < q) record(x[k], x[p], y[l], y[q], 'line', context);
|
|
147
|
+
if (p < n) row(x[p], x[p + 1], y[q], y[q + 1], context);
|
|
148
|
+
k = p + 1; l = q + 1;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
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
|
+
function row(a0, a1, b0, b1, context) {
|
|
154
|
+
if (raw === 'char') return walk(a0, a1, b0, b1, 3, null, null, context);
|
|
155
|
+
const cells = (text, a, b) => /^[ \t]*\|/.test(text.slice(a, b)) && [...text.slice(a, b).matchAll(/(?<!\\)\|/g)].map(m => a + m.index);
|
|
156
|
+
const p = !code && cells(before, a0, a1), q = p && cells(after, b0, b1);
|
|
157
|
+
if (!q || p.length !== q.length || !spend(p.length)) return walk(a0, a1, b0, b1, 2, null, null, context);
|
|
158
|
+
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
|
+
}
|
|
160
|
+
|
|
161
|
+
// Surviving source pieces keep the occurrence witnessed by the host.
|
|
162
|
+
function operations(rows) {
|
|
163
|
+
if (!Array.isArray(rows)) return null;
|
|
164
|
+
const pieces = [[0, before.length]];
|
|
165
|
+
let length = before.length, steps = 1e7;
|
|
166
|
+
const verify = n => {
|
|
167
|
+
if ((steps -= n) >= 0) return true;
|
|
168
|
+
budget.left = -1; return false;
|
|
169
|
+
};
|
|
170
|
+
const split = at => {
|
|
171
|
+
let position = 0;
|
|
172
|
+
for (let i = 0; i < pieces.length; i++) {
|
|
173
|
+
const [start, end, text] = pieces[i], size = end - start;
|
|
174
|
+
if (position === at) return i;
|
|
175
|
+
if (position + size > at) {
|
|
176
|
+
const n = at - position;
|
|
177
|
+
pieces.splice(i, 1, [start, start + n, text], [start + n, end, text]);
|
|
178
|
+
return i + 1;
|
|
179
|
+
}
|
|
180
|
+
position += size;
|
|
181
|
+
}
|
|
182
|
+
return pieces.length;
|
|
183
|
+
};
|
|
184
|
+
for (const row of rows) {
|
|
185
|
+
if (!row || !Number.isSafeInteger(row.pos) || typeof row.removed !== 'string' || typeof row.inserted !== 'string' ||
|
|
186
|
+
row.pos < 0 || row.pos + row.removed.length > length || !verify(pieces.length * 2 + 1)) return null;
|
|
187
|
+
const left = split(row.pos), right = split(row.pos + row.removed.length);
|
|
188
|
+
let at = 0;
|
|
189
|
+
for (let i = left; i < right; i++) {
|
|
190
|
+
const [start, end, text = before] = pieces[i];
|
|
191
|
+
if (!row.removed.startsWith(text.slice(start, end), at)) return null;
|
|
192
|
+
at += end - start;
|
|
193
|
+
}
|
|
194
|
+
if (row.removed === row.inserted) continue;
|
|
195
|
+
pieces.splice(left, right - left, [0, row.inserted.length, row.inserted]);
|
|
196
|
+
length += row.inserted.length - row.removed.length;
|
|
197
|
+
}
|
|
198
|
+
if (length !== after.length) return null;
|
|
199
|
+
const spans = [];
|
|
200
|
+
let a = 0, b = 0, at = 0;
|
|
201
|
+
for (const [start, end, text] of pieces) {
|
|
202
|
+
const size = end - start;
|
|
203
|
+
if (!verify(1) || !after.startsWith((text ?? before).slice(start, end), at)) return null;
|
|
204
|
+
if (text === undefined) {
|
|
205
|
+
let x = start, y = at, tail = end;
|
|
206
|
+
while (x < tail && (!safe(before, x) || !safe(after, y))) { x++; y++; if (!verify(1)) return null; }
|
|
207
|
+
while (tail > x && (!safe(before, tail) || !safe(after, at + tail - start))) { tail--; if (!verify(1)) return null; }
|
|
208
|
+
if (tail > x) {
|
|
209
|
+
if (a !== x || b !== y) spans.push([a, x, b, y]);
|
|
210
|
+
a = tail; b = at + tail - start;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
at += size;
|
|
214
|
+
}
|
|
215
|
+
if (a !== before.length || b !== after.length) spans.push([a, before.length, b, after.length]);
|
|
216
|
+
return spans;
|
|
217
|
+
}
|
|
218
|
+
return {record, name, walk, operations};
|
|
219
|
+
}
|
package/dist/ledger/format.mjs
CHANGED
|
@@ -27,12 +27,34 @@ function* checkedRecords(ledger, prepared) {
|
|
|
27
27
|
}
|
|
28
28
|
if (ledger.head.revision !== revision || ledger.head.root !== root) throw fault('history does not describe this document');
|
|
29
29
|
}
|
|
30
|
+
// A ledger object read again is proved once: a boot reads one stored history through several records. The proof holds only
|
|
31
|
+
// while that object still carries the exact proved data (its start text compared whole, the rest as canonical JSON), so a
|
|
32
|
+
// changed byte is proved again, and refused. The proof lives as long as the object does.
|
|
33
|
+
const proofs = new WeakMap();
|
|
30
34
|
function readLedgerState(input, expectedText, expectedMetadata) {
|
|
31
35
|
const ledger = plainData(input);
|
|
36
|
+
// The identifier rapier-ledger/2 is unchanged and is not backward compatible. This reader
|
|
37
|
+
// admits an empty physical splice whose authored rows name the same replacement. A reader
|
|
38
|
+
// from before that rule rejects the chain. Mixed-version sync throws notes_history_unavailable
|
|
39
|
+
// and leaves the older store unchanged; that refusal is not a held or caughtUp result.
|
|
32
40
|
if (!exactKeys(ledger, ['format','documentAuthority','start','records','head','complete','sha256']) || ledger.format !== 'rapier-ledger/2' ||
|
|
33
41
|
typeof ledger.documentAuthority !== 'string' || !ledger.documentAuthority || ledger.documentAuthority.length > 256 ||
|
|
34
42
|
typeof ledger.complete !== 'boolean' || !Array.isArray(ledger.records) ||
|
|
35
43
|
!exactKeys(ledger.start, ['text','revision','root','sha256','metadata']) || !exactKeys(ledger.head, ['revision','root','sha256','metadata'])) throw fault('unsupported or invalid envelope');
|
|
44
|
+
const shell = canonicalJSON({...ledger, start: {...ledger.start, text: ''}});
|
|
45
|
+
let proof = typeof input === 'object' ? proofs.get(input) : undefined;
|
|
46
|
+
if (!proof || proof.shell !== shell || proof.startText !== ledger.start.text) {
|
|
47
|
+
proof = {shell, startText: ledger.start.text, ...proveLedger(ledger)};
|
|
48
|
+
if (typeof input === 'object') proofs.set(input, proof);
|
|
49
|
+
}
|
|
50
|
+
proofs.set(ledger, proof);
|
|
51
|
+
const {text, metadata, root, revision, prepared, source} = proof;
|
|
52
|
+
if (expectedMetadata !== undefined && (!_rapierValidMetadata(expectedMetadata) || canonicalJSON(metadata) !== canonicalJSON(expectedMetadata)))
|
|
53
|
+
throw fault('metadata does not describe this document');
|
|
54
|
+
if (expectedText !== undefined && text !== expectedText) throw fault('history does not describe this document');
|
|
55
|
+
return {value: {ledger, text, metadata: {...metadata}, root, revision, complete: ledger.complete}, prepared, source};
|
|
56
|
+
}
|
|
57
|
+
function proveLedger(ledger) {
|
|
36
58
|
const {sha256: claimed, ...payload} = ledger;
|
|
37
59
|
if (claimed !== digest(payload)) throw fault('envelope checksum differs');
|
|
38
60
|
let text = documentText(ledger.start.text), root = ledger.start.root, revision = ledger.start.revision;
|
|
@@ -55,12 +77,9 @@ function readLedgerState(input, expectedText, expectedMetadata) {
|
|
|
55
77
|
try { authoredHistory(ledger.start.text, ledger.records); } catch (_) { throw fault('authored origin differs'); }
|
|
56
78
|
}
|
|
57
79
|
const metadata = prepared.replayMetadata(ledger.start.metadata);
|
|
58
|
-
if (!metadata || canonicalJSON(metadata) !== canonicalJSON(ledger.head.metadata)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
if (ledger.head.revision !== revision || ledger.head.root !== root || ledger.head.sha256 !== sha256(text) ||
|
|
62
|
-
(expectedText !== undefined && text !== expectedText)) throw fault('history does not describe this document');
|
|
63
|
-
return {value: {ledger, text, metadata, root, revision, complete: ledger.complete}, prepared, source};
|
|
80
|
+
if (!metadata || canonicalJSON(metadata) !== canonicalJSON(ledger.head.metadata)) throw fault('metadata does not describe this document');
|
|
81
|
+
if (ledger.head.revision !== revision || ledger.head.root !== root || ledger.head.sha256 !== sha256(text)) throw fault('history does not describe this document');
|
|
82
|
+
return {text, metadata, root, revision, prepared, source};
|
|
64
83
|
}
|
|
65
84
|
// A stored copy may hold the head text alone. Its start is that text with every record reversed; readLedger proves it.
|
|
66
85
|
export function ledgerWithStart(input, text) {
|
|
@@ -172,9 +191,7 @@ function finish(producer) {
|
|
|
172
191
|
return step.value;
|
|
173
192
|
}
|
|
174
193
|
export function exportLedger(input) { return finish(produceLedger(input)).ledger; }
|
|
175
|
-
// Both synchronous public export and cooperative recovery use the same producer.
|
|
176
194
|
// Its returned facts were computed while admitting/hashing the exact head, never supplied.
|
|
177
|
-
export function prepareLedger(input) { return finish(produceLedger(input)); }
|
|
178
195
|
export async function prepareLedgerAsync(input, {yield: yieldWork, signal} = {}) {
|
|
179
196
|
const producer = produceLedger(input);
|
|
180
197
|
let step;
|
package/dist/ledger/history.mjs
CHANGED
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
import {_rapierTransformSplices, _rapierValidMetadata, _rapierTransformMetadata, _rapierMetadataDelta, _rapierHistoryEffects, _rapierMetadataState, _rapierPrepareHistoryRecords} from './journal-records.mjs';
|
|
4
4
|
import {projectInverseSource, HistorySource} from './inverse-source.mjs';
|
|
5
5
|
import {textRootParts, rootAfter} from './hash.mjs';
|
|
6
|
-
import {authoredHistory} from './transport.mjs';
|
|
7
|
-
import {plainData} from './data.mjs';
|
|
6
|
+
import {authoredHistory, authoredPlacements} from './transport.mjs';
|
|
7
|
+
import {plainData, canonicalJSON} from './data.mjs';
|
|
8
8
|
|
|
9
9
|
const historyFailure = reason => ({ok: false, reason});
|
|
10
10
|
const historyInteger = value => Number.isSafeInteger(value) && value >= 0 && !Object.is(value, -0);
|
|
@@ -104,8 +104,11 @@ function prepareHistory(input) {
|
|
|
104
104
|
!(tx.createdAt === null || historyInteger(tx.createdAt)) ||
|
|
105
105
|
(tx.turnId != null && (typeof tx.turnId !== 'string' || !tx.turnId || tx.turnId.length > 160))) return historyFailure('history_invalid');
|
|
106
106
|
if (tx.baseRevision !== prior) complete = false;
|
|
107
|
+
const coincidentWitness = !splices.length && record.authored && Array.isArray(record.authored.splices) &&
|
|
108
|
+
record.authored.splices.some(row => row.removed.length);
|
|
107
109
|
if (!splices || effect === undefined || (!splices.length &&
|
|
108
|
-
!Object.values(effect || {}).some(pair => pair.before !== pair.after) && !effects.references.get(tx.id)?.length
|
|
110
|
+
!Object.values(effect || {}).some(pair => pair.before !== pair.after) && !effects.references.get(tx.id)?.length &&
|
|
111
|
+
!coincidentWitness)) return historyFailure('history_unavailable');
|
|
109
112
|
const row = {record, splices, metadata: effect};
|
|
110
113
|
rows.push(row); byId.set(tx.id, row); prior = tx.revision;
|
|
111
114
|
}
|
|
@@ -293,10 +296,13 @@ export function historyPlaces(input, blocks) {
|
|
|
293
296
|
if (!slices.has(key)) slices.set(key, source.slice(from, to));
|
|
294
297
|
return slices.get(key);
|
|
295
298
|
};
|
|
299
|
+
// One record's act is read once; the blocks it touched share its fields (a whole-document delete touches every block).
|
|
300
|
+
let act = null;
|
|
296
301
|
for (const [node, afterRange] of touched) {
|
|
297
302
|
const beforeRange = ranges.range(node), previous = {...beforeRange, source: cut(beforeSource, 'b', beforeRange.from, beforeRange.to)},
|
|
298
303
|
after = {...afterRange, source: cut(afterSource, 'a', afterRange.from, afterRange.to)};
|
|
299
|
-
|
|
304
|
+
// A point (where words were deleted) has no words to compare: the act that touched it wrote there.
|
|
305
|
+
if (previous.source !== after.source || afterRange.from === afterRange.to) node.block.acts.push({...(act ||= historyAct(record)), before: previous, after});
|
|
300
306
|
}
|
|
301
307
|
}
|
|
302
308
|
|
|
@@ -334,7 +340,27 @@ export function selectiveUndo(input, actIds, options = {}) {
|
|
|
334
340
|
const metadata = _rapierMetadataDelta(prepared.metadata, afterMetadata), effectChanged = selected.size > 0;
|
|
335
341
|
const sourceEffectChanged = records.some(record => record.splices.length && !prepared.effects.references.get(record.transaction.id)?.length &&
|
|
336
342
|
prepared.effects.active.has(record.transaction.id) !== projectedEffects.active.has(record.transaction.id));
|
|
337
|
-
|
|
343
|
+
// One shared replacement is visible while any of its authors is active. That
|
|
344
|
+
// includes an author stored with no second physical splice, and an inverse
|
|
345
|
+
// whose only effect is to make such an author active again.
|
|
346
|
+
let coverVisibilityChanged = false;
|
|
347
|
+
if (!sourceEffectChanged && selected.size) {
|
|
348
|
+
let origins = null;
|
|
349
|
+
try { origins = authoredPlacements(prepared.initialSource, records); } catch { origins = null; }
|
|
350
|
+
const groups = new Map();
|
|
351
|
+
if (origins) for (const record of records) {
|
|
352
|
+
const tx = record.transaction, origin = origins.get(tx.id);
|
|
353
|
+
if (!origin?.splices.some(row => row.removed.length) || tx.reverts || tx.reapplies ||
|
|
354
|
+
tx.sourceTransactionId || tx.sourceTransactionIds?.length) continue;
|
|
355
|
+
const key = canonicalJSON({basis: origin.basis, splices: origin.splices});
|
|
356
|
+
if (!groups.has(key)) groups.set(key, []);
|
|
357
|
+
groups.get(key).push(tx.id);
|
|
358
|
+
}
|
|
359
|
+
const covered = (effects, ids) => ids.some(id => effects.active.has(id));
|
|
360
|
+
for (const ids of groups.values()) if (ids.length > 1 && covered(prepared.effects, ids) !== covered(projectedEffects, ids))
|
|
361
|
+
coverVisibilityChanged = true;
|
|
362
|
+
}
|
|
363
|
+
if (!sourceEffectChanged && !coverVisibilityChanged) return {ok: true, splices: [], after: prepared.source, actIds: [...requested],
|
|
338
364
|
sourceTransactionIds: [...selected], metadata, afterMetadata, effectChanged, unchanged: !metadata && !effectChanged,
|
|
339
365
|
superseded: [], overlaps: []};
|
|
340
366
|
try {
|