rapier-html 1.2.40 → 1.2.43

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 CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "package": "rapier-html",
3
- "version": "1.2.40",
3
+ "version": "1.2.43",
4
4
  "path": "dist/rapier.html",
5
- "bytes": 5923993,
6
- "sha256": "b0fc95b2633a468feba1c5dc1d7e59d49bc147ff48ae50c2eaa891296eacf23e",
7
- "builtAt": "2026-10-24T09:33:09.207Z",
5
+ "bytes": 5832352,
6
+ "sha256": "233e582745310b79d57c2fc26435ed02c00d657f6c1f7d8b676b2d678522e3cd",
7
+ "builtAt": "2026-10-25T00:05:03.516Z",
8
8
  "node": "v22.23.4",
9
9
  "canonical": true,
10
10
  "packing": "zopfli",
@@ -31,15 +31,15 @@
31
31
  "delivery": "built-in",
32
32
  "kind": "files",
33
33
  "element": "rapier-plugin-mermaid",
34
- "bytes": 677968,
35
- "packedBytes": 224584,
34
+ "bytes": 720263,
35
+ "packedBytes": 240280,
36
36
  "files": [
37
37
  {
38
38
  "id": "rapier-mermaid-core",
39
- "name": "pele-1.6.0.js",
40
- "version": "1.6.0",
41
- "bytes": 677968,
42
- "sri": "wvZ8yi9fl1G4U3tYTRguH7dHJxCgl7Rgu/Bb+sz+ip80toql8Bc0N2aLeJCCtUMS"
39
+ "name": "pele-1.9.0-native-1.js",
40
+ "version": "1.9.0-native-1",
41
+ "bytes": 720263,
42
+ "sri": "zPE63bYrlp1ZqlwxePk6pclPq6VoxZ7o769Lz109vEo8OEPtvOl1GPrMDj0OhtVB"
43
43
  }
44
44
  ]
45
45
  },
@@ -48,15 +48,15 @@
48
48
  "delivery": "built-in",
49
49
  "kind": "files",
50
50
  "element": "rapier-plugin-mermaid-zenuml",
51
- "bytes": 1888111,
52
- "packedBytes": 249770,
51
+ "bytes": 420431,
52
+ "packedBytes": 110835,
53
53
  "files": [
54
54
  {
55
55
  "id": "rapier-mermaid-zenuml",
56
- "name": "rapier-zenuml-4.5.0-3.js",
57
- "version": "4.5.0-3",
58
- "bytes": 1888111,
59
- "sri": "/JAYBQPgTaslQtUck91RnxcqXI8V8T0iYa+dRwc9StJhd0MPJGLTOCLj9aNDRHDG"
56
+ "name": "rapier-zenuml-svg-4.5.0-1.js",
57
+ "version": "4.5.0-svg-1",
58
+ "bytes": 420431,
59
+ "sri": "Dma7QZLWfZx3gWM0ie8JpjR4KwOXqndKcIJywkdxJ2shsdw/gDRFByjGlUv+eNoQ"
60
60
  }
61
61
  ]
62
62
  },
@@ -145,15 +145,15 @@
145
145
  "delivery": "built-in",
146
146
  "kind": "files",
147
147
  "element": "rapier-plugin-font-subset",
148
- "bytes": 1214779,
149
- "packedBytes": 441276,
148
+ "bytes": 1207570,
149
+ "packedBytes": 439575,
150
150
  "files": [
151
151
  {
152
152
  "id": "rapier-font-subset",
153
153
  "name": "rapier-font-subset-3.js",
154
154
  "version": "3",
155
- "bytes": 1214779,
156
- "sri": "iAjccOClLDb549FPCJnflzsNIlLa4G4N/SRm/GnIvhcUyQd0COW2672AA2Mldcnz"
155
+ "bytes": 1207570,
156
+ "sri": "eyBreJCziFoHQdGSPtvd0S2IRcHna191kq83/ps/rafZwWWd6reInBALyPdLQmdL"
157
157
  }
158
158
  ]
159
159
  }
package/README.md CHANGED
@@ -7,8 +7,8 @@ Turn Markdown into one offline HTML file that contains the full Rapier editor.
7
7
  <img src="https://raw.githubusercontent.com/jackskip22/rapier-plugins/main/assets/write-390-light.png" alt="The Rapier editor on a phone: a launch plan with an agent's change marked" width="320">
8
8
  </picture>
9
9
 
10
- Try it: `npx rapier-html@1.2.40 notes.md`, then open `notes.rapier.html` in any browser.
11
- Install: `npm install rapier-html@1.2.40`.
10
+ Try it: `npx rapier-html@1.2.43 notes.md`, then open `notes.rapier.html` in any browser.
11
+ Install: `npm install rapier-html@1.2.43`.
12
12
 
13
13
  It opens on a phone or computer, with no account. People read, edit, draw and paint; agents hand over documents, show
14
14
  what changed and get the edited copy back.
@@ -26,14 +26,14 @@ The file needs no hosted workspace.
26
26
  Needs Node 22 or newer.
27
27
 
28
28
  ```sh
29
- npx -- rapier-html@1.2.40 notes.md # the editor, notes.md inside it, written beside the file
30
- npx -- rapier-html@1.2.40 notes.md --view draw # opens on Draw: sketch and paint, the document behind it
31
- npx -- rapier-html@1.2.40 notes.md --include draw,math # carries Draw and Math (default: what the document needs)
32
- npx -- rapier-html@1.2.40 notes.md --drawing sketch.svg # carries a drawing, opened on Draw over the document
33
- npx -- rapier-html@1.2.40 revised.md --compare original.md # opens showing what changed
34
- npx -- rapier-html@1.2.40 notes.md --return "$RETURN_URL" --return-expires-at "$RETURN_EXPIRES_AT" # Send back
35
- npx -- rapier-html@1.2.40 notes.md out.html # a named output
36
- npm install rapier-html@1.2.40 # as a library: writePage, unwrap
29
+ npx -- rapier-html@1.2.43 notes.md # the editor, notes.md inside it, written beside the file
30
+ npx -- rapier-html@1.2.43 notes.md --view draw # opens on Draw: sketch and paint, the document behind it
31
+ npx -- rapier-html@1.2.43 notes.md --include draw,math # carries Draw and Math (default: what the document needs)
32
+ npx -- rapier-html@1.2.43 notes.md --drawing sketch.svg # carries a drawing, opened on Draw over the document
33
+ npx -- rapier-html@1.2.43 revised.md --compare original.md # opens showing what changed
34
+ npx -- rapier-html@1.2.43 notes.md --return "$RETURN_URL" --return-expires-at "$RETURN_EXPIRES_AT" # Send back
35
+ npx -- rapier-html@1.2.43 notes.md out.html # a named output
36
+ npm install rapier-html@1.2.43 # as a library: writePage, unwrap
37
37
  ```
38
38
 
39
39
  The first `--` is for npm, so `--help` reaches the command. It never overwrites a file.
@@ -44,11 +44,13 @@ function* quoted(text, whole = true) {
44
44
  }
45
45
  yield '"';
46
46
  }
47
- function* dataParts(value, depth, active, sorted) {
47
+ function* dataParts(value, depth, active, sorted, known = null) {
48
48
  if (typeof value === 'string') { yield* quoted(value); return; }
49
49
  if (value === null || typeof value === 'boolean' || typeof value === 'number' && Number.isFinite(value) && !Object.is(value, -0)) {
50
50
  yield JSON.stringify(value); return;
51
51
  }
52
+ const text = known?.(value, depth);
53
+ if (text !== undefined) { yield text; return; }
52
54
  const {array, descriptors, keys} = fields(value, depth, active, sorted);
53
55
  active.add(value);
54
56
  yield array ? '[' : '{';
@@ -56,14 +58,69 @@ function* dataParts(value, depth, active, sorted) {
56
58
  if (i) yield ',';
57
59
  const key = keys[i];
58
60
  if (!array) { yield* quoted(key, false); yield ':'; }
59
- yield* dataParts(descriptors[key].value, depth + 1, active, sorted);
61
+ yield* dataParts(descriptors[key].value, depth + 1, active, sorted, known);
60
62
  }
61
63
  yield array ? ']' : '}';
62
64
  active.delete(value);
63
65
  }
64
66
  export function canonicalParts(value, depth = 0, active = new Set()) { return dataParts(value, depth, active, true); }
65
67
  export function jsonParts(value) { return dataParts(value, 0, new Set(), false); }
68
+ export function jsonPartsKnown(value, known) { return dataParts(value, 0, new Set(), false, known); }
69
+ export function jsonTextAt(value, depth) { return Array.from(dataParts(value, depth, new Set(), false)).join(''); }
66
70
  export function canonicalJSON(value, depth = 0, active = new Set()) { return Array.from(canonicalParts(value, depth, active)).join(''); }
71
+ const ownedRows = new WeakSet(), rowCanonical = new Map();
72
+ let rowCanonicalChars = 0;
73
+ const ROW_CANONICAL_LIMIT = 32 * 1024 * 1024;
74
+ function freezeData(value) {
75
+ if (!value || typeof value !== 'object' || ownedRows.has(value) || ownedLedgers.has(value)) return value;
76
+ for (const key of Object.keys(value)) freezeData(value[key]);
77
+ return Object.freeze(value);
78
+ }
79
+ export function ownRow(row) { if (!ownedRows.has(row)) { freezeData(row); ownedRows.add(row); } return row; }
80
+ const ownedLedgers = new WeakSet();
81
+ export function ownLedger(ledger) {
82
+ if (ledger && typeof ledger === 'object' && !ownedLedgers.has(ledger)) { freezeData(ledger); ownedLedgers.add(ledger); }
83
+ return ledger;
84
+ }
85
+ export function isOwnedLedger(ledger) { return !!ledger && ownedLedgers.has(ledger); }
86
+ const rowText = new WeakMap();
87
+ function canonicalRow(row, depth) {
88
+ if (!ownedRows.has(row)) return canonicalJSON(row, depth);
89
+ const own = rowText.get(row);
90
+ if (own?.depth === depth) return own.json;
91
+ const json = keyedRow(row, depth);
92
+ rowText.set(row, {depth, json});
93
+ return json;
94
+ }
95
+ function keyedRow(row, depth) {
96
+ const id = typeof row.transaction?.id === 'string' ? row.transaction.id : null, seen = JSON.stringify(row);
97
+ const hit = id === null ? null : rowCanonical.get(id);
98
+ if (hit && hit.depth === depth && hit.seen.includes(seen)) return hit.json;
99
+ const json = canonicalJSON(row, depth);
100
+ if (id !== null && 2 * seen.length + json.length <= ROW_CANONICAL_LIMIT / 4) {
101
+ const size = entry => entry.seen.reduce((sum, text) => sum + text.length, entry.json.length);
102
+ const seenAlso = hit && hit.depth === depth && hit.json === json ? [hit.seen.at(-1), seen] : [seen];
103
+ if (hit) { rowCanonical.delete(id); rowCanonicalChars -= size(hit); }
104
+ const entry = {depth, seen: seenAlso, json};
105
+ rowCanonical.set(id, entry); rowCanonicalChars += size(entry);
106
+ for (const [key, value] of rowCanonical) {
107
+ if (rowCanonicalChars <= ROW_CANONICAL_LIMIT) break;
108
+ rowCanonical.delete(key); rowCanonicalChars -= size(value);
109
+ }
110
+ }
111
+ return json;
112
+ }
113
+ const plainRowArrays = new WeakSet();
114
+ export function* canonicalRowsParts(rows, depth = 0) {
115
+ if (!(depth <= 64 && plainRowArrays.has(rows))) {
116
+ const {array} = fields(rows, depth, new Set());
117
+ if (!array) { yield* canonicalParts(rows, depth); return; }
118
+ if (Object.isFrozen(rows) && depth <= 64) plainRowArrays.add(rows);
119
+ }
120
+ yield '[';
121
+ for (let i = 0; i < rows.length; i++) { if (i) yield ','; yield canonicalRow(rows[i], depth + 1); }
122
+ yield ']';
123
+ }
67
124
  export function exactKeys(value, keys) {
68
125
  return value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
69
126
  }
@@ -4,16 +4,16 @@ import {pictureHistorySplices} from './history.mjs';
4
4
  import {HistorySource} from './source.mjs';
5
5
  import {authoredHistory} from './transport.mjs';
6
6
  import {textRoot, rootAfter, sha256, sha256PartsAsync, createSHA256} from './hash.mjs';
7
- import {canonicalJSON, canonicalParts, plainData, documentText, utf8Length, exactKeys, nonnegative, fault} from './data.mjs';
7
+ import {canonicalJSON, canonicalParts, canonicalRowsParts, ownRow, ownLedger, isOwnedLedger, plainData, documentText, utf8Length, exactKeys, nonnegative, fault} from './data.mjs';
8
8
  import {diff} from './diff/diff.mjs';
9
9
  import {minimalHistorySplice} from './inverse-source.mjs';
10
10
  import {evaluateWill} from './will.mjs';
11
11
  export {textRoot, rootAfter};
12
12
  const rootShape = value => typeof value === 'string' && /^\d+:[a-z0-9]+:[a-z0-9]+$/.test(value);
13
- function* checkedRecords(ledger, prepared) {
13
+ function* checkedRecords(ledger, prepared, from = null) {
14
14
  if (!prepared.ok || ledger.records.length !== prepared.records.length) throw fault('record chain differs');
15
- let root = ledger.start.root, revision = ledger.start.revision;
16
- for (let index = 0; index < prepared.records.length; index++) {
15
+ let root = from ? from.root : ledger.start.root, revision = from ? from.revision : ledger.start.revision;
16
+ for (let index = from ? from.index : 0; index < prepared.records.length; index++) {
17
17
  const record = ledger.records[index];
18
18
  if (!Array.isArray(record.splices) || !prepared.validRecord(index,
19
19
  ledger.documentAuthority, 64 * 1024 * 1024, utf8Length, !ledger.complete) ||
@@ -27,32 +27,47 @@ 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
+ function* envelopeParts(envelope) {
31
+ const keys = Object.keys(envelope).sort();
32
+ yield '{';
33
+ for (let i = 0; i < keys.length; i++) {
34
+ if (i) yield ',';
35
+ yield JSON.stringify(keys[i]); yield ':';
36
+ yield* keys[i] === 'records' ? canonicalRowsParts(envelope.records, 1) : canonicalParts(envelope[keys[i]], 1, new Set());
37
+ }
38
+ yield '}';
39
+ }
30
40
  const proofs = new WeakMap();
41
+ function ownedRows(ledger) {
42
+ if (ledger && Array.isArray(ledger.records)) for (const row of ledger.records) if (row && typeof row === 'object') ownRow(row);
43
+ return ledger;
44
+ }
31
45
  function copiedLedger(input, proof) {
32
- if (!proof || !input || typeof input !== 'object') return plainData(input);
46
+ if (!proof || !input || typeof input !== 'object') return ownedRows(plainData(input));
33
47
  const fields = Object.getOwnPropertyDescriptors(input), start = fields.start;
34
- if (!start || !Object.hasOwn(start, 'value') || !start.value || typeof start.value !== 'object') return plainData(input);
48
+ if (!start || !Object.hasOwn(start, 'value') || !start.value || typeof start.value !== 'object') return ownedRows(plainData(input));
35
49
  const parts = Object.getOwnPropertyDescriptors(start.value), text = parts.text;
36
- if (!text || !Object.hasOwn(text, 'value') || text.value !== proof.startText) return plainData(input);
50
+ if (!text || !Object.hasOwn(text, 'value') || text.value !== proof.startText) return ownedRows(plainData(input));
37
51
  const shell = Object.create(Object.getPrototypeOf(input), {...fields, start: {...start,
38
52
  value: Object.create(Object.getPrototypeOf(start.value), {...parts, text: {...text, value: ''}})}});
39
- const ledger = plainData(shell);
53
+ const ledger = ownedRows(plainData(shell));
40
54
  ledger.start.text = proof.startText;
41
55
  return ledger;
42
56
  }
43
57
  function ledgerAdmission(input) {
44
58
  const held = input && typeof input === 'object' ? proofs.get(input) : undefined;
59
+ if (held && isOwnedLedger(input)) return {ledger: input, shell: held.shell, proof: held};
45
60
  const ledger = copiedLedger(input, held);
46
61
  if (!exactKeys(ledger, ['format','documentAuthority','start','records','head','complete','sha256']) || ledger.format !== 'rapier-ledger/2' ||
47
62
  typeof ledger.documentAuthority !== 'string' || !ledger.documentAuthority || ledger.documentAuthority.length > 256 ||
48
63
  typeof ledger.complete !== 'boolean' || !Array.isArray(ledger.records) ||
49
64
  !exactKeys(ledger.start, ['text','revision','root','sha256','metadata']) || !exactKeys(ledger.head, ['revision','root','sha256','metadata'])) throw fault('unsupported or invalid envelope');
50
- const shell = canonicalJSON({...ledger, start: {...ledger.start, text: ''}});
65
+ const shell = Array.from(envelopeParts({...ledger, start: {...ledger.start, text: ''}})).join('');
51
66
  const proof = held?.shell === shell && held.startText === ledger.start.text ? held : null;
52
67
  return {ledger, shell, proof};
53
68
  }
54
69
  function ledgerResult(ledger, proof, expectedText, expectedMetadata) {
55
- proofs.set(ledger, proof);
70
+ proofs.set(ledger, proof); ownLedger(ledger);
56
71
  const {text, metadata, root, revision, prepared, source} = proof;
57
72
  if (expectedMetadata !== undefined && (!_rapierValidMetadata(expectedMetadata) || canonicalJSON(metadata) !== canonicalJSON(expectedMetadata)))
58
73
  throw fault('metadata does not describe this document');
@@ -67,7 +82,7 @@ function readLedgerState(input, expectedText, expectedMetadata) {
67
82
  }
68
83
  function* proveLedger(ledger, native = false) {
69
84
  const {sha256: claimed, ...payload} = ledger;
70
- if (claimed !== (yield* checksum(canonicalParts(payload), native))) throw fault('envelope checksum differs');
85
+ if (claimed !== (yield* checksum(envelopeParts(payload), native))) throw fault('envelope checksum differs');
71
86
  let text = ledger.start.text, root = ledger.start.root, revision = ledger.start.revision;
72
87
  const startFacts = yield* sourceFacts(text, native);
73
88
  if (!_rapierValidMetadata(ledger.start.metadata) || !_rapierValidMetadata(ledger.head.metadata)) throw fault('invalid document metadata');
@@ -151,6 +166,11 @@ function* sourceFacts(text, native = false) {
151
166
  return {utf8Bytes: bytes, sha256: digest, contentRoot: text.length + ':' +
152
167
  (fnv >>> 0).toString(36) + ':' + (((b << 16) | a) >>> 0).toString(36)};
153
168
  }
169
+ const expandedRows = new WeakMap();
170
+ const sameSplices = (a, b) => a === b || a.length === b.length &&
171
+ a.every((row, i) => row.pos === b[i].pos && row.removed === b[i].removed && row.inserted === b[i].inserted);
172
+ const sameEffect = (a, b) => a === b || !!a && !!b && canonicalJSON(a) === canonicalJSON(b);
173
+ let recentLedgers = [];
154
174
  function* produceLedger({text, metadata, records, documentAuthority, revision, root, complete = false, earlier = records} = {}, native = false) {
155
175
  if (!_rapierValidMetadata(metadata)) throw fault('document metadata required');
156
176
  metadata = {...metadata};
@@ -162,23 +182,40 @@ function* produceLedger({text, metadata, records, documentAuthority, revision, r
162
182
  const expanded = [];
163
183
  for (const {record, splices: rows, metadata: effect} of prepared.rows) {
164
184
  if (!rows || effect === undefined) throw fault('navigation target was not retained');
165
- expanded.push({transaction: plainData(record.transaction), splices: rows.map(row => ({pos: row.pos, removed: row.removed, inserted: row.inserted})),
185
+ const held = expandedRows.get(record);
186
+ if (held && sameSplices(held.rows, rows) && sameEffect(held.effect, effect)) { expanded.push(held.row); yield; continue; }
187
+ expanded.push(ownRow({transaction: plainData(record.transaction), splices: rows.map(row => ({pos: row.pos, removed: row.removed, inserted: row.inserted})),
166
188
  beforeHash: record.beforeHash, afterHash: record.afterHash, ...(Object.hasOwn(record, 'authored') ? {authored: plainData(record.authored)} : {}), ...(effect ? {metadata: plainData(effect)} : {}),
167
189
  ...(record.changeSet && Object.hasOwn(record.changeSet, 'label') ? {changeSet: {label: typeof record.changeSet.label === 'string' ? record.changeSet.label : plainData(record.changeSet.label)}} : {}),
168
- ...(record.derivedCommentIndex == null ? {} : {derivedCommentIndex: typeof record.derivedCommentIndex === 'string' ? record.derivedCommentIndex : plainData(record.derivedCommentIndex)})});
190
+ ...(record.derivedCommentIndex == null ? {} : {derivedCommentIndex: typeof record.derivedCommentIndex === 'string' ? record.derivedCommentIndex : plainData(record.derivedCommentIndex)})}));
191
+ expandedRows.set(record, {row: expanded.at(-1), rows, effect});
169
192
  yield;
170
193
  }
171
194
  const headBytes = bytes;
195
+ let base = recentLedgers.find(row => row.rows.length <= expanded.length && row.rows.every((value, at) => value === expanded[at]));
172
196
  let startSource = new HistorySource(text), startMetadata = {...metadata};
173
- for (let i = expanded.length - 1; i >= 0; i--) {
197
+ for (let i = expanded.length - 1; i >= (base ? base.rows.length : 0); i--) {
174
198
  startSource = startSource.apply(expanded[i].splices, true);
175
- startMetadata = _rapierTransformMetadata(startMetadata, expanded[i].metadata, true);
199
+ if (expanded[i].metadata) startMetadata = _rapierTransformMetadata(startMetadata, expanded[i].metadata, true);
176
200
  if (startSource === null || !startMetadata) throw fault('history does not reverse from this document');
177
201
  for (const row of expanded[i].splices) bytes += utf8Length(row.removed) - utf8Length(row.inserted);
178
202
  if (bytes > 64 * 1024 * 1024) throw fault('document exceeds the editor text limit');
179
203
  yield;
180
204
  }
181
- const startText = startSource.slice();
205
+ if (base && (bytes !== base.headBytes || startSource.length !== base.headText.length || !startSource.matches(0, base.headText) ||
206
+ canonicalJSON(startMetadata) !== base.headMetadata)) {
207
+ base = null; bytes = headBytes; startSource = new HistorySource(text); startMetadata = {...metadata};
208
+ for (let i = expanded.length - 1; i >= 0; i--) {
209
+ startSource = startSource.apply(expanded[i].splices, true);
210
+ if (expanded[i].metadata) startMetadata = _rapierTransformMetadata(startMetadata, expanded[i].metadata, true);
211
+ if (startSource === null || !startMetadata) throw fault('history does not reverse from this document');
212
+ for (const row of expanded[i].splices) bytes += utf8Length(row.removed) - utf8Length(row.inserted);
213
+ if (bytes > 64 * 1024 * 1024) throw fault('document exceeds the editor text limit');
214
+ yield;
215
+ }
216
+ }
217
+ if (base) { startMetadata = {...base.startMetadata}; bytes = base.startBytes; }
218
+ const startText = base ? base.startText : startSource.slice();
182
219
  const unchangedText = expanded.every(row => !row.splices.length);
183
220
  const contentRoot = headFacts.contentRoot;
184
221
  revision ??= expanded.at(-1)?.transaction.revision ?? 0;
@@ -187,20 +224,26 @@ function* produceLedger({text, metadata, records, documentAuthority, revision, r
187
224
  const startRevision = expanded[0]?.transaction.baseRevision ?? revision, startRoot = expanded[0]?.beforeHash ?? root;
188
225
  if (typeof documentAuthority !== 'string' || !documentAuthority || documentAuthority.length > 256) throw fault('unsupported or invalid envelope');
189
226
  if (!nonnegative(startRevision) || !rootShape(startRoot)) throw fault('invalid start');
190
- const startFacts = unchangedText ? headFacts : yield* sourceFacts(startText, native);
227
+ const startFacts = unchangedText ? headFacts : base ? base.startFacts : yield* sourceFacts(startText, native);
191
228
  if (startRevision === 0 && startRoot !== startFacts.contentRoot) throw fault('invalid start');
192
229
  const headSHA = headFacts.sha256, startSHA = startFacts.sha256;
193
230
  const payload = {format: 'rapier-ledger/2', documentAuthority,
194
231
  start: {text: startText, revision: startRevision, root: startRoot, sha256: startSHA, metadata: startMetadata},
195
232
  records: expanded, head: {revision, root, sha256: headSHA, metadata: {...metadata}}, complete: complete === true && startRevision === 0 && expanded.every((row, i) => !i || row.transaction.baseRevision === expanded[i - 1].transaction.revision)};
196
- for (const _record of checkedRecords(payload, prepared)) yield;
233
+ const from = base && base.documentAuthority === documentAuthority && base.complete === payload.complete &&
234
+ base.startRoot === startRoot && base.startRevision === startRevision ? {index: base.rows.length, root: base.chainRoot, revision: base.chainRevision} : null;
235
+ for (const _record of checkedRecords(payload, prepared, from)) yield;
197
236
  if (expanded.some(row => Object.hasOwn(row, 'authored'))) {
198
237
  try { authoredHistory(startText, expanded); } catch (_) { throw fault('authored origin differs'); }
199
238
  }
200
239
  const replayedMetadata = prepared.replayMetadata(startMetadata);
201
240
  if (!replayedMetadata || canonicalJSON(replayedMetadata) !== canonicalJSON(metadata)) throw fault('metadata does not describe this document');
202
- const ledger = {...payload, sha256: yield* checksum(canonicalParts(payload), native)};
203
- proofs.set(ledger, {shell: canonicalJSON({...ledger, start: {...ledger.start, text: ''}}), startText,
241
+ const ledger = {...payload, sha256: yield* checksum(envelopeParts(payload), native)};
242
+ recentLedgers = [{rows: expanded, headText: text, headMetadata: canonicalJSON(metadata), headBytes, startText, startMetadata: {...startMetadata},
243
+ startFacts, startBytes: bytes, documentAuthority, complete: payload.complete, startRoot, startRevision, chainRoot: payload.head.root,
244
+ chainRevision: payload.head.revision}, ...recentLedgers.filter(row => row.rows[0] !== expanded[0] && row.documentAuthority === documentAuthority)].slice(0, 4);
245
+ ownLedger(ledger);
246
+ proofs.set(ledger, {shell: Array.from(envelopeParts({...ledger, start: {...ledger.start, text: ''}})).join(''), startText,
204
247
  text, metadata: {...metadata}, root, revision, prepared: _rapierPrepareHistoryRecords(ledger.records),
205
248
  source: new HistorySource(text)});
206
249
  return {ledger, contentRoot, utf8Bytes: headBytes};
@@ -250,7 +293,7 @@ function historyNavigation(ledger, text, metadata, prepared, source) {
250
293
  let traversed = source, traversedMetadata = {...metadata};
251
294
  for (let i = cursor - 1; i >= 0; i--) {
252
295
  traversed = step(traversed, branch[i], false);
253
- traversedMetadata = _rapierTransformMetadata(traversedMetadata, byId.get(branch[i]).metadata, true);
296
+ if (byId.get(branch[i]).metadata) traversedMetadata = _rapierTransformMetadata(traversedMetadata, byId.get(branch[i]).metadata, true);
254
297
  if (traversed === null || !traversedMetadata) throw fault('undo branch does not reverse');
255
298
  }
256
299
  if (ledger.complete) {
@@ -261,7 +304,7 @@ function historyNavigation(ledger, text, metadata, prepared, source) {
261
304
  traversed = source; traversedMetadata = {...metadata};
262
305
  for (let i = cursor; i < branch.length; i++) {
263
306
  traversed = step(traversed, branch[i], true);
264
- traversedMetadata = _rapierTransformMetadata(traversedMetadata, byId.get(branch[i]).metadata);
307
+ if (byId.get(branch[i]).metadata) traversedMetadata = _rapierTransformMetadata(traversedMetadata, byId.get(branch[i]).metadata);
265
308
  if (traversed === null || !traversedMetadata) throw fault('redo branch does not replay');
266
309
  }
267
310
  if (ledger.complete && pictures.size) {
@@ -363,7 +363,7 @@ export function selectiveUndo(input, actIds, options = {}) {
363
363
  const result = projectInverseSource(transported ? {...prepared, rows, records: transported.records} : prepared, selected, projectedEffects, options);
364
364
  if (!result.ok) return result;
365
365
  if (options.actor?.kind === 'agent' && prepared.metadata.docKind === 'markdown' &&
366
- evaluateWill(prepared.source, result.splices).outcome !== 'applied')
366
+ evaluateWill(prepared.source, result.splices, {restores: result.splices}).outcome !== 'applied')
367
367
  return historyFailure('will_refused');
368
368
  return {...result, actIds: [...requested], sourceTransactionIds: [...selected], metadata, afterMetadata,
369
369
  effectChanged, unchanged: result.after === prepared.source && !metadata && !effectChanged};
@@ -32,6 +32,8 @@ function historyHunks(row) {
32
32
  }
33
33
 
34
34
 
35
+ const differentActors = (a, b) => !!a && !!b && (a.kind !== b.kind || a.id !== b.id);
36
+
35
37
  export function projectInverseSource(prepared, selected, projectedEffects, options = {}) {
36
38
  const records = prepared.records || prepared.rows.map(({record, splices}) => record.splices === splices ? record : {...record, splices});
37
39
  if (!options.origins && !records.some(record => !prepared.effects.references.get(record.transaction.id)?.length &&
@@ -40,6 +42,7 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
40
42
  try {
41
43
  let pieces = prepared.initialSource ? [{text: prepared.initialSource, owner: null, lineage: []}] : [];
42
44
  const all = [], byId = new Map(), byAct = new Map(), processed = [], keptInverseAncestors = new Set();
45
+ const actorOf = new Map(records.map(record => [record.transaction.id, record.transaction.actor]));
43
46
  const recordsForCover = prepared.records || prepared.rows.map(({record, splices}) => record.splices === splices ? record : {...record, splices});
44
47
  const coverGroups = new Map();
45
48
  let coverOrigins = null;
@@ -280,6 +283,12 @@ export function projectInverseSource(prepared, selected, projectedEffects, optio
280
283
  const id = tx.id + '#' + rowIndex + ':' + index;
281
284
  const lineage = new Set([id]);
282
285
  for (const piece of removed) for (const parent of piece.lineage || piece.mark?.lineage || []) lineage.add(parent);
286
+ if (!row.removed.length && !count && at > 0 && /[\p{L}\p{N}\p{M}]/u.test(row.inserted)) {
287
+ const left = pieces[at - 1], right = pieces[at];
288
+ const inner = left?.text && right?.text && left.owner === right.owner ? byId.get(left.owner) : null;
289
+ if (inner && inner.actId !== tx.id && inner.removed && differentActors(actorOf.get(inner.actId), tx.actor) && /[\p{L}\p{N}\p{M}]$/u.test(left.text) && /^[\p{L}\p{N}\p{M}]/u.test(right.text))
290
+ for (const parent of [inner.id, ...(left.lineage || [])]) lineage.add(parent);
291
+ }
283
292
  const hunk = {id, actId: tx.id, removed: row.removed, inserted: row.inserted, before: removed, derived,
284
293
  chosen, kept: selected.has(tx.id) && !chosen, lineage: [...lineage], supersededBy: new Set(), active: true,
285
294
  ...(coverIdsFor(tx.id) ? {coverIds: coverIdsFor(tx.id)} : {})};