amalgm 0.1.152 → 0.1.153
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/lib/tunnel-events.js +268 -49
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +4 -0
- package/runtime/scripts/amalgm-mcp/state/app-resources.js +2 -21
- package/runtime/scripts/amalgm-mcp/state/db.js +50 -0
- package/runtime/scripts/amalgm-mcp/state/docs.js +351 -31
- package/runtime/scripts/amalgm-mcp/state/merge3.js +197 -0
- package/runtime/scripts/amalgm-mcp/state/rest.js +79 -20
- package/runtime/scripts/amalgm-mcp/tests/browser-transport.test.js +11 -1
- package/runtime/scripts/amalgm-mcp/tests/state-docs-merge.test.js +251 -0
- package/runtime/scripts/amalgm-mcp/tests/state-docs-versioning.test.js +394 -0
- package/runtime/scripts/amalgm-mcp/tests/state-stream-bootstrap.test.js +223 -0
|
@@ -29,13 +29,24 @@
|
|
|
29
29
|
* when disk content changed past the last text we read or wrote
|
|
30
30
|
* (entry.lastDiskText) — our own write-backs and stale watcher events can
|
|
31
31
|
* never splice, so they can never re-emit.
|
|
32
|
+
*
|
|
33
|
+
* When the doc has ALSO diverged from lastDiskText (live edits inside the
|
|
34
|
+
* write-back debounce window), splicing disk text wholesale would delete
|
|
35
|
+
* those edits. Instead the disk change three-way merges against
|
|
36
|
+
* lastDiskText as the base (see ./merge3): non-overlapping hunks from both
|
|
37
|
+
* sides land automatically; overlapping hunks keep the live doc's text, and
|
|
38
|
+
* the full incoming disk text is preserved in a `<name>.conflicted-<stamp>`
|
|
39
|
+
* sidecar next to the file, with a `conflict` field on the emitted doc
|
|
40
|
+
* event. The next write-back then puts the merged text on disk — the
|
|
41
|
+
* incoming version it replaces lives on in the sidecar.
|
|
32
42
|
*/
|
|
33
43
|
|
|
34
44
|
const crypto = require('crypto');
|
|
35
45
|
const fs = require('fs');
|
|
36
46
|
const path = require('path');
|
|
37
|
-
const { cachedStatement, openLocalDb } = require('./db');
|
|
47
|
+
const { cachedStatement, ensureDocStatesSchema, openLocalDb } = require('./db');
|
|
38
48
|
const { appendStateEvent, currentSeq } = require('./events');
|
|
49
|
+
const { merge3 } = require('./merge3');
|
|
39
50
|
|
|
40
51
|
const DOC_RESOURCE_PREFIX = 'doc:';
|
|
41
52
|
const TEXT_KEY = 'content';
|
|
@@ -45,6 +56,27 @@ const DISK_WRITE_DEBOUNCE_MS = 300;
|
|
|
45
56
|
const WATCH_RECONCILE_DEBOUNCE_MS = 200;
|
|
46
57
|
const MAX_UPDATES_PER_CALL = 200;
|
|
47
58
|
|
|
59
|
+
// Bounds on the per-doc disk-sync history ring (see pushDiskHistory): a small
|
|
60
|
+
// window of recent {text, sha256, seq, origin} states that versioned writers
|
|
61
|
+
// resolve their declared base against, and that the unversioned-overwrite
|
|
62
|
+
// guard uses to tell "live edits since the last disk state" from "the agent
|
|
63
|
+
// just rewrote its own file". In-memory only; dies with the doc entry.
|
|
64
|
+
const MAX_HISTORY_ENTRIES = 8;
|
|
65
|
+
const MAX_HISTORY_BYTES = 4 * 1024 * 1024;
|
|
66
|
+
const MAX_HISTORY_AGE_MS = 10 * 60 * 1000;
|
|
67
|
+
|
|
68
|
+
// Example payloads embedded in validation errors (example-first: the fastest
|
|
69
|
+
// way to tell a writer what shape the call wants is to show a correct one).
|
|
70
|
+
const WRITE_EXAMPLE = '{"path": "notes.md", "text": "full new file contents", '
|
|
71
|
+
+ '"baseHash": "<sha256 hex of the exact text you read>"}';
|
|
72
|
+
|
|
73
|
+
// Conflict sidecars: `notes.conflicted-20260713-104501.md` next to `notes.md`
|
|
74
|
+
// (a `-2`, `-3`… suffix on the stamp if the second collides). The pattern is
|
|
75
|
+
// what keeps them inert — the directory watcher skips matching names and
|
|
76
|
+
// resolveDocPath refuses to open them, so a sidecar can never become a live
|
|
77
|
+
// doc or feed the reconcile loop.
|
|
78
|
+
const SIDECAR_NAME_RE = /\.conflicted-\d{8}-\d{6}(?:-\d+)?(?:\.[^./\\]*)?$/;
|
|
79
|
+
|
|
48
80
|
// resolved path -> { doc, resource, reconcileTimer, diskWriteTimer,
|
|
49
81
|
// lastAccess, lastDiskText, lastDiskHash, pathAliases }
|
|
50
82
|
const openDocs = new Map();
|
|
@@ -80,36 +112,65 @@ function invalid(message, statusCode = 400) {
|
|
|
80
112
|
return Object.assign(new Error(message), { statusCode });
|
|
81
113
|
}
|
|
82
114
|
|
|
83
|
-
function ensureSchema(database) {
|
|
84
|
-
database.exec(`
|
|
85
|
-
CREATE TABLE IF NOT EXISTS doc_states (
|
|
86
|
-
path TEXT PRIMARY KEY,
|
|
87
|
-
state BLOB NOT NULL,
|
|
88
|
-
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
89
|
-
);
|
|
90
|
-
`);
|
|
91
|
-
// last_disk_hash records the disk content as we last read or wrote it.
|
|
92
|
-
// On reopen it distinguishes "disk changed externally" (fold it in) from
|
|
93
|
-
// "disk is merely behind our persisted state" (write the doc back out) —
|
|
94
|
-
// without it, a restart would revert edits that hadn't hit the debounced
|
|
95
|
-
// disk write yet.
|
|
96
|
-
try {
|
|
97
|
-
database.exec('ALTER TABLE doc_states ADD COLUMN last_disk_hash TEXT');
|
|
98
|
-
} catch {
|
|
99
|
-
// Column already exists.
|
|
100
|
-
}
|
|
101
|
-
}
|
|
102
|
-
|
|
103
115
|
function hashText(text) {
|
|
104
116
|
return crypto.createHash('sha256').update(text, 'utf8').digest('hex');
|
|
105
117
|
}
|
|
106
118
|
|
|
107
119
|
// The only way lastDiskText is ever assigned: the hash is what persistence
|
|
108
120
|
// writes, and hashing at assignment (rare: open, reconcile, flush) keeps the
|
|
109
|
-
// full-text SHA-256 off the per-batch persist path.
|
|
110
|
-
|
|
121
|
+
// full-text SHA-256 off the per-batch persist path. `origin` says how the
|
|
122
|
+
// text got onto disk — 'disk' (external write folded in), 'flush' (the live
|
|
123
|
+
// doc's own write-back), 'open' (seed of an unchanged file) — and is what the
|
|
124
|
+
// overwrite guard reads: a 'flush' head means the last disk state was
|
|
125
|
+
// produced through the live channel.
|
|
126
|
+
function setLastDiskText(entry, text, origin) {
|
|
111
127
|
entry.lastDiskText = text;
|
|
112
128
|
entry.lastDiskHash = text == null ? null : hashText(text);
|
|
129
|
+
if (text != null) pushDiskHistory(entry, text, entry.lastDiskHash, origin || 'disk');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Retain the new disk-sync state in the entry's bounded ring. `seq` is the
|
|
133
|
+
// event-log position at record time so versioned writers can name a base by
|
|
134
|
+
// the seq they observed. Identical text refreshes the head (seq/timestamp)
|
|
135
|
+
// instead of duplicating it — which also guarantees a 'flush' head really
|
|
136
|
+
// does differ from the previous entry (i.e. live edits happened).
|
|
137
|
+
function pushDiskHistory(entry, text, sha256, origin) {
|
|
138
|
+
const history = entry.diskHistory;
|
|
139
|
+
const head = history[history.length - 1];
|
|
140
|
+
if (head && head.sha256 === sha256) {
|
|
141
|
+
head.seq = currentSeq();
|
|
142
|
+
head.at = Date.now();
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
history.push({ text, sha256, seq: currentSeq(), at: Date.now(), origin });
|
|
146
|
+
const cutoff = Date.now() - MAX_HISTORY_AGE_MS;
|
|
147
|
+
let bytes = 0;
|
|
148
|
+
for (const item of history) bytes += Buffer.byteLength(item.text, 'utf8');
|
|
149
|
+
// The head (current state) is never trimmed; older entries fall off by
|
|
150
|
+
// count, age, or total bytes.
|
|
151
|
+
while (history.length > 1 && (
|
|
152
|
+
history.length > MAX_HISTORY_ENTRIES
|
|
153
|
+
|| history[0].at < cutoff
|
|
154
|
+
|| bytes > MAX_HISTORY_BYTES
|
|
155
|
+
)) {
|
|
156
|
+
bytes -= Buffer.byteLength(history[0].text, 'utf8');
|
|
157
|
+
history.shift();
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// True when the current head disk state was written by the live channel
|
|
162
|
+
// (flush) — meaning edits arrived through Yjs since the previous disk state,
|
|
163
|
+
// and wholesale-replacing that text would destroy them. A 'disk'/'open' head
|
|
164
|
+
// means disk itself produced the last state: nothing live is at risk and
|
|
165
|
+
// overwrites stay silent (the common agent-rewrites-its-own-file case).
|
|
166
|
+
function liveEditsSinceLastDiskState(entry) {
|
|
167
|
+
const history = entry.diskHistory;
|
|
168
|
+
const head = history[history.length - 1];
|
|
169
|
+
if (!head || head.origin !== 'flush') return false;
|
|
170
|
+
const previous = history[history.length - 2];
|
|
171
|
+
// pushDiskHistory dedupes identical text, so a 'flush' head implies a real
|
|
172
|
+
// live-channel change; the comparison is belt-and-braces for a trimmed ring.
|
|
173
|
+
return !previous || previous.text !== head.text;
|
|
113
174
|
}
|
|
114
175
|
|
|
115
176
|
function docResourceName(absolutePath) {
|
|
@@ -127,6 +188,9 @@ function pathFromDocResource(resource) {
|
|
|
127
188
|
|
|
128
189
|
function resolveDocPath(pathInput) {
|
|
129
190
|
const resolved = fsPrivate().resolveSafePathSync(pathInput);
|
|
191
|
+
if (isConflictSidecar(path.basename(resolved))) {
|
|
192
|
+
throw invalid('conflict sidecar files cannot be opened as live documents');
|
|
193
|
+
}
|
|
130
194
|
const { isText } = fsPrivate().classifyFile(resolved);
|
|
131
195
|
if (!isText) {
|
|
132
196
|
throw invalid(`live documents support text files only (got ${path.extname(resolved) || 'no extension'})`);
|
|
@@ -205,25 +269,120 @@ function flushDocToDisk(Y, entry) {
|
|
|
205
269
|
const text = entry.doc.getText(TEXT_KEY).toString();
|
|
206
270
|
const onDisk = fs.readFileSync(entry.path, 'utf8');
|
|
207
271
|
if (onDisk !== text) fs.writeFileSync(entry.path, text, 'utf8');
|
|
208
|
-
setLastDiskText(entry, text);
|
|
272
|
+
setLastDiskText(entry, text, 'flush');
|
|
209
273
|
persistDocState(Y, entry);
|
|
210
274
|
} catch (error) {
|
|
211
275
|
console.warn(`[LiveDoc] Failed to flush "${entry.path}" to disk:`, error?.message || error);
|
|
212
276
|
}
|
|
213
277
|
}
|
|
214
278
|
|
|
279
|
+
function isConflictSidecar(name) {
|
|
280
|
+
return SIDECAR_NAME_RE.test(name);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function conflictSidecarPath(docPath) {
|
|
284
|
+
const dir = path.dirname(docPath);
|
|
285
|
+
const ext = path.extname(docPath);
|
|
286
|
+
const stem = path.basename(docPath, ext);
|
|
287
|
+
const now = new Date();
|
|
288
|
+
const pad = (value) => String(value).padStart(2, '0');
|
|
289
|
+
const stamp = `${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}`
|
|
290
|
+
+ `-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`;
|
|
291
|
+
let candidate = path.join(dir, `${stem}.conflicted-${stamp}${ext}`);
|
|
292
|
+
for (let n = 2; fs.existsSync(candidate); n += 1) {
|
|
293
|
+
candidate = path.join(dir, `${stem}.conflicted-${stamp}-${n}${ext}`);
|
|
294
|
+
}
|
|
295
|
+
return candidate;
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Preserve a losing version of the document next to the file. Throws on
|
|
299
|
+
// failure — every caller then aborts before applying, which is the safe
|
|
300
|
+
// posture: nothing is replaced until its predecessor is durably preserved.
|
|
301
|
+
// Applying without the sidecar would be the silent-loss bug.
|
|
302
|
+
function writePreservationSidecar(entry, text, why) {
|
|
303
|
+
const sidecarPath = conflictSidecarPath(entry.path);
|
|
304
|
+
fs.writeFileSync(sidecarPath, text, 'utf8');
|
|
305
|
+
console.warn(`[LiveDoc] CONFLICT path="${entry.path}" sidecar="${sidecarPath}" — ${why}`);
|
|
306
|
+
return sidecarPath;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
// Overlapping-hunk merge: the merged text keeps OURS for conflicted regions,
|
|
310
|
+
// so THEIRS (the full incoming text) is what needs preserving.
|
|
311
|
+
function writeConflictSidecar(entry, theirsText, hunkCount) {
|
|
312
|
+
return writePreservationSidecar(
|
|
313
|
+
entry,
|
|
314
|
+
theirsText,
|
|
315
|
+
`${hunkCount} overlapping hunk(s); conflicted regions keep the live doc, `
|
|
316
|
+
+ 'full incoming text preserved in the sidecar',
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
215
320
|
function reconcileFromDisk(Y, entry) {
|
|
216
321
|
try {
|
|
217
322
|
const onDisk = fs.readFileSync(entry.path, 'utf8');
|
|
218
323
|
// Only fold in disk content that actually CHANGED since we last read or
|
|
219
324
|
// wrote it. Without this, a watcher event firing while the doc is ahead
|
|
220
325
|
// of disk (write-back still debounced) would "reconcile" the stale disk
|
|
221
|
-
// text back over live edits.
|
|
326
|
+
// text back over live edits. (This is also the THEIRS === BASE no-op.)
|
|
222
327
|
if (onDisk === entry.lastDiskText) return;
|
|
223
|
-
setLastDiskText(entry, onDisk);
|
|
224
328
|
const ytext = entry.doc.getText(TEXT_KEY);
|
|
329
|
+
const ours = ytext.toString();
|
|
330
|
+
const base = entry.lastDiskText;
|
|
331
|
+
let nextText = onDisk;
|
|
332
|
+
// OURS === BASE (no live divergence) or no base at all (first seed):
|
|
333
|
+
// disk text applies wholesale, exactly as before. Otherwise both sides
|
|
334
|
+
// moved since the last sync — three-way merge against BASE so a writer
|
|
335
|
+
// that read the file before our live edits cannot delete them.
|
|
336
|
+
if (base != null && ours !== base && ours !== onDisk) {
|
|
337
|
+
const merged = merge3(base, ours, onDisk);
|
|
338
|
+
nextText = merged.text;
|
|
339
|
+
if (merged.conflicts.length > 0) {
|
|
340
|
+
const sidecarPath = writeConflictSidecar(entry, onDisk, merged.conflicts.length);
|
|
341
|
+
entry.pendingConflict = {
|
|
342
|
+
reason: 'overlapping-hunks',
|
|
343
|
+
sidecarPath,
|
|
344
|
+
hunks: merged.conflicts.length,
|
|
345
|
+
at: new Date().toISOString(),
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
} else if (ours !== onDisk && liveEditsSinceLastDiskState(entry)) {
|
|
349
|
+
// Wholesale accept, but the text it replaces was produced through the
|
|
350
|
+
// live channel and already flushed (ours === lastDiskText, so the merge
|
|
351
|
+
// gate above sees no divergence — this was the silent-loss window). A
|
|
352
|
+
// raw fs write carries no base, so lineage is genuinely ambiguous: the
|
|
353
|
+
// writer keeps the freedom to rewrite the file, but the replaced live
|
|
354
|
+
// text is preserved first and the overwrite is surfaced, never silent.
|
|
355
|
+
const sidecarPath = writePreservationSidecar(
|
|
356
|
+
entry,
|
|
357
|
+
ours,
|
|
358
|
+
'unversioned disk write replaced flushed live edits; '
|
|
359
|
+
+ 'replaced text preserved in the sidecar',
|
|
360
|
+
);
|
|
361
|
+
entry.pendingConflict = {
|
|
362
|
+
reason: 'unversioned-overwrite',
|
|
363
|
+
sidecarPath,
|
|
364
|
+
at: new Date().toISOString(),
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
setLastDiskText(entry, onDisk, 'disk');
|
|
225
368
|
// origin 'disk' keeps the update handler from writing the same bytes back.
|
|
226
|
-
spliceTextInto(Y, entry.doc, ytext,
|
|
369
|
+
const spliced = spliceTextInto(Y, entry.doc, ytext, nextText, 'disk');
|
|
370
|
+
if (!spliced && entry.pendingConflict) {
|
|
371
|
+
// Every external hunk conflicted, so the doc text is unchanged and no
|
|
372
|
+
// update event fired — publish the conflict on its own.
|
|
373
|
+
appendStateEvent({
|
|
374
|
+
resource: entry.resource,
|
|
375
|
+
op: 'update',
|
|
376
|
+
id: entry.path,
|
|
377
|
+
patch: { conflict: entry.pendingConflict },
|
|
378
|
+
source: 'doc:disk',
|
|
379
|
+
});
|
|
380
|
+
entry.pendingConflict = null;
|
|
381
|
+
}
|
|
382
|
+
// A merge that diverged from what's on disk must write back: disk still
|
|
383
|
+
// holds THEIRS (preserved in the sidecar when it conflicted); the file
|
|
384
|
+
// artifact follows the doc.
|
|
385
|
+
if (nextText !== onDisk) scheduleDiskWrite(Y, entry);
|
|
227
386
|
// Persist even for a no-op splice: the refreshed lastDiskText hash still
|
|
228
387
|
// needs recording.
|
|
229
388
|
persistDocState(Y, entry);
|
|
@@ -260,7 +419,7 @@ function seedFromDisk(Y, entry, persisted) {
|
|
|
260
419
|
reconcileFromDisk(Y, entry);
|
|
261
420
|
return;
|
|
262
421
|
}
|
|
263
|
-
setLastDiskText(entry, onDisk);
|
|
422
|
+
setLastDiskText(entry, onDisk, 'open');
|
|
264
423
|
if (entry.doc.getText(TEXT_KEY).toString() !== onDisk) {
|
|
265
424
|
scheduleDiskWrite(Y, entry);
|
|
266
425
|
}
|
|
@@ -286,6 +445,9 @@ function watchDocFile(Y, entry) {
|
|
|
286
445
|
// and idempotent, so fanning out to every doc in the directory is
|
|
287
446
|
// safe when we cannot tell which file changed.
|
|
288
447
|
if (filename) {
|
|
448
|
+
// Conflict sidecars live in the doc's directory but must never feed
|
|
449
|
+
// the reconcile loop.
|
|
450
|
+
if (isConflictSidecar(filename)) return;
|
|
289
451
|
const target = docs.get(filename);
|
|
290
452
|
if (target) scheduleReconcile(Y, target);
|
|
291
453
|
return;
|
|
@@ -370,7 +532,7 @@ function loadDocEntry(pathInput) {
|
|
|
370
532
|
}
|
|
371
533
|
|
|
372
534
|
const database = openLocalDb();
|
|
373
|
-
|
|
535
|
+
ensureDocStatesSchema(database);
|
|
374
536
|
|
|
375
537
|
const doc = new Y.Doc();
|
|
376
538
|
const entry = {
|
|
@@ -385,6 +547,14 @@ function loadDocEntry(pathInput) {
|
|
|
385
547
|
// setLastDiskText so the hash that persistence writes stays in step.
|
|
386
548
|
lastDiskText: null,
|
|
387
549
|
lastDiskHash: null,
|
|
550
|
+
// Bounded ring of recent disk-sync states {text, sha256, seq, at, origin}
|
|
551
|
+
// (see pushDiskHistory): the base-resolution window for versioned writes
|
|
552
|
+
// and the live-edit detector for unversioned overwrites. Newest last.
|
|
553
|
+
diskHistory: [],
|
|
554
|
+
// Set by reconcileFromDisk when a three-way merge hit overlapping hunks;
|
|
555
|
+
// consumed by the very next disk-origin update event (the merge splice is
|
|
556
|
+
// synchronous) so the conflict rides the doc's own event patch.
|
|
557
|
+
pendingConflict: null,
|
|
388
558
|
// Every pathInput form seen for this doc, so close can clear its
|
|
389
559
|
// resolvedPathCache entries.
|
|
390
560
|
pathAliases: new Set([pathInput]),
|
|
@@ -411,12 +581,19 @@ function loadDocEntry(pathInput) {
|
|
|
411
581
|
// acknowledged edit; at worst the disk artifact lags by the debounce window
|
|
412
582
|
// until the doc is next opened.
|
|
413
583
|
doc.on('update', (update, origin) => {
|
|
584
|
+
const patch = { yjs: Buffer.from(update).toString('base64') };
|
|
585
|
+
// Disk reconciles and versioned writes both splice synchronously right
|
|
586
|
+
// after setting pendingConflict, so the conflict rides this event.
|
|
587
|
+
if ((origin === 'disk' || origin === 'write') && entry.pendingConflict) {
|
|
588
|
+
patch.conflict = entry.pendingConflict;
|
|
589
|
+
entry.pendingConflict = null;
|
|
590
|
+
}
|
|
414
591
|
appendStateEvent({
|
|
415
592
|
resource: entry.resource,
|
|
416
593
|
op: 'update',
|
|
417
594
|
id: entry.path,
|
|
418
|
-
patch
|
|
419
|
-
source: origin === 'disk' ? 'doc:disk' : 'doc:update',
|
|
595
|
+
patch,
|
|
596
|
+
source: origin === 'disk' ? 'doc:disk' : origin === 'write' ? 'doc:write' : 'doc:update',
|
|
420
597
|
});
|
|
421
598
|
if (origin !== 'disk') scheduleDiskWrite(Y, entry);
|
|
422
599
|
});
|
|
@@ -440,10 +617,150 @@ function openDoc(pathInput) {
|
|
|
440
617
|
resource: entry.resource,
|
|
441
618
|
path: entry.path,
|
|
442
619
|
state: Buffer.from(Y.encodeStateAsUpdate(entry.doc)).toString('base64'),
|
|
620
|
+
// The versioned-write handshake: hash of the exact current text; a writer
|
|
621
|
+
// holds onto it and sends it back as `baseHash` so its later write can be
|
|
622
|
+
// safely three-way merged no matter what landed in between.
|
|
623
|
+
sha256: hashText(entry.doc.getText(TEXT_KEY).toString()),
|
|
443
624
|
seq: currentSeq(),
|
|
444
625
|
};
|
|
445
626
|
}
|
|
446
627
|
|
|
628
|
+
/**
|
|
629
|
+
* Resolve a writer-declared base to the exact text it read. `baseHash` is the
|
|
630
|
+
* authoritative claim (hash of the exact text) and is checked against the
|
|
631
|
+
* current live text first, then the retained disk-sync history. `baseSeq` is
|
|
632
|
+
* only consulted when no hash was sent: it must exactly match a retained
|
|
633
|
+
* entry's recorded seq. Returns null when the declared base is unknown or has
|
|
634
|
+
* aged out of the ring — the caller then degrades to the conservative path.
|
|
635
|
+
*/
|
|
636
|
+
function resolveBaseText(entry, oursText, baseHash, baseSeq) {
|
|
637
|
+
if (baseHash !== undefined) {
|
|
638
|
+
if (baseHash === hashText(oursText)) return oursText;
|
|
639
|
+
const match = entry.diskHistory.find((item) => item.sha256 === baseHash);
|
|
640
|
+
return match ? match.text : null;
|
|
641
|
+
}
|
|
642
|
+
if (baseSeq !== undefined) {
|
|
643
|
+
const match = entry.diskHistory.find((item) => item.seq === baseSeq);
|
|
644
|
+
return match ? match.text : null;
|
|
645
|
+
}
|
|
646
|
+
return null;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Versioned whole-text write — the SDK write contract (POST /state/docs/write).
|
|
651
|
+
*
|
|
652
|
+
* `options.baseHash` (sha256 hex of the exact text the writer read; preferred)
|
|
653
|
+
* or `options.baseSeq` (the seq at which it read) declare the write's lineage:
|
|
654
|
+
* - base == current text → plain apply (merge: 'clean')
|
|
655
|
+
* - base in retained history → three-way merge; non-overlapping edits
|
|
656
|
+
* both land (merge: 'merged'); overlapping regions keep the live doc,
|
|
657
|
+
* incoming text preserved in a sidecar + conflict on the doc event
|
|
658
|
+
* (merge: 'conflict', reason 'overlapping-hunks')
|
|
659
|
+
* - base unknown / aged out → conservative: current live text preserved
|
|
660
|
+
* to a sidecar, incoming applies, conflict surfaced
|
|
661
|
+
* (merge: 'base-unknown', reason 'base-unknown')
|
|
662
|
+
* - no base sent → unversioned overwrite (merge: 'overwrite'):
|
|
663
|
+
* silent when nothing arrived through the live channel since the last disk
|
|
664
|
+
* sync; otherwise replaced text preserved + conflict surfaced
|
|
665
|
+
* (reason 'unversioned-overwrite')
|
|
666
|
+
* Never a silent winner: any path that discards text preserves it first.
|
|
667
|
+
*/
|
|
668
|
+
function writeDocText(pathInput, textInput, options = {}) {
|
|
669
|
+
const Y = loadYjs();
|
|
670
|
+
if (typeof textInput !== 'string') {
|
|
671
|
+
throw invalid(`"text" must be the full replacement file contents as a string, e.g. ${WRITE_EXAMPLE} (got ${typeof textInput})`);
|
|
672
|
+
}
|
|
673
|
+
if (Buffer.byteLength(textInput, 'utf8') > MAX_DOC_BYTES) {
|
|
674
|
+
throw invalid(`"text" exceeds the ${MAX_DOC_BYTES}-byte live document limit`, 413);
|
|
675
|
+
}
|
|
676
|
+
const { baseHash, baseSeq } = options;
|
|
677
|
+
if (baseHash !== undefined && !(typeof baseHash === 'string' && /^[0-9a-f]{64}$/.test(baseHash))) {
|
|
678
|
+
throw invalid(`"baseHash" must be the lowercase sha256 hex (64 chars) of the exact text you read — the "sha256" field returned by open/write, e.g. ${WRITE_EXAMPLE} (got ${JSON.stringify(baseHash)})`);
|
|
679
|
+
}
|
|
680
|
+
if (baseSeq !== undefined && !(Number.isInteger(baseSeq) && baseSeq >= 0)) {
|
|
681
|
+
throw invalid(`"baseSeq" must be the non-negative integer event seq at which you read the doc, e.g. {"path": "notes.md", "text": "...", "baseSeq": 42} (got ${JSON.stringify(baseSeq)})`);
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
const entry = loadDocEntry(pathInput);
|
|
685
|
+
entry.lastAccess = Date.now();
|
|
686
|
+
const ytext = entry.doc.getText(TEXT_KEY);
|
|
687
|
+
const ours = ytext.toString();
|
|
688
|
+
|
|
689
|
+
const respond = (merge, finalText, conflict) => ({
|
|
690
|
+
merge,
|
|
691
|
+
seq: currentSeq(),
|
|
692
|
+
sha256: hashText(finalText),
|
|
693
|
+
conflict: conflict || null,
|
|
694
|
+
});
|
|
695
|
+
|
|
696
|
+
if (textInput === ours) return respond('noop', ours, null);
|
|
697
|
+
|
|
698
|
+
let merge;
|
|
699
|
+
let nextText = textInput;
|
|
700
|
+
let conflict = null;
|
|
701
|
+
if (baseHash !== undefined || baseSeq !== undefined) {
|
|
702
|
+
const baseText = resolveBaseText(entry, ours, baseHash, baseSeq);
|
|
703
|
+
if (baseText != null) {
|
|
704
|
+
const merged = merge3(baseText, ours, textInput);
|
|
705
|
+
nextText = merged.text;
|
|
706
|
+
merge = merged.conflicts.length > 0 ? 'conflict' : baseText === ours ? 'clean' : 'merged';
|
|
707
|
+
if (merged.conflicts.length > 0) {
|
|
708
|
+
const sidecarPath = writeConflictSidecar(entry, textInput, merged.conflicts.length);
|
|
709
|
+
conflict = {
|
|
710
|
+
reason: 'overlapping-hunks',
|
|
711
|
+
sidecarPath,
|
|
712
|
+
hunks: merged.conflicts.length,
|
|
713
|
+
at: new Date().toISOString(),
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
} else {
|
|
717
|
+
// The declared base is not the current text and not in the retention
|
|
718
|
+
// window: merging against a guess could mangle both sides, so preserve
|
|
719
|
+
// the current text durably, let the write land, and surface it.
|
|
720
|
+
merge = 'base-unknown';
|
|
721
|
+
const sidecarPath = writePreservationSidecar(
|
|
722
|
+
entry,
|
|
723
|
+
ours,
|
|
724
|
+
'versioned write declared a base outside the retention window; '
|
|
725
|
+
+ 'replaced live text preserved in the sidecar',
|
|
726
|
+
);
|
|
727
|
+
conflict = { reason: 'base-unknown', sidecarPath, at: new Date().toISOString() };
|
|
728
|
+
}
|
|
729
|
+
} else {
|
|
730
|
+
// No base declared: same conservative posture as a raw disk write —
|
|
731
|
+
// silent when the replaced text is just the last disk state, preserved
|
|
732
|
+
// and surfaced when live-channel edits would be destroyed.
|
|
733
|
+
merge = 'overwrite';
|
|
734
|
+
if (ours !== entry.lastDiskText || liveEditsSinceLastDiskState(entry)) {
|
|
735
|
+
const sidecarPath = writePreservationSidecar(
|
|
736
|
+
entry,
|
|
737
|
+
ours,
|
|
738
|
+
'unversioned API write replaced live edits; replaced text preserved in the sidecar',
|
|
739
|
+
);
|
|
740
|
+
conflict = { reason: 'unversioned-overwrite', sidecarPath, at: new Date().toISOString() };
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
entry.pendingConflict = conflict;
|
|
745
|
+
// origin 'write' rides the conflict on the emitted doc event and schedules
|
|
746
|
+
// the disk write-back (unlike 'disk', which must not echo to disk).
|
|
747
|
+
const spliced = spliceTextInto(Y, entry.doc, ytext, nextText, 'write');
|
|
748
|
+
if (!spliced && entry.pendingConflict) {
|
|
749
|
+
// Every incoming hunk conflicted: the doc text is unchanged, no update
|
|
750
|
+
// event fired — publish the conflict on its own (mirrors reconcile).
|
|
751
|
+
appendStateEvent({
|
|
752
|
+
resource: entry.resource,
|
|
753
|
+
op: 'update',
|
|
754
|
+
id: entry.path,
|
|
755
|
+
patch: { conflict: entry.pendingConflict },
|
|
756
|
+
source: 'doc:write',
|
|
757
|
+
});
|
|
758
|
+
entry.pendingConflict = null;
|
|
759
|
+
}
|
|
760
|
+
persistDocState(Y, entry);
|
|
761
|
+
return respond(merge, nextText, conflict);
|
|
762
|
+
}
|
|
763
|
+
|
|
447
764
|
function applyDocUpdates(pathInput, updatesInput) {
|
|
448
765
|
const Y = loadYjs();
|
|
449
766
|
const inputs = Array.isArray(updatesInput) ? updatesInput : [updatesInput];
|
|
@@ -528,4 +845,7 @@ module.exports = {
|
|
|
528
845
|
openDoc,
|
|
529
846
|
pathFromDocResource,
|
|
530
847
|
readDocResource,
|
|
848
|
+
writeDocText,
|
|
849
|
+
// Test hooks only — not part of the module contract.
|
|
850
|
+
_private: { openDocs },
|
|
531
851
|
};
|