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.
- package/README.md +71 -37
- package/dist/agent/diff.mjs +52 -0
- package/dist/kit/ledger/authorship.mjs +3 -3
- package/dist/kit/ledger/carried.mjs +10 -10
- package/dist/kit/ledger/format.mjs +44 -28
- package/dist/kit/ledger/history.mjs +526 -0
- package/dist/kit/ledger/journal-records.mjs +216 -45
- package/dist/kit/ledger/merge.mjs +191 -51
- package/dist/kit/ledger/transport.mjs +318 -0
- package/dist/kit/rapier-ledger.mjs +12 -2
- package/dist/kit/render-markdown.mjs +227 -109
- package/dist/kit/render-styles.mjs +5 -4
- package/dist/kit/render-work.mjs +145 -0
- package/dist/kit/render.mjs +249 -76
- package/package.json +1 -1
|
@@ -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)
|
|
37
|
-
const
|
|
38
|
-
const
|
|
39
|
-
const target = targetId &&
|
|
40
|
-
if (!target
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
})) :
|
|
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
|
-
|
|
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
|
|
94
|
-
const own = Array.isArray(record.splices)
|
|
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
|
-
|
|
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
|
-
//
|
|
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,
|
|
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
|
|
125
|
-
value.every(id => identity(id,
|
|
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)
|
|
131
|
-
!Array.isArray(transaction.affectedBlockIds) ||
|
|
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
|
-
|
|
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,
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
56
|
-
if (
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
const
|
|
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)
|
|
148
|
+
if (next === null) return null;
|
|
72
149
|
const beforeHash = root, revision = (records.at(-1)?.transaction.revision ?? ours.ledger.start.revision) + 1;
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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)
|
|
173
|
+
let against = ours.ledger.records.slice(common.ours);
|
|
82
174
|
const incoming = theirs.ledger.records.slice(common.theirs);
|
|
83
|
-
//
|
|
84
|
-
const unseen = incoming.filter(record => !imported.has(
|
|
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
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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
|
-
|
|
110
|
-
|
|
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
|
}
|