rapier-markdown-kit 1.1.90 → 1.1.92

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.
@@ -31,19 +31,203 @@ function _rapierTransformSplices(text, splices, inverse = false) {
31
31
  } catch (_) { return null; }
32
32
  }
33
33
 
34
+ function _rapierPlainLedgerData(record) {
35
+ try {
36
+ const active = new Set(), pending = [[record, false]];
37
+ while (pending.length) {
38
+ const [value, leaving] = pending.pop();
39
+ if (leaving) { active.delete(value); continue; }
40
+ if (value === null || typeof value === 'boolean') continue;
41
+ if (typeof value === 'string') { if (/[\uD800-\uDFFF]/u.test(value)) return false; continue; }
42
+ if (typeof value === 'number') { if (!Number.isFinite(value) || Object.is(value, -0)) return false; continue; }
43
+ if (!value || typeof value !== 'object' || active.has(value)) return false;
44
+ const array = Array.isArray(value), proto = Object.getPrototypeOf(value);
45
+ if (!array && proto !== null && (Object.getPrototypeOf(proto) !== null ||
46
+ Object.getOwnPropertyDescriptor(proto, 'constructor')?.value?.name !== 'Object')) return false;
47
+ const keys = Reflect.ownKeys(value);
48
+ if (array && keys.length !== value.length + 1) return false;
49
+ active.add(value); pending.push([value, true]);
50
+ for (const key of keys) {
51
+ if (array && key === 'length') continue;
52
+ if (typeof key !== 'string' || ['__proto__', 'constructor', 'prototype'].includes(key) ||
53
+ (array && (!Number.isSafeInteger(Number(key)) || Number(key) < 0 || Number(key) >= value.length || String(Number(key)) !== key))) return false;
54
+ const field = Object.getOwnPropertyDescriptor(value, key);
55
+ if (!field || !Object.hasOwn(field, 'value') || !field.enumerable) return false;
56
+ pending.push([field.value, false]);
57
+ }
58
+ }
59
+ return true;
60
+ } catch (_) { return false; }
61
+ }
62
+
63
+ const _rapierMetadataValue = (key, value) => key === 'filename'
64
+ ? typeof value === 'string' && value.trim().length > 0 && value.length <= 512 &&
65
+ !/[<>"\x00-\x1f\x7f/\\\u200b-\u200f\u2028-\u202e\u2066-\u2069\ufeff\uD800-\uDFFF]/u.test(value) && value !== '.' && value !== '..'
66
+ : key === 'docKind' && ['markdown', 'text', 'code'].includes(value);
67
+
68
+ function _rapierValidMetadata(value) {
69
+ return _rapierPlainLedgerData(value) && value && !Array.isArray(value) &&
70
+ Object.keys(value).length === 2 && ['filename', 'docKind'].every(key => Object.hasOwn(value, key) && _rapierMetadataValue(key, value[key]));
71
+ }
72
+
73
+ function _rapierValidMetadataEffect(effect) {
74
+ return _rapierPlainLedgerData(effect) && effect && !Array.isArray(effect) &&
75
+ Object.keys(effect).length > 0 && Object.keys(effect).every(key => {
76
+ const pair = effect[key];
77
+ return pair && !Array.isArray(pair) && Object.keys(pair).length === 2 &&
78
+ Object.hasOwn(pair, 'before') && Object.hasOwn(pair, 'after') &&
79
+ _rapierMetadataValue(key, pair.before) && _rapierMetadataValue(key, pair.after);
80
+ });
81
+ }
82
+
83
+ function _rapierMetadataDelta(before, after, {assign = []} = {}) {
84
+ if (!_rapierValidMetadata(before) || !_rapierValidMetadata(after)) return null;
85
+ const effect = {};
86
+ for (const key of ['filename', 'docKind']) if (before[key] !== after[key] || assign.includes(key)) effect[key] = {before: before[key], after: after[key]};
87
+ return Object.keys(effect).length ? effect : null;
88
+ }
89
+
90
+ function _rapierTransformMetadata(value, effect, inverse = false) {
91
+ if (!_rapierValidMetadata(value) || (effect != null && !_rapierValidMetadataEffect(effect))) return null;
92
+ const next = {...value};
93
+ for (const [key, pair] of Object.entries(effect || {})) {
94
+ if (next[key] !== pair[inverse ? 'after' : 'before']) return null;
95
+ next[key] = pair[inverse ? 'before' : 'after'];
96
+ }
97
+ return next;
98
+ }
99
+
100
+ // An origin binds transported placement to exact authored rows and their causal basis.
101
+ function _rapierValidAuthored(origin) {
102
+ if (!_rapierPlainLedgerData(origin) || !origin || Array.isArray(origin) ||
103
+ Object.keys(origin).length !== 3 || !Array.isArray(origin.basis) ||
104
+ new Set(origin.basis).size !== origin.basis.length || origin.basis.some((id, index) => index && origin.basis[index - 1] >= id) ||
105
+ !origin.basis.every(id => typeof id === 'string' && id.length > 0 && id.length <= 256) ||
106
+ typeof origin.source !== 'string' || !/^[a-f0-9]{64}$/.test(origin.source) ||
107
+ !Array.isArray(origin.splices) || origin.splices.length > 64) return false;
108
+ return origin.splices.every(row => row && !Array.isArray(row) && Object.keys(row).length === 3 &&
109
+ Number.isSafeInteger(row.pos) && row.pos >= 0 && !Object.is(row.pos, -0) &&
110
+ typeof row.removed === 'string' && typeof row.inserted === 'string' &&
111
+ !/[\uD800-\uDFFF]/u.test(row.removed) && !/[\uD800-\uDFFF]/u.test(row.inserted));
112
+ }
113
+
34
114
  function _rapierRecordSplices(entry, ledger) {
35
115
  try {
36
- if (entry && Array.isArray(entry.splices) && entry.splices.length) return entry.splices;
37
- const transaction = entry && entry.transaction;
38
- const targetId = String(transaction?.reverts || transaction?.reapplies || '');
39
- const target = targetId && (ledger || []).find(record => record?.transaction?.id === targetId);
40
- if (!target || !Array.isArray(target.splices) || !target.splices.length) return null;
41
- return transaction.reverts ? target.splices.slice().reverse().map(row => ({
42
- pos: row.pos, removed: row.inserted, inserted: row.removed,
43
- })) : target.splices;
116
+ if (entry && Array.isArray(entry.splices)) return entry.splices;
117
+ const tx = entry?.transaction, targetId = tx?.reverts || tx?.reapplies;
118
+ const prior = (ledger || []).filter(record => record?.transaction?.revision < tx?.revision);
119
+ const target = targetId && prior.find(record => record?.transaction?.id === targetId);
120
+ if (!target) return null;
121
+ const rows = _rapierRecordSplices(target, prior);
122
+ if (!rows) return null;
123
+ return tx.reverts ? rows.slice().reverse().map(row => ({pos: row.pos, removed: row.inserted, inserted: row.removed})) : rows;
44
124
  } catch (_) { return null; }
45
125
  }
46
126
 
127
+ function _rapierRecordMetadata(entry, ledger) {
128
+ try {
129
+ if (Object.hasOwn(entry, 'metadata')) return _rapierValidMetadataEffect(entry.metadata) ? entry.metadata : undefined;
130
+ // An expanded record owns its complete effect; only compact navigation inherits one.
131
+ if (Array.isArray(entry.splices)) return null;
132
+ const tx = entry.transaction, targetId = tx.reverts || tx.reapplies;
133
+ const prior = (ledger || []).filter(record => record?.transaction?.revision < tx.revision);
134
+ const target = targetId && prior.find(record => record?.transaction?.id === targetId);
135
+ if (!target) return undefined;
136
+ const effect = _rapierRecordMetadata(target, prior);
137
+ if (effect == null) return effect;
138
+ return tx.reverts ? Object.fromEntries(Object.entries(effect).map(([key, pair]) =>
139
+ [key, {before: pair.after, after: pair.before}])) : effect;
140
+ } catch (_) { return undefined; }
141
+ }
142
+
143
+ // Active inverses suppress their targets. Reversing an inverse restores its targets,
144
+ // including assignments hidden by later choices. Nothing outside these records is retained.
145
+ function _rapierHistoryEffects(records, visit = null) {
146
+ try {
147
+ if (!Array.isArray(records)) return {ok: false};
148
+ const nodes = new Map(), references = new Map();
149
+ const update = (target, delta) => {
150
+ const pending = [[target, delta]];
151
+ while (pending.length) {
152
+ const [id, change] = pending.pop(), node = nodes.get(id), was = node.active;
153
+ node.blockers += change;
154
+ if (node.blockers < 0) throw Error('effect');
155
+ node.active = node.blockers === 0;
156
+ if (was !== node.active) for (const ref of node.references) pending.push([ref, node.active ? 1 : -1]);
157
+ }
158
+ };
159
+ const lastUndo = new Map();
160
+ for (const record of records) {
161
+ const tx = record?.transaction;
162
+ if (!tx || typeof tx.id !== 'string' || !tx.id || nodes.has(tx.id) || (tx.reverts && tx.reapplies)) return {ok: false};
163
+ const supplied = tx.sourceTransactionIds || (tx.sourceTransactionId ? [tx.sourceTransactionId] : []);
164
+ if (!Array.isArray(supplied) || new Set(supplied).size !== supplied.length ||
165
+ (tx.sourceTransactionIds && tx.sourceTransactionId && (supplied.length !== 1 || supplied[0] !== tx.sourceTransactionId))) return {ok: false};
166
+ let refs = supplied;
167
+ if (tx.reverts || tx.reapplies) {
168
+ const target = tx.reverts || tx.reapplies;
169
+ if (!nodes.has(target) || (supplied.length && (supplied.length !== 1 || supplied[0] !== target))) return {ok: false};
170
+ if (tx.reverts) refs = [target];
171
+ else {
172
+ const undo = lastUndo.get(target);
173
+ if (!undo || !nodes.get(undo).active) return {ok: false};
174
+ refs = [undo];
175
+ }
176
+ }
177
+ if (refs.some(id => typeof id !== 'string' || !nodes.get(id)?.active)) return {ok: false};
178
+ nodes.set(tx.id, {active: true, blockers: 0, references: refs}); references.set(tx.id, refs.slice());
179
+ for (const ref of refs) update(ref, 1);
180
+ if (tx.reverts) lastUndo.set(tx.reverts, tx.id);
181
+ if (visit) visit(record, nodes, refs);
182
+ }
183
+ const effectiveActIds = [...nodes].filter(([, node]) => node.active).map(([id]) => id);
184
+ return {ok: true, effectiveActIds, active: new Set(effectiveActIds), references};
185
+ } catch (_) { return {ok: false}; }
186
+ }
187
+
188
+ function _rapierMetadataState(initial, records, effects = _rapierHistoryEffects(records)) {
189
+ if (!_rapierValidMetadata(initial) || !effects.ok) return null;
190
+ const state = {...initial};
191
+ for (const record of records) if (effects.active.has(record.transaction.id) && !effects.references.get(record.transaction.id)?.length) {
192
+ const effect = _rapierRecordMetadata(record, records);
193
+ if (effect === undefined) return null;
194
+ for (const [key, pair] of Object.entries(effect || {})) state[key] = pair.after;
195
+ }
196
+ return state;
197
+ }
198
+
199
+ // Prove each recorded transition and each inverse's semantic result, not only the final values.
200
+ function _rapierReplayMetadata(initial, records) {
201
+ if (!_rapierValidMetadata(initial)) return null;
202
+ let state = {...initial};
203
+ const earlier = [], assignments = {filename: [], docKind: []};
204
+ const effects = _rapierHistoryEffects(records, (record, nodes, refs) => {
205
+ const effect = _rapierRecordMetadata(record, earlier);
206
+ if (effect === undefined) throw Error('metadata');
207
+ state = _rapierTransformMetadata(state, effect);
208
+ if (!state) throw Error('metadata');
209
+ if (!refs.length) for (const [key, pair] of Object.entries(effect || {}))
210
+ assignments[key].push({id: record.transaction.id, value: pair.after});
211
+ for (const key of ['filename', 'docKind']) {
212
+ let expected = initial[key];
213
+ for (let i = assignments[key].length - 1; i >= 0; i--) if (nodes.get(assignments[key][i].id).active) {
214
+ expected = assignments[key][i].value; break;
215
+ }
216
+ if (state[key] !== expected) throw Error('metadata_effect');
217
+ }
218
+ earlier.push(record);
219
+ });
220
+ return effects.ok ? state : null;
221
+ }
222
+
223
+ function _rapierHasHistoryEffect(record, earlier = []) {
224
+ const tx = record?.transaction;
225
+ if (!tx || !(tx.reverts || tx.reapplies || tx.sourceTransactionId || tx.sourceTransactionIds?.length)) return false;
226
+ let id = tx.id;
227
+ if (!id) { id = 'candidate'; while (earlier.some(row => row?.transaction?.id === id)) id += ':'; }
228
+ return _rapierHistoryEffects([...earlier, {...record, transaction: {...tx, id}}]).ok;
229
+ }
230
+
47
231
  // A ledger record as the journal and the Notes return validate it below: plain data, nothing
48
232
  // undefined anywhere in it. A live record could carry a row whose type or order is not yet known (a
49
233
  // block just inserted, before its first render: `type: undefined`), and the validator would refuse
@@ -65,48 +249,30 @@ function _rapierValidLedgerRecord(record, prior, earlier, documentAuthority, max
65
249
  // Iterative traversal refuses cycles without exhausting the call stack. Proxies/counters that
66
250
  // throw are refused by the same gate rather than escaping into document recovery.
67
251
  try {
68
- const active = new Set(), pending = [[record, false]];
69
- while (pending.length) {
70
- const [value, leaving] = pending.pop();
71
- if (leaving) { active.delete(value); continue; }
72
- if (value === null || typeof value === 'string' || typeof value === 'boolean') continue;
73
- if (typeof value === 'number') { if (!Number.isFinite(value)) return false; continue; }
74
- if (!value || typeof value !== 'object' || active.has(value)) return false;
75
- const array = Array.isArray(value), proto = Object.getPrototypeOf(value);
76
- if (!array && proto !== null && (Object.getPrototypeOf(proto) !== null ||
77
- Object.getOwnPropertyDescriptor(proto, 'constructor')?.value?.name !== 'Object')) return false;
78
- const keys = Reflect.ownKeys(value);
79
- if (array && keys.length !== value.length + 1) return false;
80
- active.add(value); pending.push([value, true]);
81
- for (const key of keys) {
82
- if (array && key === 'length') continue;
83
- if (typeof key !== 'string' || key === '__proto__' || key === 'constructor' || key === 'prototype' ||
84
- (array && (!Number.isSafeInteger(Number(key)) || Number(key) < 0 ||
85
- Number(key) >= value.length || String(Number(key)) !== key))) return false;
86
- const field = Object.getOwnPropertyDescriptor(value, key);
87
- if (!field || !Object.hasOwn(field, 'value')) return false;
88
- pending.push([field.value, false]);
89
- }
90
- }
252
+ if (!_rapierPlainLedgerData(record) || (Object.hasOwn(record, 'authored') && !_rapierValidAuthored(record.authored))) return false;
91
253
  if (!record || typeof record !== 'object' ||
92
254
  typeof record.beforeHash !== 'string' || !record.beforeHash.length ||
93
- typeof record.afterHash !== 'string' || !record.afterHash.length || record.beforeHash === record.afterHash) return false;
94
- const own = Array.isArray(record.splices) && record.splices.length;
95
- const navigation = !!(record.transaction?.reverts || record.transaction?.reapplies);
255
+ typeof record.afterHash !== 'string' || !record.afterHash.length) return false;
256
+ const own = Array.isArray(record.splices), navigation = !!(record.transaction?.reverts || record.transaction?.reapplies);
96
257
  if ((!own && !navigation) || (own && record.splices.length > 64) ||
97
- (record.transaction?.reverts && record.transaction?.reapplies)) return false;
98
- const splices = _rapierRecordSplices(record, earlier);
258
+ (record.transaction?.reverts && record.transaction?.reapplies)) return false;
259
+ const splices = _rapierRecordSplices(record, earlier), metadata = _rapierRecordMetadata(record, earlier);
260
+ const semantic = _rapierHasHistoryEffect(record, earlier);
261
+ if (metadata === undefined || (!splices?.length && !Object.values(metadata || {}).some(pair => pair.before !== pair.after) && !semantic) ||
262
+ (record.beforeHash === record.afterHash && splices?.length) ||
263
+ (record.beforeHash !== record.afterHash && !splices?.length) ||
264
+ ((record.transaction?.sourceTransactionId || record.transaction?.sourceTransactionIds?.length || navigation) && !semantic)) return false;
99
265
  if (!splices || splices.length > 64 || !splices.every(row =>
100
266
  row && Number.isSafeInteger(row.pos) && row.pos >= 0 && !Object.is(row.pos, -0) &&
101
267
  typeof row.removed === 'string' && typeof row.inserted === 'string' &&
102
268
  !/[\uD800-\uDFFF]/u.test(row.removed) && !/[\uD800-\uDFFF]/u.test(row.inserted) &&
103
269
  utf8Length(row.removed) <= maxDocumentBytes &&
104
270
  utf8Length(row.inserted) <= maxDocumentBytes)) return false;
271
+ if (record.authored && record.authored.splices.some(row => utf8Length(row.removed) > maxDocumentBytes || utf8Length(row.inserted) > maxDocumentBytes)) return false;
105
272
  const transaction = record.transaction;
106
273
  const nullableText = (value, limit, empty = true) => value === null ||
107
274
  (typeof value === 'string' && value.length <= limit && (empty || value.length > 0));
108
- // A grouped agent contribution's optional fields, bounded like the identities beside them: a name of 1 to 120 characters, a
109
- // revision, at most 500 source identities (the journal the kernel retains) of 1 to 160, and a hosted row's identity of 1 to 256.
275
+ // Identity fields are bounded individually; durable history has no record-count limit.
110
276
  const optional = (value, valid) => value === undefined || valid(value);
111
277
  const identity = (value, limit) => typeof value === 'string' && value.length > 0 && value.length <= limit;
112
278
  if (!transaction || typeof transaction.id !== 'string' || !transaction.id.length ||
@@ -118,17 +284,21 @@ function _rapierValidLedgerRecord(record, prior, earlier, documentAuthority, max
118
284
  typeof transaction.operation !== 'string' || !transaction.operation.length ||
119
285
  transaction.operation.length > _RAPIER_TRANSACTION_OPERATION_LIMIT ||
120
286
  !nullableText(transaction.requestId, _RAPIER_TRANSACTION_REQUEST_LIMIT) ||
121
- !nullableText(transaction.sourceTransactionId, _RAPIER_TRANSACTION_REQUEST_LIMIT) ||
287
+ !nullableText(transaction.sourceTransactionId, 256, false) ||
288
+ !optional(transaction.label, value => nullableText(value, 120)) ||
289
+ !optional(transaction.turnBaseRevision, value => Number.isSafeInteger(value) && value >= 0) ||
290
+ !optional(transaction.turnId, value => value === null || identity(value, 160)) ||
291
+ !optional(transaction.turnLabel, value => value === null || identity(value, 120)) ||
122
292
  !optional(transaction.contribution, value => identity(value, 120)) ||
123
293
  !optional(transaction.contributionBaseRevision, value => Number.isSafeInteger(value) && value >= 0 && !Object.is(value, -0)) ||
124
- !optional(transaction.sourceTransactionIds, value => Array.isArray(value) && value.length <= 500 &&
125
- value.every(id => identity(id, _RAPIER_TRANSACTION_REQUEST_LIMIT))) ||
294
+ !optional(transaction.sourceTransactionIds, value => Array.isArray(value) && value.length > 0 &&
295
+ new Set(value).size === value.length && value.every(id => identity(id, 256))) ||
126
296
  !optional(transaction.remoteTransactionId, value => identity(value, 256)) ||
127
297
  !nullableText(transaction.parent, 256, false) ||
128
298
  !nullableText(transaction.reverts, 256, false) ||
129
299
  !nullableText(transaction.reapplies, 256, false) ||
130
- !Number.isSafeInteger(transaction.createdAt) || transaction.createdAt < 0 ||
131
- !Array.isArray(transaction.affectedBlockIds) || transaction.affectedBlockIds.length > 64 ||
300
+ !(transaction.createdAt === null || (Number.isSafeInteger(transaction.createdAt) && transaction.createdAt >= 0)) ||
301
+ !Array.isArray(transaction.affectedBlockIds) ||
132
302
  !transaction.affectedBlockIds.every(id => Number.isSafeInteger(id) && id >= 0) ||
133
303
  !Number.isSafeInteger(transaction.revision) || transaction.revision < 1 ||
134
304
  !Number.isSafeInteger(transaction.baseRevision) ||
@@ -140,8 +310,9 @@ function _rapierValidLedgerRecord(record, prior, earlier, documentAuthority, max
140
310
  return !!transaction.actor && typeof transaction.actor.kind === 'string' &&
141
311
  /^(?:human|agent|system)$/.test(transaction.actor.kind) &&
142
312
  typeof transaction.actor.id === 'string' && transaction.actor.id.length > 0 &&
143
- transaction.actor.id.length <= _RAPIER_TRANSACTION_ACTOR_LIMIT;
313
+ transaction.actor.id.length <= _RAPIER_TRANSACTION_ACTOR_LIMIT &&
314
+ optional(transaction.actor.name, value => nullableText(value, 120, false));
144
315
  } catch (_) { return false; }
145
316
  }
146
317
 
147
- export { _RAPIER_TRANSACTION_ACTOR_LIMIT, _RAPIER_TRANSACTION_OPERATION_LIMIT, _RAPIER_TRANSACTION_REQUEST_LIMIT, _rapierTransformSplices, _rapierRecordSplices, _rapierValidLedgerRecord, _rapierJournalRecord};
318
+ export { _rapierValidAuthored, _RAPIER_TRANSACTION_ACTOR_LIMIT, _RAPIER_TRANSACTION_OPERATION_LIMIT, _RAPIER_TRANSACTION_REQUEST_LIMIT, _rapierTransformSplices, _rapierRecordSplices, _rapierValidLedgerRecord, _rapierJournalRecord, _rapierValidMetadata, _rapierValidMetadataEffect, _rapierMetadataDelta, _rapierTransformMetadata, _rapierRecordMetadata, _rapierHistoryEffects, _rapierMetadataState, _rapierReplayMetadata, _rapierHasHistoryEffect};
@@ -1,7 +1,8 @@
1
1
  // SPDX-License-Identifier: MIT
2
2
  import {readLedger, exportLedger, rootAfter} from './format.mjs';
3
- import {_rapierTransformSplices} from './journal-records.mjs';
3
+ import {_rapierTransformSplices, _rapierTransformMetadata, _rapierMetadataDelta, _rapierHistoryEffects, _rapierMetadataState} from './journal-records.mjs';
4
4
  import {mergeText} from './text-merge.mjs';
5
+ import {transposeAuthored, authoredPlacements} from './transport.mjs';
5
6
  import {parseWill} from '../../agent/will.mjs';
6
7
  import {sha256} from './hash.mjs';
7
8
  import {canonicalJSON, fault} from './data.mjs';
@@ -31,18 +32,30 @@ export function transportTouchedInterval(start, end, splices) {
31
32
  }
32
33
  return range;
33
34
  }
34
- function over(row, other) {
35
- // Equal-position insertions have no ordering evidence. Review, never a lexical winner.
36
- if (!row.removed.length && !other.removed.length && row.pos === other.pos) return null;
37
- const range = transportInterval(row.pos, row.pos + row.removed.length, [other]);
38
- return range ? {...row, pos: range.start} : null;
39
- }
40
35
  const recordKey = record => sha256(canonicalJSON(record));
41
- const origin = record => record.transaction.sourceTransactionId?.startsWith('ledger:') ? record.transaction.sourceTransactionId : 'ledger:' + recordKey(record);
36
+ // Identity survives rebasing. Position, roots, revision and parent are local placement,
37
+ // while authorial choices and semantic inverse targets belong to the original act.
38
+ function actFingerprint(record, effects) {
39
+ const tx = record.transaction, references = effects.references.get(tx.id) || [];
40
+ const {id, documentAuthority, baseRevision, revision, parent, reverts, reapplies,
41
+ sourceTransactionId, sourceTransactionIds, remoteTransactionId, ...authored} = tx;
42
+ return sha256(canonicalJSON({transaction: authored, references,
43
+ changeSet: record.changeSet ?? null, derivedCommentIndex: record.derivedCommentIndex ?? null,
44
+ // Applied source placement is proved below, never inferred from this fingerprint.
45
+ // An inverse's physical metadata is projected from its targets at the destination.
46
+ metadata: references.length ? null : Object.fromEntries(Object.entries(record.metadata || {}).map(([key, pair]) => [key, pair.after]))}));
47
+ }
48
+ const mergeIdentity = (localDigest, incomingDigest) => 'merge:' + sha256(canonicalJSON([localDigest, incomingDigest]));
42
49
  function checkpointList(read) {
43
- const list = [{root: read.ledger.start.root, text: read.ledger.start.text, index: 0}], records = read.ledger.records;
44
- let text = read.ledger.start.text;
45
- for (let i = 0; i < records.length; i++) { text = _rapierTransformSplices(text, records[i].splices); list.push({root: records[i].afterHash, text, index: i + 1}); }
50
+ const list = [{root: read.ledger.start.root, text: read.ledger.start.text, metadata: read.ledger.start.metadata,
51
+ revision: read.ledger.start.revision, identity: null, index: 0}], records = read.ledger.records;
52
+ let text = read.ledger.start.text, metadata = read.ledger.start.metadata;
53
+ for (let i = 0; i < records.length; i++) {
54
+ text = _rapierTransformSplices(text, records[i].splices);
55
+ metadata = _rapierTransformMetadata(metadata, records[i].metadata);
56
+ list.push({root: records[i].afterHash, text, metadata, revision: records[i].transaction.revision,
57
+ identity: recordKey(records[i]), index: i + 1});
58
+ }
46
59
  return list;
47
60
  }
48
61
  function kept(text) {
@@ -50,67 +63,194 @@ function kept(text) {
50
63
  if (will.fault) return {fault: will.fault, text};
51
64
  return will.regions.filter(region => region.law === 'keep').map(region => text.slice(region.start, region.end));
52
65
  }
66
+ // A retained suffix can reuse a prefix only when an existing ledger proves its
67
+ // exact source, metadata, revision and path root. Export then rechecks the whole
68
+ // chain, including the boundary parent. No missing act is invented.
69
+ function expandRetainedStart(shorter, longer) {
70
+ if (shorter.ledger.documentAuthority !== longer.ledger.documentAuthority ||
71
+ shorter.ledger.start.revision <= longer.ledger.start.revision) return null;
72
+ const start = shorter.ledger.start, point = checkpointList(longer).find(point =>
73
+ point.revision === start.revision && point.root === start.root && point.text === start.text &&
74
+ canonicalJSON(point.metadata) === canonicalJSON(start.metadata));
75
+ if (!point) return null;
76
+ try { return exportLedger({text: shorter.text, metadata: shorter.metadata,
77
+ records: [...longer.ledger.records.slice(0, point.index), ...shorter.ledger.records],
78
+ documentAuthority: shorter.ledger.documentAuthority, revision: shorter.revision, root: shorter.root,
79
+ complete: longer.complete}); } catch (_) { return null; }
80
+ }
81
+
53
82
  export function merge(first, second) {
54
83
  const ours = readLedger(first), theirs = readLedger(second);
55
- // A reviewed conflict receipt names the complete incoming envelope: it too is idempotent.
56
- if (ours.ledger.records.some(record => record.transaction.operation === 'document.merge' && record.transaction.sourceTransactionId === 'ledger:' + theirs.ledger.sha256)) {
57
- const clean = !ours.text.includes('<!-- note-conflict:v1 ');
58
- return {text: ours.text, ledger: ours.ledger, clean, conflicts: [], review: {required: !clean, base: ours.text, proposed: ours.text}};
84
+ const expandedTheirs = expandRetainedStart(theirs, ours);
85
+ if (expandedTheirs) return merge(ours.ledger, expandedTheirs);
86
+ const expandedOurs = expandRetainedStart(ours, theirs);
87
+ if (expandedOurs) {
88
+ const result = merge(expandedOurs, theirs.ledger);
89
+ // A refused join still returns the caller's exact retained envelope.
90
+ return !result.clean && result.ledger.sha256 === expandedOurs.sha256
91
+ ? {...result, ledger: ours.ledger} : result;
92
+ }
93
+ const localEffects = _rapierHistoryEffects(ours.ledger.records), incomingEffects = _rapierHistoryEffects(theirs.ledger.records);
94
+ const localOrigins = authoredPlacements(ours.ledger.start.text, ours.ledger.records),
95
+ incomingOrigins = authoredPlacements(theirs.ledger.start.text, theirs.ledger.records);
96
+ const localIds = new Map(ours.ledger.records.map(record => [record.transaction.id, record])), unproved = new Set();
97
+ for (const record of theirs.ledger.records) {
98
+ const local = localIds.get(record.transaction.id);
99
+ if (local && actFingerprint(local, localEffects) !== actFingerprint(record, incomingEffects)) return {text: ours.text, metadata: ours.metadata, ledger: ours.ledger,
100
+ clean: false, conflicts: [{kind: 'identity', code: 'act_identity_conflict', actId: record.transaction.id}],
101
+ review: {required: true, base: ours.text, proposed: theirs.text}};
102
+ if (local && canonicalJSON(localOrigins.get(local.transaction.id)) !== canonicalJSON(incomingOrigins.get(record.transaction.id)))
103
+ return {text: ours.text, metadata: ours.metadata, ledger: ours.ledger, clean: false,
104
+ conflicts: [{kind: 'identity', code: 'act_order_conflict', actId: record.transaction.id}],
105
+ review: {required: true, base: ours.text, proposed: theirs.text}};
106
+ if (local && (canonicalJSON(local.splices) !== canonicalJSON(record.splices) ||
107
+ canonicalJSON(local.metadata || {}) !== canonicalJSON(record.metadata || {}))) unproved.add(record.transaction.id);
59
108
  }
60
109
  const a = checkpointList(ours), b = checkpointList(theirs);
110
+ // A synthetic conflict act has one identity derived from both original envelopes.
111
+ // Reconstruct its exact local prefix to recognize a repeat without aliasing act IDs.
112
+ for (const [index, record] of ours.ledger.records.entries()) {
113
+ if (record.transaction.operation !== 'document.merge') continue;
114
+ const point = a[index], prefix = exportLedger({text: point.text, metadata: point.metadata,
115
+ records: ours.ledger.records.slice(0, index), documentAuthority: ours.ledger.documentAuthority,
116
+ revision: point.revision, root: point.root, complete: ours.complete});
117
+ if (record.transaction.id !== mergeIdentity(prefix.sha256, theirs.ledger.sha256)) continue;
118
+ const clean = !ours.text.includes('<!-- note-conflict:v1 ');
119
+ return {text: ours.text, metadata: ours.metadata, ledger: ours.ledger, clean, conflicts: [], review: {required: !clean, base: ours.text, proposed: ours.text}};
120
+ }
61
121
  let common = null;
62
122
  for (let i = b.length - 1; i >= 0 && !common; i--) {
63
- const found = a.findLast(point => point.root === b[i].root && point.text === b[i].text);
64
- if (found) common = {ours: found.index, theirs: b[i].index, text: found.text};
123
+ if (theirs.ledger.records.slice(0, b[i].index).some(row => unproved.has(row.transaction.id))) continue;
124
+ const found = a.findLast(point => !ours.ledger.records.slice(0, point.index).some(row => unproved.has(row.transaction.id)) && point.root === b[i].root && point.text === b[i].text && point.revision === b[i].revision &&
125
+ point.identity === b[i].identity && canonicalJSON(point.metadata) === canonicalJSON(b[i].metadata));
126
+ if (found) common = {ours: found.index, theirs: b[i].index, text: found.text, metadata: found.metadata};
65
127
  }
66
128
  if (!common) throw fault('copies have no proven common start');
67
- const records = ours.ledger.records.slice(), imported = new Set(records.map(origin));
68
- let text = ours.text, root = ours.root;
69
- const append = (splices, actor, sourceId, operation, at) => {
129
+ // A prior join can contain our acts in a different transported order. After
130
+ // proving their placement below, keep the complete superset's representation.
131
+ const incomingIds = new Set(theirs.ledger.records.map(row => row.transaction.id));
132
+ const sameStart = ours.complete && theirs.complete && ours.ledger.documentAuthority === theirs.ledger.documentAuthority &&
133
+ canonicalJSON(ours.ledger.start) === canonicalJSON(theirs.ledger.start);
134
+ const oursContained = sameStart && [...localIds.keys()].every(id => incomingIds.has(id));
135
+ const theirsContained = sameStart && [...incomingIds].every(id => localIds.has(id));
136
+ const settled = read => ({text: read.text, metadata: read.metadata, ledger: read.ledger,
137
+ clean: !read.text.includes('<!-- note-conflict:v1 '), conflicts: [],
138
+ review: {required: read.text.includes('<!-- note-conflict:v1 '), base: ours.text, proposed: read.text}});
139
+ if (oursContained && theirsContained) {
140
+ if (ours.text !== theirs.text || canonicalJSON(ours.metadata) !== canonicalJSON(theirs.metadata))
141
+ return {text: ours.text, metadata: ours.metadata, ledger: ours.ledger, clean: false,
142
+ conflicts: [{kind: 'identity', code: 'act_order_conflict'}], review: {required: true, base: ours.text, proposed: theirs.text}};
143
+ }
144
+ const records = ours.ledger.records.slice(), imported = new Set(localIds.keys());
145
+ let text = ours.text, metadata = {...ours.metadata}, root = ours.root;
146
+ const append = (splices, original, effect = null, inverseIds = [], origin = original.authored) => {
70
147
  const next = _rapierTransformSplices(text, splices);
71
- if (next === null) throw fault('transported record failed replay');
148
+ if (next === null) return null;
72
149
  const beforeHash = root, revision = (records.at(-1)?.transaction.revision ?? ours.ledger.start.revision) + 1;
73
- for (const row of splices) root = rootAfter(root, row);
74
- records.push({beforeHash, afterHash: root, splices, transaction: {
75
- id: ours.ledger.documentAuthority.slice(0, 48) + ':merge:' + revision.toString(36), documentAuthority: ours.ledger.documentAuthority,
76
- baseRevision: revision - 1, revision, actor, transport: 'platform', operation, requestId: null,
77
- sourceTransactionId: sourceId, affectedBlockIds: [], parent: records.at(-1)?.transaction.id ?? null,
78
- reverts: null, reapplies: null, createdAt: at,
79
- }}); text = next;
150
+ let nextRoot = root;
151
+ for (const row of splices) nextRoot = rootAfter(nextRoot, row);
152
+ const {remoteTransactionId, sourceTransactionId, sourceTransactionIds, ...tx} = original.transaction;
153
+ const record = {beforeHash, afterHash: nextRoot, splices, ...(origin ? {authored: origin} : {}), transaction: {
154
+ ...tx, id: tx.id, documentAuthority: ours.ledger.documentAuthority,
155
+ baseRevision: revision - 1, revision, actor: tx.actor, transport: tx.transport, operation: tx.operation, requestId: tx.requestId,
156
+ sourceTransactionId: inverseIds.length === 1 ? inverseIds[0] : null,
157
+ ...(inverseIds.length > 1 ? {sourceTransactionIds: inverseIds} : {}),
158
+ ...(tx.turnId ? {turnId: tx.turnId} : {}), affectedBlockIds: tx.affectedBlockIds.slice(),
159
+ parent: records.at(-1)?.transaction.id ?? null, reverts: null, reapplies: null, createdAt: tx.createdAt,
160
+ }, ...(original.changeSet ? {changeSet: original.changeSet} : {}),
161
+ ...(original.derivedCommentIndex == null ? {} : {derivedCommentIndex: original.derivedCommentIndex})};
162
+ if (inverseIds.length) {
163
+ const after = _rapierMetadataState(ours.ledger.start.metadata, [...records, record]);
164
+ if (!after) return null;
165
+ effect = _rapierMetadataDelta(metadata, after);
166
+ }
167
+ const nextMetadata = _rapierTransformMetadata(metadata, effect);
168
+ if (!nextMetadata) return null;
169
+ if (effect) record.metadata = effect;
170
+ records.push(record); text = next; metadata = nextMetadata; root = nextRoot;
171
+ return record.transaction.id;
80
172
  };
81
- let against = ours.ledger.records.slice(common.ours).flatMap(record => record.splices.map(row => ({...row})));
173
+ let against = ours.ledger.records.slice(common.ours);
82
174
  const incoming = theirs.ledger.records.slice(common.theirs);
83
- // Complete repeats are detected by record content, never the revision ID (copies can reuse IDs).
84
- const unseen = incoming.filter(record => !imported.has(origin(record)));
175
+ // The same original ID is a repeat only after its immutable fingerprint agrees.
176
+ const unseen = incoming.filter(record => !imported.has(record.transaction.id));
177
+ const refused = (kind, code, details = {}) => ({text: ours.text, metadata: ours.metadata, ledger: ours.ledger,
178
+ clean: false, conflicts: [{kind, code, ...details}],
179
+ review: {required: true, base: ours.text, proposed: theirs.text}});
180
+ const metadataConflict = (code, details) => refused('metadata', code, details);
181
+ const incomingFields = new Set(unseen.flatMap(record => Object.keys(record.metadata || {})));
182
+ const assignmentFields = (record, effects) => effects.references.get(record.transaction.id)?.length ? [] : Object.keys(record.metadata || {});
183
+ const competingFields = (record, pending) => {
184
+ const fields = new Set(pending.flatMap(row => assignmentFields(row, localEffects)));
185
+ return assignmentFields(record, incomingEffects).filter(key => fields.has(key));
186
+ };
187
+ const mappedIds = new Map();
188
+ for (const record of theirs.ledger.records) {
189
+ const local = localIds.get(record.transaction.id);
190
+ if (local) mappedIds.set(record.transaction.id, local.transaction.id);
191
+ }
85
192
  const keepDiffers = canonicalJSON(kept(ours.text)) !== canonicalJSON(kept(theirs.text));
86
- let conflicted = keepDiffers && unseen.length > 0;
87
- if (!conflicted && unseen.length && unseen.length !== incoming.length) conflicted = true; // partial receipt needs a common text merge
88
- if (!conflicted) for (const record of unseen) {
89
- const moved = [];
90
- for (const splice of record.splices) {
91
- let row = {...splice}; const nextAgainst = [];
92
- for (const other of against) {
93
- const next = over(row, other), otherNext = over(other, row);
94
- if (!next || !otherNext) { conflicted = true; break; }
95
- nextAgainst.push(otherNext); row = next;
96
- }
97
- if (conflicted) break;
98
- moved.push(row); against = nextAgainst;
193
+ let conflicted = keepDiffers && unseen.length > 0, incomingSource = common.text;
194
+ if (!conflicted) for (const record of incoming) {
195
+ const beforeSource = incomingSource;
196
+ incomingSource = _rapierTransformSplices(incomingSource, record.splices);
197
+ if (incomingSource === null) return refused('history', 'record_transport_conflict');
198
+ if (imported.has(record.transaction.id)) {
199
+ const index = against.findIndex(row => row.transaction.id === record.transaction.id);
200
+ if (index < 0) return refused('identity', 'act_order_conflict', {actId: record.transaction.id});
201
+ const preceding = against.slice(0, index), competing = competingFields(record, preceding);
202
+ if (competing.length) return metadataConflict('metadata_assignment_conflict', {fields: competing});
203
+ unproved.delete(record.transaction.id);
204
+ against = against.filter(row => row.transaction.id !== record.transaction.id);
205
+ continue;
206
+ }
207
+ const competing = competingFields(record, against);
208
+ if (competing.length) return metadataConflict('metadata_assignment_conflict', {fields: competing});
209
+ const references = incomingEffects.references.get(record.transaction.id) || [];
210
+ const inverseIds = references.map(id => mappedIds.get(id));
211
+ if (inverseIds.some(id => !id || !_rapierHistoryEffects(records).active.has(id))) return metadataConflict('inverse_target_unavailable');
212
+ let moved;
213
+ try { moved = transposeAuthored(ours.ledger.start.text, records, {...record, authored: incomingOrigins.get(record.transaction.id)},
214
+ theirs.ledger.records.slice(0, theirs.ledger.records.indexOf(record)).map(row => row.transaction.id)); } catch (_) {}
215
+ if (!moved || moved.submittedBefore !== beforeSource || moved.submitted !== incomingSource ||
216
+ _rapierTransformSplices(text, moved.splices) === null) { conflicted = true; break; }
217
+ const effect = record.metadata ? Object.fromEntries(Object.entries(record.metadata).map(([key, pair]) =>
218
+ [key, {before: metadata[key], after: pair.after}])) : null;
219
+ const origin = record.authored ?? (against.length ? incomingOrigins.get(record.transaction.id) : null);
220
+ const mapped = append(moved.splices, record, effect, inverseIds, origin);
221
+ if (!mapped) return metadataConflict(inverseIds.length ? 'inverse_target_unavailable' : 'record_transport_conflict');
222
+ against = against.filter(row => row.transaction.id !== record.transaction.id);
223
+ mappedIds.set(record.transaction.id, mapped);
224
+ }
225
+ if (!conflicted) {
226
+ if (unproved.size) return refused('identity', 'act_order_conflict', {actId: unproved.values().next().value});
227
+ if (theirsContained) {
228
+ // Only equivalent proved states choose a representation, never competing authored values.
229
+ return settled(oursContained && theirs.ledger.sha256 < ours.ledger.sha256 ? theirs : ours);
230
+ }
231
+ if (oursContained) {
232
+ if (text !== theirs.text || canonicalJSON(metadata) !== canonicalJSON(theirs.metadata))
233
+ return refused('identity', 'act_order_conflict');
234
+ return settled(theirs);
99
235
  }
100
- if (conflicted || _rapierTransformSplices(text, moved) === null) { conflicted = true; break; }
101
- append(moved, record.transaction.actor, origin(record), record.transaction.operation, record.transaction.createdAt);
102
236
  }
103
237
  let conflicts = [];
104
238
  if (conflicted) {
239
+ if (unseen.length !== incoming.length) return refused('history', 'record_transport_conflict');
240
+ if (incomingFields.size || unseen.some(record => incomingEffects.references.get(record.transaction.id)?.length))
241
+ return metadataConflict('record_transport_conflict', {fields: [...incomingFields]});
105
242
  // Restart from the original local head: partially transported records never leak into a conflict result.
106
- records.splice(ours.ledger.records.length); text = ours.text; root = ours.root;
243
+ records.splice(ours.ledger.records.length); text = ours.text; metadata = {...ours.metadata}; root = ours.root;
107
244
  const merged = mergeText(keepDiffers ? null : common.text, ours.text, theirs.text, {oursId: 'copy-' + ours.ledger.sha256.slice(0, 16), theirsId: 'copy-' + theirs.ledger.sha256.slice(0, 16)});
108
245
  conflicts = merged.conflicts;
109
- if (merged.text !== text) append([{pos: 0, removed: text, inserted: merged.text}], {kind: 'system', id: 'merge'},
110
- 'ledger:' + theirs.ledger.sha256, 'document.merge', Math.max(0, ...incoming.map(record => record.transaction.createdAt)));
246
+ // Text equality cannot replace the unproved original acts with a clean receipt.
247
+ if (!conflicts.length) return refused('history', 'record_transport_conflict');
248
+ if (merged.text !== text) append([{pos: 0, removed: text, inserted: merged.text}], {transaction: {
249
+ id: mergeIdentity(ours.ledger.sha256, theirs.ledger.sha256), actor: {kind: 'system', id: 'merge'}, transport: 'platform', operation: 'document.merge', requestId: null,
250
+ affectedBlockIds: [], createdAt: null}});
111
251
  }
112
- const ledger = exportLedger({text, records, documentAuthority: ours.ledger.documentAuthority, root,
252
+ const ledger = exportLedger({text, metadata, records, documentAuthority: ours.ledger.documentAuthority, root,
113
253
  revision: records.at(-1)?.transaction.revision ?? ours.revision, complete: ours.complete});
114
254
  const clean = !conflicts.length && !text.includes('<!-- note-conflict:v1 ');
115
- return {text, ledger, clean, conflicts, review: {required: !clean, base: ours.text, proposed: text}};
255
+ return {text, metadata, ledger, clean, conflicts, review: {required: !clean, base: ours.text, proposed: text}};
116
256
  }