rapier-html 1.1.89 → 1.1.93

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.1.89",
3
+ "version": "1.1.93",
4
4
  "path": "dist/rapier.html",
5
- "bytes": 2297314,
6
- "sha256": "7a59454e6a82c474335933b9dbba6494490cdb6a6c1b021bc425b5d848db63fb",
7
- "builtAt": "2026-10-10T00:06:12.965Z",
5
+ "bytes": 2328188,
6
+ "sha256": "e4ae46d69c8b724026ff9cee44952049938b28b5f002e6b3eaa55e17e0dede34",
7
+ "builtAt": "2026-10-10T18:17:09.057Z",
8
8
  "node": "v22.23.3",
9
9
  "canonical": true,
10
10
  "packing": "zopfli",
package/README.md CHANGED
@@ -1,7 +1,8 @@
1
1
  # rapier-html
2
2
 
3
- Give a person a document they can read, edit and keep: one command writes one HTML file that is the whole
4
- editor, offline, with the document inside.
3
+ Turn Markdown into one offline HTML file with the full Rapier editor. It opens in any browser on a phone or
4
+ computer, with no account. People read, edit, draw and paint watercolor. Agents deliver documents, show what
5
+ changed and receive the person's edited copy through the door. Start with `npx rapier-html@1.1.93 notes.md`.
5
6
 
6
7
  Use it for a plan to rearrange, an illustrated guide to annotate, a draft to continue writing, or a revision
7
8
  to review. Text and drawings stay editable, and the file remains usable independently of a hosted workspace.
@@ -10,32 +11,48 @@ to review. Text and drawings stay editable, and the file remains usable independ
10
11
  and nothing fetched to open, edit or save.
11
12
  - `--return` adds a one-use Send back address from the agent door: the person edits offline, presses Send back,
12
13
  and the agent reads the returned copy.
13
- - `--view draw` opens on Draw; `--propose original.md` opens on the diff of a proposed change, and the person keeps
14
- or drops each change; `--by <name>` says on that diff who proposed it and when.
14
+ - `--view draw` opens on Draw; `--compare original.md` opens the edited document showing what changed;
15
+ `--by <name>` attributes the comparison with the time the page was made.
15
16
  - `wrap` and `unwrap` carry a document into a page and out again, exactly, for programs.
16
17
 
17
18
  Node 22 or newer.
18
19
 
19
20
  ```sh
20
- npx -- rapier-html@1.1.89 notes.md # the editor, notes.md inside it, written beside the file
21
- npx -- rapier-html@1.1.89 notes.md --view draw # opens on Draw: sketch and paint, the document behind it
22
- npx -- rapier-html@1.1.89 notes.md --view notes # opens the Notes library
23
- npx -- rapier-html@1.1.89 notes.md --drawing sketch.svg # carries a drawing, opened on Draw over the document
24
- npx -- rapier-html@1.1.89 --propose original.md proposal.md # opens on the diff of a proposed change
25
- npx -- rapier-html@1.1.89 notes.md --return "$RETURN_URL" --return-expires-at "$RETURN_EXPIRES_AT" # Send back
26
- npx -- rapier-html@1.1.89 notes.md out.html # a named output
27
- npm install rapier-html@1.1.89 # as a library: wrap, unwrap
21
+ npx -- rapier-html@1.1.93 notes.md # the editor, notes.md inside it, written beside the file
22
+ npx -- rapier-html@1.1.93 notes.md --view draw # opens on Draw: sketch and paint, the document behind it
23
+ npx -- rapier-html@1.1.93 notes.md --view notes # opens the Notes library
24
+ npx -- rapier-html@1.1.93 notes.md --drawing sketch.svg # carries a drawing, opened on Draw over the document
25
+ npx -- rapier-html@1.1.93 revised.md --compare original.md # opens showing what changed
26
+ npx -- rapier-html@1.1.93 notes.md --return "$RETURN_URL" --return-expires-at "$RETURN_EXPIRES_AT" # Send back
27
+ npx -- rapier-html@1.1.93 notes.md out.html # a named output
28
+ npm install rapier-html@1.1.93 # as a library: wrap, unwrap
28
29
  ```
29
30
 
30
31
  The first `--` is for npm, so `--help` reaches the command. It never overwrites a file.
31
32
 
32
- For live shared editing, open the document through Rapier's MCP tools. Read the person's current text or
33
- drawing before revising it, show the attributed changes, and export Markdown or an offline editor when the
34
- result needs an independent copy. Send back returns an edited offline copy for inspection and review alongside
35
- the hosted document.
33
+ For live shared editing, open the document through Rapier's MCP tools. The agent edits beside the person or
34
+ while they are away. Rapier shows its presence and change marks; the person taps a change to see the before
35
+ and undoes anything. Use `comparison.present` to show a diff deliberately. Export Markdown or an offline
36
+ editor when the result needs an independent copy. Send back returns the edited copy alongside the hosted
37
+ document, preserving both.
36
38
 
37
39
  Will markers in the Markdown control agent edits: `keep`, `append`, `edit`.
38
40
 
41
+ ## Choose a Rapier workflow
42
+
43
+ Rapier is a phone-first Markdown editor for notes, diagrams, drawing and watercolor painting. It runs offline
44
+ as one HTML file on Android, Web and Windows.
45
+ Collaborate over MCP and WebMCP, use optional encrypted sync to your own Cloudflare account, or embed the
46
+ editor and lightweight reader (about 190 kB gzipped). The agent edits live; the person sees its presence and
47
+ changes, taps a change to see the before and undoes anything. Use `comparison.present` to show a diff deliberately.
48
+
49
+ 1. **Work together live.** Connect to `https://mcp.rapier.website/mcp`, then call `rapier.open` to share one document with the person.
50
+ 2. **Deliver an offline editor.** Run `npx rapier-html@1.1.93 notes.md` to put the document and editor in one HTML file ([rapier-html](https://github.com/jackskip22/rapier-plugins/tree/main/npm/rapier-html)).
51
+ 3. **Embed in your app.** Install [rapier-embed](https://github.com/jackskip22/rapier-plugins/tree/main/npm/rapier-embed); mount the editor or reader, with your app's agent over WebMCP in the editor.
52
+ 4. **Keep portable Markdown.** Use [rapier-markdown-kit](https://github.com/jackskip22/rapier-plugins/tree/main/npm/rapier-markdown-kit) for pictures, editable SVG drawings and layout in one `.md` file. Respect [Will/1](https://github.com/jackskip22/will) `keep`, `append` and `edit` regions.
53
+ 5. **Encode JPEG XL.** Install [rapier-jxl](https://github.com/jackskip22/rapier-jxl) to encode pixels, photographs and existing JPEGs in JavaScript.
54
+ 6. **Host the door.** Run [rapier-server](https://github.com/jackskip22/rapier/tree/main/server) over your own folder or S3-compatible bucket.
55
+
39
56
  ## Send an edited document back
40
57
 
41
58
  Call `document.create_return` on the Rapier agent door for the document's workspace and pass its `return_url`
@@ -50,8 +67,9 @@ document up to 25 MiB and grants no access to the agent's workspace. After the e
50
67
  Save instead; bring that file back to the assistant for a fresh return.
51
68
 
52
69
  The agent's `document.wait_for_user` returns a `returned` receipt with the `return_id`, name and time;
53
- `document.get_context` lists receipts; `document.read_context({return_id, start: 0})` reads the source,
54
- continuing at the returned `end` until `complete`. The returned copy stays separate from the workspace's
70
+ `document.observe` with the `returns` facet lists receipts. Use
71
+ `document.read({target: {kind: "return", return_id, start: 0}})` to read the source, then `cursor` alone
72
+ to continue until complete. The returned copy stays separate from the workspace's
55
73
  current document.
56
74
 
57
75
  ## As a library
@@ -69,24 +87,30 @@ console.log('words back exactly:', unwrap(page).text === words);
69
87
  await writeFile('hello.rapier.html', page, {flag: 'wx'}); // 'wx': never over an existing file
70
88
  ```
71
89
 
72
- `wrap(rapierHtml, text, name, {view, drawing, drawingName, base, baseName, return: returnURL, return_expires_at})`
73
- and `unwrap(pageHtml)`, which returns the same fields with `null` for what a page does not carry. Also exported:
90
+ `wrap(rapierHtml, text, name, {view, drawing, drawingName, base, baseName, by, at, return: returnURL, return_expires_at})`
91
+ and `unwrap(pageHtml)`, which returns the same fields plus the validated `comparisonBase` record, with `null`
92
+ for what a page does not carry. Also exported:
74
93
  `encodeCarried`, `decodeCarried`, `main`.
75
94
 
76
95
  ## How the page carries the document
77
96
 
78
97
  One `<script type="text/markdown" id="rapier-document">` block at the top of the body, never executed,
79
- read once at boot. A drawing rides in `id="rapier-drawing"`, the text a change was proposed against in
98
+ read once at boot. A drawing rides in `id="rapier-drawing"`, the original text for comparison in
80
99
  `id="rapier-base"`, the view to open on in the document block's `data-view`, the optional return URL
81
100
  in `data-return` and its exact expiry in `data-return-expires-at`. The block holds no `<`
82
101
  before `/` or `!`, no carriage return and no NUL: `\\`, `\/`, `\!`, `\r` and `\0` stand for them, one
83
102
  reversible pass each way, so a note about HTML is as safe as any other. A byte-order mark is kept as a
84
103
  fact of the file and written back on Save.
85
104
 
105
+ `comparisonBase` contains the original `text`, `sha256`, `revision`, `name`, `by` and `at`. Rewrapping retains
106
+ its original text, hash and revision; a baseline equal to the current text is omitted. It is a display
107
+ reference, never a write grant or a history record. The edited document remains the source used by Save and
108
+ Send back.
109
+
86
110
  ## Safety
87
111
 
88
112
  - It never overwrites: an existing output, the document itself included, is refused, as are unknown options, a
89
- `--view` other than `draw` or `notes`, a `--drawing` or `--propose` with no file, a return without its expiry, an
113
+ `--view` other than `draw` or `notes`, a `--drawing` or `--compare` with no file, a return without its expiry, an
90
114
  invalid expiry, an unsafe return URL and a third filename.
91
115
  - Nothing reports on the page's use. It reaches the network only when the document or the person asks: a linked
92
116
  picture or video, a plug-in on first use (maths, diagrams, PDF import, text in pictures, a letter set), or
@@ -33,7 +33,7 @@ export function readAuthorship(input, text) {
33
33
  let cursor = 0;
34
34
  for (const run of value.runs) {
35
35
  if (!Array.isArray(run) || run.length !== 5 || run[0] !== cursor || !nonnegative(run[1]) || run[1] <= cursor || run[1] > text.length ||
36
- !['human','agent','system'].includes(run[2]) || typeof run[3] !== 'string' || !run[3] || run[3].length > 160 || !nonnegative(run[4]) ||
36
+ !['human','agent','system'].includes(run[2]) || typeof run[3] !== 'string' || !run[3] || run[3].length > 160 || !(run[4] === null || nonnegative(run[4])) ||
37
37
  /[\uD800-\uDFFF]/u.test(text.slice(cursor, run[1]))) throw fault('invalid authorship run');
38
38
  cursor = run[1];
39
39
  }
@@ -41,7 +41,7 @@ export function readAuthorship(input, text) {
41
41
  return value;
42
42
  }
43
43
  // Authorship-only imports become an incomplete insertion ledger. There is no mutable blame database.
44
- export function ledgerFromAuthorship(input, text, documentAuthority) {
44
+ export function ledgerFromAuthorship(input, text, documentAuthority, metadata) {
45
45
  const value = readAuthorship(input, text), records = [];
46
46
  let current = '', root = textRoot('');
47
47
  for (const [start, end, kind, id, createdAt] of value.runs) {
@@ -55,5 +55,5 @@ export function ledgerFromAuthorship(input, text, documentAuthority) {
55
55
  reverts: null, reapplies: null, createdAt,
56
56
  }});
57
57
  }
58
- return exportLedger({text: current, records, documentAuthority, revision: records.length, root, complete: false});
58
+ return exportLedger({text: current, metadata, records, documentAuthority, revision: records.length, root, complete: false});
59
59
  }
@@ -9,10 +9,10 @@ export function encodeCarried(text) {
9
9
  export function decodeCarried(text) {
10
10
  return String(text).replace(/\\([\\/!r0])/g, (_, c) => c === '\\' ? '\\' : c === 'r' ? '\r' : c === '0' ? '\0' : c === '/' ? '</' : '<!');
11
11
  }
12
- export function validateParts(text, {ledger = null, authorship = null} = {}) {
12
+ export function validateParts(text, {ledger = null, authorship = null, metadata} = {}) {
13
13
  // A file's leading BOM is metadata, as in the editor's source intake.
14
14
  const canonical = text.charCodeAt(0) === 0xfeff ? text.slice(1) : text;
15
- const history = ledger === null ? null : readLedger(ledger, canonical).ledger;
15
+ const history = ledger === null ? null : readLedger(ledger, canonical, metadata).ledger;
16
16
  const runs = authorship === null ? null : readAuthorship(authorship, canonical);
17
17
  if (history && runs && canonicalJSON(projectAuthorship(history)) !== canonicalJSON(runs)) throw fault('authorship differs from ledger replay');
18
18
  return {ledger: history, authorship: runs};
@@ -69,19 +69,19 @@ export function readDocument(source) {
69
69
  return {text, ...validateParts(text, parts)};
70
70
  }
71
71
 
72
- // One portable baseline. A digest is integrity evidence, not authentication of the named proposer.
72
+ // One portable baseline. A digest is integrity evidence, not authentication of the named author.
73
73
  export function readBase(value) {
74
74
  if (!value || typeof value !== 'object' || typeof value.text !== 'string' ||
75
75
  typeof value.sha256 !== 'string' || !/^[a-f0-9]{64}$/.test(value.sha256) || sha256(value.text) !== value.sha256)
76
- throw fault('proposal base SHA-256 differs from its text');
76
+ throw fault('comparison base SHA-256 differs from its text');
77
77
  if (typeof value.revision !== 'string' || !value.revision || value.revision.length > 256 || /[\u0000-\u001f\u007f]/.test(value.revision))
78
- throw fault('invalid proposal base revision');
78
+ throw fault('invalid comparison base revision');
79
79
  if (typeof value.name !== 'string' || !value.name || [...value.name].length > 256 || /[\\/\u0000-\u001f\u007f]/.test(value.name))
80
- throw fault('invalid proposal base name');
80
+ throw fault('invalid comparison base name');
81
81
  if (typeof value.by !== 'string' || !value.by.trim() || value.by.length > 96 || /[\u0000-\u001f\u007f]/.test(value.by))
82
- throw fault('a proposal needs its proposer name');
82
+ throw fault('a comparison needs its author name');
83
83
  if (typeof value.at !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{1,3})?Z$/.test(value.at) || !Number.isFinite(Date.parse(value.at)))
84
- throw fault('the time of a proposal is an ISO 8601 UTC timestamp');
84
+ throw fault('the time of a comparison is an ISO 8601 UTC timestamp');
85
85
  return {text: value.text, sha256: value.sha256, revision: value.revision, name: value.name, by: value.by, at: value.at};
86
86
  }
87
87
  const escapeAttribute = value => value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
@@ -98,7 +98,7 @@ export function readBaseElements(elements) {
98
98
  if (!rows.length) return null;
99
99
  const row = rows[0];
100
100
  if (rows.length !== 1 || row.type !== 'text/markdown' || (row.tagName && row.tagName.toLowerCase() !== 'script'))
101
- throw fault('duplicate or executable proposal base');
101
+ throw fault('duplicate or executable comparison base');
102
102
  const get = name => row.getAttribute ? row.getAttribute('data-' + name) : row[name];
103
103
  return readBase({text: decodeCarried(row.textContent), ...Object.fromEntries(['name', 'sha256', 'revision', 'by', 'at'].map(key => [key, get(key)]))});
104
104
  }
@@ -109,7 +109,7 @@ export function takeBase(html) {
109
109
  if (!pairs.some(([key, value]) => key === 'id' && value === 'rapier-base')) return whole;
110
110
  const seen = new Set(), values = {};
111
111
  for (const [key, value] of pairs) {
112
- if (seen.has(key)) throw fault('ambiguous proposal base');
112
+ if (seen.has(key)) throw fault('ambiguous comparison base');
113
113
  seen.add(key); values[key] = value;
114
114
  }
115
115
  elements.push({id: values.id, type: values.type, textContent: content,
@@ -1,24 +1,26 @@
1
1
  // SPDX-License-Identifier: MIT
2
- import {_rapierValidLedgerRecord, _rapierRecordSplices, _rapierTransformSplices} from './journal-records.mjs';
2
+ import {_rapierValidLedgerRecord, _rapierRecordSplices, _rapierTransformSplices, _rapierRecordMetadata, _rapierValidMetadata, _rapierTransformMetadata, _rapierReplayMetadata} from './journal-records.mjs';
3
+ import {authoredHistory} from './transport.mjs';
3
4
  import {textRoot, rootAfter, sha256} from './hash.mjs';
4
5
  import {canonicalJSON, plainData, documentText, exactKeys, nonnegative, fault} from './data.mjs';
5
6
  export {textRoot, rootAfter};
6
7
  const digest = value => sha256(canonicalJSON(value));
7
8
  const rootShape = value => typeof value === 'string' && /^\d+:[a-z0-9]+:[a-z0-9]+$/.test(value);
8
- export function readLedger(input, expectedText) {
9
+ export function readLedger(input, expectedText, expectedMetadata) {
9
10
  const ledger = plainData(input);
10
- if (!exactKeys(ledger, ['format','documentAuthority','start','records','head','complete','sha256']) || ledger.format !== 'rapier-ledger/1' ||
11
+ if (!exactKeys(ledger, ['format','documentAuthority','start','records','head','complete','sha256']) || ledger.format !== 'rapier-ledger/2' ||
11
12
  typeof ledger.documentAuthority !== 'string' || !ledger.documentAuthority || ledger.documentAuthority.length > 256 ||
12
13
  typeof ledger.complete !== 'boolean' || !Array.isArray(ledger.records) ||
13
- !exactKeys(ledger.start, ['text','revision','root','sha256']) || !exactKeys(ledger.head, ['revision','root','sha256'])) throw fault('invalid envelope');
14
+ !exactKeys(ledger.start, ['text','revision','root','sha256','metadata']) || !exactKeys(ledger.head, ['revision','root','sha256','metadata'])) throw fault('unsupported or invalid envelope');
14
15
  const {sha256: claimed, ...payload} = ledger;
15
16
  if (claimed !== digest(payload)) throw fault('envelope checksum differs');
16
17
  let text = documentText(ledger.start.text), root = ledger.start.root, revision = ledger.start.revision;
18
+ if (!_rapierValidMetadata(ledger.start.metadata) || !_rapierValidMetadata(ledger.head.metadata)) throw fault('invalid document metadata');
17
19
  if (!nonnegative(revision) || !rootShape(root) || (revision === 0 && root !== textRoot(text)) || ledger.start.sha256 !== sha256(text) ||
18
20
  (ledger.complete && revision !== 0)) throw fault('invalid start');
19
21
  const earlier = [];
20
22
  for (const record of ledger.records) {
21
- if (!Array.isArray(record.splices) || !record.splices.length || !_rapierValidLedgerRecord(record, earlier.at(-1) || null, earlier,
23
+ if (!Array.isArray(record.splices) || !_rapierValidLedgerRecord(record, earlier.at(-1) || null, earlier,
22
24
  ledger.documentAuthority, 25 * 1024 * 1024, text => new TextEncoder().encode(text).length, !ledger.complete) ||
23
25
  record.transaction.baseRevision < revision || (record.transaction.baseRevision !== revision && ledger.complete) ||
24
26
  (record.transaction.baseRevision === revision && record.beforeHash !== root)) throw fault('record chain differs');
@@ -31,39 +33,50 @@ export function readLedger(input, expectedText) {
31
33
  if (root !== record.afterHash) throw fault('record root differs');
32
34
  revision = record.transaction.revision; earlier.push(record);
33
35
  }
36
+ if (ledger.records.some(row => Object.hasOwn(row, 'authored'))) {
37
+ try { authoredHistory(ledger.start.text, ledger.records); } catch (_) { throw fault('authored origin differs'); }
38
+ }
39
+ const metadata = _rapierReplayMetadata(ledger.start.metadata, ledger.records);
40
+ if (!metadata || canonicalJSON(metadata) !== canonicalJSON(ledger.head.metadata) ||
41
+ (expectedMetadata !== undefined && (!_rapierValidMetadata(expectedMetadata) || canonicalJSON(metadata) !== canonicalJSON(expectedMetadata))))
42
+ throw fault('metadata does not describe this document');
34
43
  if (ledger.head.revision !== revision || ledger.head.root !== root || ledger.head.sha256 !== sha256(text) ||
35
44
  (expectedText !== undefined && text !== expectedText)) throw fault('history does not describe this document');
36
- return {ledger, text, root, revision, complete: ledger.complete};
45
+ return {ledger, text, metadata, root, revision, complete: ledger.complete};
37
46
  }
38
- export function exportLedger({text, records, documentAuthority, revision, root, complete = false, earlier = records} = {}) {
47
+ export function exportLedger({text, metadata, records, documentAuthority, revision, root, complete = false, earlier = records} = {}) {
39
48
  documentText(text);
49
+ if (!_rapierValidMetadata(metadata)) throw fault('document metadata required');
40
50
  if (!Array.isArray(records) || !Array.isArray(earlier)) throw fault('records required');
41
51
  // Inspect before touching properties: exporting must not call accessors on an alleged record.
42
52
  records = plainData(records); earlier = plainData(earlier);
43
53
  const expanded = records.map(record => {
44
- const rows = _rapierRecordSplices(record, earlier);
45
- if (!rows) throw fault('navigation target was not retained');
54
+ const rows = _rapierRecordSplices(record, earlier), effect = _rapierRecordMetadata(record, earlier);
55
+ if (!rows || effect === undefined) throw fault('navigation target was not retained');
46
56
  return {transaction: record.transaction, splices: rows.map(row => ({pos: row.pos, removed: row.removed, inserted: row.inserted})),
47
- beforeHash: record.beforeHash, afterHash: record.afterHash};
57
+ beforeHash: record.beforeHash, afterHash: record.afterHash, ...(Object.hasOwn(record, 'authored') ? {authored: record.authored} : {}), ...(effect ? {metadata: effect} : {}),
58
+ ...(record.changeSet && Object.hasOwn(record.changeSet, 'label') ? {changeSet: {label: record.changeSet.label}} : {}),
59
+ ...(record.derivedCommentIndex == null ? {} : {derivedCommentIndex: record.derivedCommentIndex})};
48
60
  });
49
- let startText = text;
61
+ let startText = text, startMetadata = {...metadata};
50
62
  for (let i = expanded.length - 1; i >= 0; i--) {
51
63
  startText = _rapierTransformSplices(startText, expanded[i].splices, true);
52
- if (startText === null) throw fault('history does not reverse from this document');
64
+ startMetadata = _rapierTransformMetadata(startMetadata, expanded[i].metadata, true);
65
+ if (startText === null || !startMetadata) throw fault('history does not reverse from this document');
53
66
  }
54
67
  revision ??= expanded.at(-1)?.transaction.revision ?? 0;
55
68
  root ??= expanded.at(-1)?.afterHash ?? textRoot(text);
56
69
  documentAuthority ??= expanded[0]?.transaction.documentAuthority;
57
70
  const startRevision = expanded[0]?.transaction.baseRevision ?? revision;
58
- const payload = {format: 'rapier-ledger/1', documentAuthority,
59
- start: {text: startText, revision: startRevision, root: expanded[0]?.beforeHash ?? root, sha256: sha256(startText)},
60
- records: expanded, head: {revision, root, sha256: sha256(text)}, complete: complete === true && startRevision === 0 && expanded.every((row, i) => !i || row.transaction.baseRevision === expanded[i - 1].transaction.revision)};
71
+ const payload = {format: 'rapier-ledger/2', documentAuthority,
72
+ start: {text: startText, revision: startRevision, root: expanded[0]?.beforeHash ?? root, sha256: sha256(startText), metadata: startMetadata},
73
+ records: expanded, head: {revision, root, sha256: sha256(text), metadata: {...metadata}}, complete: complete === true && startRevision === 0 && expanded.every((row, i) => !i || row.transaction.baseRevision === expanded[i - 1].transaction.revision)};
61
74
  const ledger = {...payload, sha256: digest(payload)};
62
- readLedger(ledger, text); return ledger;
75
+ readLedger(ledger, text, metadata); return ledger;
63
76
  }
64
77
  // Derived navigation, never a second history. A target older than start is intentionally unavailable.
65
78
  export function historyEnvelope(input, segmentIdentity = []) {
66
- const {ledger, text} = readLedger(input), ids = new Set(ledger.records.map(row => row.transaction.id));
79
+ const {ledger, text, metadata} = readLedger(input), ids = new Set(ledger.records.map(row => row.transaction.id));
67
80
  let branch = [], cursor = 0;
68
81
  for (const {transaction: tx} of ledger.records) {
69
82
  const target = tx.reverts || tx.reapplies;
@@ -75,28 +88,31 @@ export function historyEnvelope(input, segmentIdentity = []) {
75
88
  } else { branch = branch.slice(0, cursor); branch.push(tx.id); cursor = branch.length; }
76
89
  }
77
90
  const byId = new Map(ledger.records.map(row => [row.transaction.id, row]));
78
- let traversed = text;
91
+ let traversed = text, traversedMetadata = {...metadata};
79
92
  for (let i = cursor - 1; i >= 0; i--) {
80
93
  traversed = _rapierTransformSplices(traversed, byId.get(branch[i]).splices, true);
81
- if (traversed === null) throw fault('undo branch does not reverse');
94
+ traversedMetadata = _rapierTransformMetadata(traversedMetadata, byId.get(branch[i]).metadata, true);
95
+ if (traversed === null || !traversedMetadata) throw fault('undo branch does not reverse');
82
96
  }
83
- traversed = text;
97
+ traversed = text; traversedMetadata = {...metadata};
84
98
  for (let i = cursor; i < branch.length; i++) {
85
99
  traversed = _rapierTransformSplices(traversed, byId.get(branch[i]).splices);
86
- if (traversed === null) throw fault('redo branch does not replay');
100
+ traversedMetadata = _rapierTransformMetadata(traversedMetadata, byId.get(branch[i]).metadata);
101
+ if (traversed === null || !traversedMetadata) throw fault('redo branch does not replay');
87
102
  }
88
103
  if (ledger.complete) {
89
104
  if (ledger.records.length && !branch.length) throw fault('complete history lost its branch');
90
- let active = ledger.start.text;
91
- if (!cursor && active !== text) throw fault('complete history start differs');
105
+ let active = ledger.start.text, activeMetadata = {...ledger.start.metadata};
106
+ if (!cursor && (active !== text || canonicalJSON(activeMetadata) !== canonicalJSON(metadata))) throw fault('complete history start differs');
92
107
  for (let i = 0; i < branch.length; i++) {
93
108
  active = _rapierTransformSplices(active, byId.get(branch[i]).splices);
94
- if (active === null || (i + 1 === cursor && active !== text)) throw fault('complete branch differs');
109
+ activeMetadata = _rapierTransformMetadata(activeMetadata, byId.get(branch[i]).metadata);
110
+ if (active === null || !activeMetadata || (i + 1 === cursor && (active !== text || canonicalJSON(activeMetadata) !== canonicalJSON(metadata)))) throw fault('complete branch differs');
95
111
  }
96
112
  }
97
- return {schemaVersion: 4, documentAuthority: ledger.documentAuthority, documentRevision: ledger.head.revision,
113
+ return {schemaVersion: 5, documentAuthority: ledger.documentAuthority, documentRevision: ledger.head.revision,
98
114
  ledger: ledger.records, branch, cursor, earliestRevision: ledger.start.revision, earliestHash: ledger.start.root,
99
- sourceRootId: ledger.head.root, segmentIdentity, historyComplete: ledger.complete,
115
+ sourceRootId: ledger.head.root, earliestMetadata: {...ledger.start.metadata}, documentMetadata: {...metadata}, segmentIdentity, historyComplete: ledger.complete,
100
116
  trimReason: ledger.complete ? '' : 'carried_start', trimmedBytes: 0};
101
117
  }
102
118
  export function agentActorId(door, agent) {
@@ -124,8 +140,8 @@ export function replaceLedgerText(input, text, {actor = {kind: 'system', id: 'ra
124
140
  documentAuthority: current.ledger.documentAuthority, baseRevision: current.revision, revision,
125
141
  actor, transport: 'platform', operation, requestId: null, sourceTransactionId: null,
126
142
  affectedBlockIds: [], parent: current.ledger.records.at(-1)?.transaction.id ?? null,
127
- reverts: null, reapplies: null, createdAt: at ?? current.ledger.records.at(-1)?.transaction.createdAt ?? 0,
143
+ reverts: null, reapplies: null, createdAt: at ?? null,
128
144
  }};
129
- return exportLedger({text, records: [...current.ledger.records, record], documentAuthority: current.ledger.documentAuthority,
145
+ return exportLedger({text, metadata: current.metadata, records: [...current.ledger.records, record], documentAuthority: current.ledger.documentAuthority,
130
146
  revision, root, complete: current.complete});
131
147
  }
@@ -21,27 +21,33 @@ const K = new Uint32Array([
21
21
  0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
22
22
  0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2,
23
23
  ]);
24
- const rotate = (x, n) => (x >>> n) | (x << (32 - n));
24
+ // The rounds run on plain 32-bit locals: no array is made per block, so a megabyte (a painting's text) hashes about five times
25
+ // faster than through destructured arrays, with the same digest.
26
+ const W = new Int32Array(64);
25
27
  export function sha256(text) {
26
- const bytes = new TextEncoder().encode(text);
27
- const padded = new Uint8Array(Math.ceil((bytes.length + 9) / 64) * 64);
28
- padded.set(bytes); padded[bytes.length] = 0x80;
29
- const data = new DataView(padded.buffer), bits = bytes.length * 8;
30
- data.setUint32(padded.length - 8, Math.floor(bits / 0x100000000)); data.setUint32(padded.length - 4, bits >>> 0);
31
- const h = new Uint32Array([0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19]), w = new Uint32Array(64);
32
- for (let offset = 0; offset < padded.length; offset += 64) {
33
- for (let i = 0; i < 16; i++) w[i] = data.getUint32(offset + i * 4);
28
+ const bytes = new TextEncoder().encode(text), length = bytes.length;
29
+ const padded = new Uint8Array(Math.ceil((length + 9) / 64) * 64), end = padded.length;
30
+ padded.set(bytes); padded[length] = 0x80;
31
+ const high = Math.floor(length * 8 / 0x100000000), low = (length * 8) >>> 0;
32
+ padded[end - 8] = high >>> 24; padded[end - 7] = high >>> 16; padded[end - 6] = high >>> 8; padded[end - 5] = high;
33
+ padded[end - 4] = low >>> 24; padded[end - 3] = low >>> 16; padded[end - 2] = low >>> 8; padded[end - 1] = low;
34
+ let h0 = 0x6a09e667 | 0, h1 = 0xbb67ae85 | 0, h2 = 0x3c6ef372 | 0, h3 = 0xa54ff53a | 0, h4 = 0x510e527f | 0, h5 = 0x9b05688c | 0, h6 = 0x1f83d9ab | 0, h7 = 0x5be0cd19 | 0;
35
+ const w = W;
36
+ for (let offset = 0; offset < end; offset += 64) {
37
+ for (let i = 0, j = offset; i < 16; i++, j += 4) w[i] = padded[j] << 24 | padded[j + 1] << 16 | padded[j + 2] << 8 | padded[j + 3];
34
38
  for (let i = 16; i < 64; i++) {
35
39
  const x = w[i - 15], y = w[i - 2];
36
- w[i] = w[i - 16] + (rotate(x, 7) ^ rotate(x, 18) ^ (x >>> 3)) + w[i - 7] + (rotate(y, 17) ^ rotate(y, 19) ^ (y >>> 10));
40
+ w[i] = (w[i - 16] + ((x >>> 7 | x << 25) ^ (x >>> 18 | x << 14) ^ x >>> 3) + w[i - 7] + ((y >>> 17 | y << 15) ^ (y >>> 19 | y << 13) ^ y >>> 10)) | 0;
37
41
  }
38
- let [a,b,c,d,e,f,g,k] = h;
42
+ let a = h0, b = h1, c = h2, d = h3, e = h4, f = h5, g = h6, k = h7;
39
43
  for (let i = 0; i < 64; i++) {
40
- const t1 = (k + (rotate(e, 6) ^ rotate(e, 11) ^ rotate(e, 25)) + ((e & f) ^ (~e & g)) + K[i] + w[i]) >>> 0;
41
- const t2 = ((rotate(a, 2) ^ rotate(a, 13) ^ rotate(a, 22)) + ((a & b) ^ (a & c) ^ (b & c))) >>> 0;
42
- k = g; g = f; f = e; e = (d + t1) >>> 0; d = c; c = b; b = a; a = (t1 + t2) >>> 0;
44
+ const t1 = (k + ((e >>> 6 | e << 26) ^ (e >>> 11 | e << 21) ^ (e >>> 25 | e << 7)) + (e & f ^ ~e & g) + K[i] + w[i]) | 0;
45
+ const t2 = (((a >>> 2 | a << 30) ^ (a >>> 13 | a << 19) ^ (a >>> 22 | a << 10)) + (a & b ^ a & c ^ b & c)) | 0;
46
+ k = g; g = f; f = e; e = (d + t1) | 0; d = c; c = b; b = a; a = (t1 + t2) | 0;
43
47
  }
44
- for (const [i, value] of [a,b,c,d,e,f,g,k].entries()) h[i] += value;
48
+ h0 = (h0 + a) | 0; h1 = (h1 + b) | 0; h2 = (h2 + c) | 0; h3 = (h3 + d) | 0; h4 = (h4 + e) | 0; h5 = (h5 + f) | 0; h6 = (h6 + g) | 0; h7 = (h7 + k) | 0;
45
49
  }
46
- return [...h].map(x => x.toString(16).padStart(8, '0')).join('');
50
+ let out = '';
51
+ for (const x of [h0, h1, h2, h3, h4, h5, h6, h7]) out += (x >>> 0).toString(16).padStart(8, '0');
52
+ return out;
47
53
  }