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.
@@ -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
- function setLastDiskText(entry, text) {
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, onDisk, 'disk');
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
- ensureSchema(database);
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: { yjs: Buffer.from(update).toString('base64') },
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
  };