rapier-html 1.2.40 → 1.2.42

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.42",
4
4
  "path": "dist/rapier.html",
5
- "bytes": 5923993,
6
- "sha256": "b0fc95b2633a468feba1c5dc1d7e59d49bc147ff48ae50c2eaa891296eacf23e",
7
- "builtAt": "2026-10-24T09:33:09.207Z",
5
+ "bytes": 5828319,
6
+ "sha256": "12f1cc548ff4e0d95a105b0170ce2f40aed8989cc58a4937f43eacb05581833c",
7
+ "builtAt": "2026-10-24T21:57:08.348Z",
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.42 notes.md`, then open `notes.rapier.html` in any browser.
11
+ Install: `npm install rapier-html@1.2.42`.
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.42 notes.md # the editor, notes.md inside it, written beside the file
30
+ npx -- rapier-html@1.2.42 notes.md --view draw # opens on Draw: sketch and paint, the document behind it
31
+ npx -- rapier-html@1.2.42 notes.md --include draw,math # carries Draw and Math (default: what the document needs)
32
+ npx -- rapier-html@1.2.42 notes.md --drawing sketch.svg # carries a drawing, opened on Draw over the document
33
+ npx -- rapier-html@1.2.42 revised.md --compare original.md # opens showing what changed
34
+ npx -- rapier-html@1.2.42 notes.md --return "$RETURN_URL" --return-expires-at "$RETURN_EXPIRES_AT" # Send back
35
+ npx -- rapier-html@1.2.42 notes.md out.html # a named output
36
+ npm install rapier-html@1.2.42 # 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,65 @@ 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
+ export function* canonicalRowsParts(rows, depth = 0) {
114
+ const {array, keys} = fields(rows, depth, new Set());
115
+ if (!array) { yield* canonicalParts(rows, depth); return; }
116
+ yield '[';
117
+ for (let i = 0; i < keys.length; i++) { if (i) yield ','; yield canonicalRow(rows[keys[i]], depth + 1); }
118
+ yield ']';
119
+ }
67
120
  export function exactKeys(value, keys) {
68
121
  return value && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length === keys.length && keys.every(key => Object.hasOwn(value, key));
69
122
  }
@@ -4,7 +4,7 @@ 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';
@@ -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,10 @@ 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);
154
173
  function* produceLedger({text, metadata, records, documentAuthority, revision, root, complete = false, earlier = records} = {}, native = false) {
155
174
  if (!_rapierValidMetadata(metadata)) throw fault('document metadata required');
156
175
  metadata = {...metadata};
@@ -162,10 +181,13 @@ function* produceLedger({text, metadata, records, documentAuthority, revision, r
162
181
  const expanded = [];
163
182
  for (const {record, splices: rows, metadata: effect} of prepared.rows) {
164
183
  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})),
184
+ const held = expandedRows.get(record);
185
+ if (held && sameSplices(held.rows, rows) && sameEffect(held.effect, effect)) { expanded.push(held.row); yield; continue; }
186
+ expanded.push(ownRow({transaction: plainData(record.transaction), splices: rows.map(row => ({pos: row.pos, removed: row.removed, inserted: row.inserted})),
166
187
  beforeHash: record.beforeHash, afterHash: record.afterHash, ...(Object.hasOwn(record, 'authored') ? {authored: plainData(record.authored)} : {}), ...(effect ? {metadata: plainData(effect)} : {}),
167
188
  ...(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)})});
189
+ ...(record.derivedCommentIndex == null ? {} : {derivedCommentIndex: typeof record.derivedCommentIndex === 'string' ? record.derivedCommentIndex : plainData(record.derivedCommentIndex)})}));
190
+ expandedRows.set(record, {row: expanded.at(-1), rows, effect});
169
191
  yield;
170
192
  }
171
193
  const headBytes = bytes;
@@ -199,8 +221,9 @@ function* produceLedger({text, metadata, records, documentAuthority, revision, r
199
221
  }
200
222
  const replayedMetadata = prepared.replayMetadata(startMetadata);
201
223
  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,
224
+ const ledger = {...payload, sha256: yield* checksum(envelopeParts(payload), native)};
225
+ ownLedger(ledger);
226
+ proofs.set(ledger, {shell: Array.from(envelopeParts({...ledger, start: {...ledger.start, text: ''}})).join(''), startText,
204
227
  text, metadata: {...metadata}, root, revision, prepared: _rapierPrepareHistoryRecords(ledger.records),
205
228
  source: new HistorySource(text)});
206
229
  return {ledger, contentRoot, utf8Bytes: headBytes};
@@ -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)} : {})};
@@ -76,7 +76,7 @@ function _rapierValidAuthored(origin) {
76
76
  !/[\uD800-\uDFFF]/u.test(row.removed) && !/[\uD800-\uDFFF]/u.test(row.inserted));
77
77
  }
78
78
 
79
- function _rapierRecordIndex(records) {
79
+ function _rapierRecordIndex(records, shared = false) {
80
80
  const byId = new Map(), splices = new Map(), metadata = new Map();
81
81
  for (const record of records) {
82
82
  const id = record?.transaction?.id;
@@ -100,9 +100,12 @@ function _rapierRecordIndex(records) {
100
100
  }
101
101
  cache.set(current, value);
102
102
  for (let i = path.length - 1; i >= 0; i--) {
103
- if (path[i].transaction.reverts && value != null) value = effects
104
- ? Object.fromEntries(Object.entries(value).map(([key, pair]) => [key, {before: pair.after, after: pair.before}]))
105
- : value.slice().reverse().map(row => ({pos: row.pos, removed: row.inserted, inserted: row.removed}));
103
+ if (path[i].transaction.reverts && value != null) {
104
+ value = effects
105
+ ? Object.fromEntries(Object.entries(value).map(([key, pair]) => [key, {before: pair.after, after: pair.before}]))
106
+ : value.slice().reverse().map(row => ({pos: row.pos, removed: row.inserted, inserted: row.removed}));
107
+ if (shared) { for (const row of Object.values(value)) Object.freeze(row); Object.freeze(value); }
108
+ }
106
109
  cache.set(path[i], value);
107
110
  }
108
111
  return value;
@@ -110,10 +113,27 @@ function _rapierRecordIndex(records) {
110
113
  return {byId, splices: record => resolve(record, false), metadata: record => resolve(record, true)};
111
114
  }
112
115
 
116
+ const _rapierLedgerIndexes = new WeakMap();
117
+ function _rapierLedgerIndex(ledger) {
118
+ const records = ledger || [];
119
+ if (!Array.isArray(records)) return _rapierRecordIndex(records);
120
+ const held = _rapierLedgerIndexes.get(records);
121
+ if (held && held.records.length === records.length && held.records.at(-1) === records.at(-1) &&
122
+ (held.checked || held.records.every((record, at) => record === records[at]))) {
123
+ if (!held.checked) { held.checked = true; queueMicrotask(() => { held.checked = false; }); }
124
+ return held.index;
125
+ }
126
+ if (!records.every(record => record && typeof record === 'object' && Object.isFrozen(record) && Object.isFrozen(record.transaction ?? {})))
127
+ return _rapierRecordIndex(records);
128
+ const index = _rapierRecordIndex(records, true);
129
+ _rapierLedgerIndexes.set(records, {records: records.slice(), index});
130
+ return index;
131
+ }
132
+
113
133
  function _rapierRecordSplices(entry, ledger) {
114
134
  try {
115
135
  if (entry && Array.isArray(entry.splices)) return entry.splices;
116
- return _rapierRecordIndex(ledger || []).splices(entry);
136
+ return _rapierLedgerIndex(ledger).splices(entry);
117
137
  } catch (_) { return null; }
118
138
  }
119
139
 
@@ -163,7 +183,7 @@ function _rapierRecordMetadata(entry, ledger) {
163
183
  try {
164
184
  if (Object.hasOwn(entry, 'metadata')) return _rapierValidMetadataEffect(entry.metadata) ? entry.metadata : undefined;
165
185
  if (Array.isArray(entry.splices)) return null;
166
- return _rapierRecordIndex(ledger || []).metadata(entry);
186
+ return _rapierLedgerIndex(ledger).metadata(entry);
167
187
  } catch (_) { return undefined; }
168
188
  }
169
189
 
@@ -507,9 +527,10 @@ const _rapierPrepareHistoryRecords = (() => {
507
527
 
508
528
  const _rapierJournalRecord = (() => {
509
529
  const copies = new WeakMap();
530
+ const fixed = value => { if (value && typeof value === 'object') { for (const key of Object.keys(value)) fixed(value[key]); Object.freeze(value); } return value; };
510
531
  return record => {
511
532
  let copy = copies.get(record);
512
- if (!copy) { copy = JSON.parse(JSON.stringify(record)); copies.set(record, copy); }
533
+ if (!copy) { copy = fixed(JSON.parse(JSON.stringify(record))); copies.set(record, copy); }
513
534
  return copy;
514
535
  };
515
536
  })();
@@ -215,7 +215,7 @@ export function willTransition(beforeText, afterText, splices, markerSplices = s
215
215
  return null;
216
216
  }
217
217
 
218
- export function evaluateWill(beforeText, splices) {
218
+ export function evaluateWill(beforeText, splices, {restores = null} = {}) {
219
219
  const invalid = rule => ({outcome: 'invalid', reason: 'precondition', rule});
220
220
  if (typeof beforeText !== 'string' || !beforeText.isWellFormed()) return invalid('invalid_unicode');
221
221
  if (!Array.isArray(splices)) return invalid('malformed_splice');
@@ -231,7 +231,7 @@ export function evaluateWill(beforeText, splices) {
231
231
  if (afterText.slice(row.pos, end) !== row.removed) return invalid('source_mismatch');
232
232
  afterText = afterText.slice(0, row.pos) + row.inserted + afterText.slice(end);
233
233
  }
234
- const refusal = splices.length && willTransition(beforeText, afterText, splices);
234
+ const refusal = splices.length && willTransition(beforeText, afterText, splices, splices, {restores});
235
235
  return refusal ? {outcome: 'refused', reason: 'document_law', ...refusal} : {outcome: 'applied'};
236
236
  }
237
237