amalgm 0.1.156 → 0.1.157
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/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/state/db.js +8 -0
- package/runtime/scripts/amalgm-mcp/state/docs.js +211 -132
- package/runtime/scripts/amalgm-mcp/state/mutations.js +20 -0
- package/runtime/scripts/amalgm-mcp/state/promotions.js +54 -2
- package/runtime/scripts/amalgm-mcp/state/replicas.js +2 -0
- package/runtime/scripts/amalgm-mcp/state/trace.js +26 -0
- package/runtime/scripts/amalgm-mcp/tests/state-docs-versioning.test.js +61 -0
- package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +182 -0
package/package.json
CHANGED
|
@@ -186,6 +186,11 @@ function migrate(database = openLocalDb()) {
|
|
|
186
186
|
next_attempt_at TEXT,
|
|
187
187
|
last_error TEXT,
|
|
188
188
|
snapshot_key TEXT,
|
|
189
|
+
-- Promotion fence: MAX(journal_id) when the snapshot was captured.
|
|
190
|
+
-- Local mutations journaled past this cursor are not in the snapshot;
|
|
191
|
+
-- acknowledgePromotion re-homes them onto the shared channel so edits
|
|
192
|
+
-- made while sharing was in flight reach the cloud outbox.
|
|
193
|
+
journal_cursor INTEGER NOT NULL DEFAULT 0,
|
|
189
194
|
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
190
195
|
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
191
196
|
);
|
|
@@ -604,6 +609,9 @@ function migrate(database = openLocalDb()) {
|
|
|
604
609
|
// untouched while new SDK mutations carry their stable identity.
|
|
605
610
|
ensureColumn(database, 'event_log', 'mutation_id', 'TEXT');
|
|
606
611
|
ensureColumn(database, 'event_log', 'shared_resource_id', 'TEXT');
|
|
612
|
+
// Wave-3 canaries created shared_resource_promotions before the fence
|
|
613
|
+
// cursor existed.
|
|
614
|
+
ensureColumn(database, 'shared_resource_promotions', 'journal_cursor', 'INTEGER NOT NULL DEFAULT 0');
|
|
607
615
|
require('./migrations').runLegacyMigrations(database);
|
|
608
616
|
}
|
|
609
617
|
|
|
@@ -47,6 +47,7 @@ const path = require('path');
|
|
|
47
47
|
const { cachedStatement, ensureDocStatesSchema, openLocalDb } = require('./db');
|
|
48
48
|
const { appendStateEvent, currentSeq } = require('./events');
|
|
49
49
|
const { merge3 } = require('./merge3');
|
|
50
|
+
const { trace } = require('./trace');
|
|
50
51
|
|
|
51
52
|
const DOC_RESOURCE_PREFIX = 'doc:';
|
|
52
53
|
const TEXT_KEY = 'content';
|
|
@@ -208,13 +209,12 @@ function resolveDocPath(pathInput) {
|
|
|
208
209
|
}
|
|
209
210
|
|
|
210
211
|
/**
|
|
211
|
-
*
|
|
212
|
-
*
|
|
212
|
+
* The minimal edit turning `current` into `nextText`: common prefix and
|
|
213
|
+
* suffix kept, backed off surrogate-pair boundaries so a character is never
|
|
214
|
+
* split. `[start, endCurrent)` is the replaced region of `current`;
|
|
215
|
+
* `[start, endNext)` is its replacement in `nextText`.
|
|
213
216
|
*/
|
|
214
|
-
function
|
|
215
|
-
const current = ytext.toString();
|
|
216
|
-
if (current === nextText) return false;
|
|
217
|
-
|
|
217
|
+
function spliceBounds(current, nextText) {
|
|
218
218
|
let start = 0;
|
|
219
219
|
const maxStart = Math.min(current.length, nextText.length);
|
|
220
220
|
while (start < maxStart && current[start] === nextText[start]) start += 1;
|
|
@@ -230,6 +230,29 @@ function spliceTextInto(Y, doc, ytext, nextText, origin) {
|
|
|
230
230
|
endCurrent += 1;
|
|
231
231
|
endNext += 1;
|
|
232
232
|
}
|
|
233
|
+
return { start, endCurrent, endNext };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Whether replacing `current` with `nextText` deletes any text. This is the
|
|
237
|
+
// exact question the overwrite guards care about: an insertion-only change
|
|
238
|
+
// (append, new paragraph, edits at several points) destroys nothing, so
|
|
239
|
+
// there is nothing to preserve and no reason to surface it. "Deletes
|
|
240
|
+
// nothing" means every character of `current` survives, in order, in
|
|
241
|
+
// `nextText` — a subsequence scan, so multi-point insertions count as
|
|
242
|
+
// preserving, not just a single splice.
|
|
243
|
+
function overwriteDeletesText(current, nextText) {
|
|
244
|
+
let kept = 0;
|
|
245
|
+
for (let j = 0; kept < current.length && j < nextText.length; j += 1) {
|
|
246
|
+
if (current[kept] === nextText[j]) kept += 1;
|
|
247
|
+
}
|
|
248
|
+
return kept < current.length;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// Splice `nextText` into ytext as the minimal edit from spliceBounds.
|
|
252
|
+
function spliceTextInto(Y, doc, ytext, nextText, origin) {
|
|
253
|
+
const current = ytext.toString();
|
|
254
|
+
if (current === nextText) return false;
|
|
255
|
+
const { start, endCurrent, endNext } = spliceBounds(current, nextText);
|
|
233
256
|
|
|
234
257
|
doc.transact(() => {
|
|
235
258
|
if (endCurrent > start) ytext.delete(start, endCurrent - start);
|
|
@@ -242,6 +265,98 @@ function isLowSurrogate(code) {
|
|
|
242
265
|
return code >= 0xdc00 && code <= 0xdfff;
|
|
243
266
|
}
|
|
244
267
|
|
|
268
|
+
/**
|
|
269
|
+
* Splice `nextText` into the live doc and hand back the exact update it
|
|
270
|
+
* produced, ready for commitDocMutation. The live doc's own clientID keeps
|
|
271
|
+
* the Yjs state vector at one entry per open session — a fresh doc per splice
|
|
272
|
+
* would grow it forever. The in-memory apply preceding the journal insert is
|
|
273
|
+
* safe: nothing persists before materialization, so a death in the gap leaves
|
|
274
|
+
* disk ahead of the persisted state and reopen simply re-reconciles (a write
|
|
275
|
+
* API caller got no acknowledgement and retries). Returns null when the text
|
|
276
|
+
* is already current.
|
|
277
|
+
*/
|
|
278
|
+
function spliceDocText(Y, entry, nextText, source) {
|
|
279
|
+
const origin = { amalgmMutation: true, source, patch: null };
|
|
280
|
+
const changed = spliceTextInto(Y, entry.doc, entry.doc.getText(TEXT_KEY), nextText, origin);
|
|
281
|
+
if (!changed) return null;
|
|
282
|
+
return { mutationId: newMutationId(), update: origin.patch.yjs, bytes: null };
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* The one door. Every locally produced document edit — a client Yjs update,
|
|
287
|
+
* a whole-text API write, a disk reconcile — becomes exactly one journal
|
|
288
|
+
* mutation: durable row, then materialization, then the projected event;
|
|
289
|
+
* shared docs land in the cloud outbox by the same row. `bytes` is null when
|
|
290
|
+
* the update came from spliceDocText — the live doc already holds it and
|
|
291
|
+
* materialization only persists; client updates arrive as bytes and are
|
|
292
|
+
* applied here. `entry.pendingConflict` (set by the merge that produced this
|
|
293
|
+
* edit) rides the projected event and is consumed here.
|
|
294
|
+
*/
|
|
295
|
+
function commitDocMutation(Y, entry, { mutationId, update, bytes }, source) {
|
|
296
|
+
// Finish before starting: a journal row whose materialization failed (a
|
|
297
|
+
// full disk, a permission flip) is completed here — original mutation id,
|
|
298
|
+
// event, cloud-outbox eligibility — before any new edit is journaled. This
|
|
299
|
+
// is what keeps journal order and the cloud stream gap-free; if the disk is
|
|
300
|
+
// still broken, the new edit fails too instead of shipping past a hole.
|
|
301
|
+
recoverPendingDocMutations(Y, entry);
|
|
302
|
+
const replica = require('./replicas').getReplicaByLocalResource(entry.resource);
|
|
303
|
+
trace('doc-commit', {
|
|
304
|
+
mutationId,
|
|
305
|
+
source,
|
|
306
|
+
path: entry.path,
|
|
307
|
+
shared: replica?.resourceId,
|
|
308
|
+
});
|
|
309
|
+
const origin = { amalgmMutation: true, source, patch: null };
|
|
310
|
+
return require('./mutations').commitLocalMutation({
|
|
311
|
+
channelId: replica ? `resource:${replica.resourceId}` : entry.resource,
|
|
312
|
+
localResource: entry.resource,
|
|
313
|
+
sharedResourceId: replica?.resourceId,
|
|
314
|
+
mutationId,
|
|
315
|
+
deviceId: replica ? process.env.AMALGM_DEVICE_ID : null,
|
|
316
|
+
authorityEpoch: replica?.authorityEpoch || 0,
|
|
317
|
+
baseCloudVersion: replica?.appliedVersion || 0,
|
|
318
|
+
contract: replica?.contract || 'text-yjs@1',
|
|
319
|
+
schemaVersion: replica?.schemaVersion || 1,
|
|
320
|
+
operationKind: 'yjs.update',
|
|
321
|
+
operation: { kind: 'yjs.update', update },
|
|
322
|
+
}, {
|
|
323
|
+
materialize() {
|
|
324
|
+
if (bytes) Y.applyUpdate(entry.doc, new Uint8Array(bytes), origin);
|
|
325
|
+
persistDocState(Y, entry);
|
|
326
|
+
if (replica) {
|
|
327
|
+
// Shared files have a strict saved-local boundary: the user-visible
|
|
328
|
+
// file is current before the event/HTTP acknowledgement.
|
|
329
|
+
flushDocToDisk(Y, entry);
|
|
330
|
+
} else {
|
|
331
|
+
scheduleDiskWrite(Y, entry);
|
|
332
|
+
}
|
|
333
|
+
},
|
|
334
|
+
event: () => {
|
|
335
|
+
const patch = origin.patch || { yjs: update };
|
|
336
|
+
if (entry.pendingConflict) {
|
|
337
|
+
patch.conflict = entry.pendingConflict;
|
|
338
|
+
entry.pendingConflict = null;
|
|
339
|
+
}
|
|
340
|
+
return { resource: entry.resource, op: 'update', id: entry.path, patch, source };
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// A merge where every incoming hunk conflicted leaves the doc text unchanged:
|
|
346
|
+
// there is no edit to journal, but the conflict (and its sidecar) must still
|
|
347
|
+
// reach subscribed surfaces.
|
|
348
|
+
function publishConflictOnly(entry, source) {
|
|
349
|
+
if (!entry.pendingConflict) return;
|
|
350
|
+
appendStateEvent({
|
|
351
|
+
resource: entry.resource,
|
|
352
|
+
op: 'update',
|
|
353
|
+
id: entry.path,
|
|
354
|
+
patch: { conflict: entry.pendingConflict },
|
|
355
|
+
source,
|
|
356
|
+
});
|
|
357
|
+
entry.pendingConflict = null;
|
|
358
|
+
}
|
|
359
|
+
|
|
245
360
|
// Schema is ensured once at loadDocEntry; every entry that reaches here came
|
|
246
361
|
// through it.
|
|
247
362
|
function persistDocState(Y, entry) {
|
|
@@ -263,21 +378,52 @@ function scheduleDiskWrite(Y, entry) {
|
|
|
263
378
|
if (entry.diskWriteTimer) return;
|
|
264
379
|
entry.diskWriteTimer = setTimeout(() => {
|
|
265
380
|
entry.diskWriteTimer = null;
|
|
266
|
-
|
|
381
|
+
// The debounced write-back has no caller to answer to. A failure here
|
|
382
|
+
// loses nothing durable — the acknowledged state lives in SQLite — so it
|
|
383
|
+
// is logged and the next flush (edit, close, reopen) retries.
|
|
384
|
+
try {
|
|
385
|
+
flushDocToDisk(Y, entry);
|
|
386
|
+
} catch (error) {
|
|
387
|
+
console.warn(`[LiveDoc] Debounced write-back failed for "${entry.path}":`, error?.message || error);
|
|
388
|
+
}
|
|
267
389
|
}, DISK_WRITE_DEBOUNCE_MS);
|
|
268
390
|
if (typeof entry.diskWriteTimer.unref === 'function') entry.diskWriteTimer.unref();
|
|
269
391
|
}
|
|
270
392
|
|
|
393
|
+
// Replace-by-rename so the file is always either the old text or the new
|
|
394
|
+
// text, never a torn partial write. The rename replaces the inode, so the
|
|
395
|
+
// original's permission bits must be carried over explicitly — chmod, not an
|
|
396
|
+
// open-mode, because open(2) modes are masked by the umask. An executable
|
|
397
|
+
// script stays executable through a live edit.
|
|
398
|
+
function writeFileAtomic(filePath, text) {
|
|
399
|
+
const tmpPath = path.join(path.dirname(filePath), `.${path.basename(filePath)}.amalgm-write`);
|
|
400
|
+
let mode = null;
|
|
401
|
+
try {
|
|
402
|
+
mode = fs.statSync(filePath).mode;
|
|
403
|
+
} catch (error) {
|
|
404
|
+
if (error.code !== 'ENOENT') throw error;
|
|
405
|
+
}
|
|
406
|
+
fs.writeFileSync(tmpPath, text, 'utf8');
|
|
407
|
+
if (mode != null) fs.chmodSync(tmpPath, mode);
|
|
408
|
+
fs.renameSync(tmpPath, filePath);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
// Throws on failure: "saved locally" must mean the file is really current.
|
|
412
|
+
// Callers that acknowledge an edit (mutation materialization) propagate the
|
|
413
|
+
// failure so the journal row is never completed and no event is projected;
|
|
414
|
+
// only fire-and-forget callers (debounce timer, close) may catch and log.
|
|
271
415
|
function flushDocToDisk(Y, entry) {
|
|
416
|
+
const text = entry.doc.getText(TEXT_KEY).toString();
|
|
417
|
+
let onDisk = null;
|
|
272
418
|
try {
|
|
273
|
-
|
|
274
|
-
const onDisk = fs.readFileSync(entry.path, 'utf8');
|
|
275
|
-
if (onDisk !== text) fs.writeFileSync(entry.path, text, 'utf8');
|
|
276
|
-
setLastDiskText(entry, text, 'flush');
|
|
277
|
-
persistDocState(Y, entry);
|
|
419
|
+
onDisk = fs.readFileSync(entry.path, 'utf8');
|
|
278
420
|
} catch (error) {
|
|
279
|
-
|
|
421
|
+
// A deleted file is not a failed save — the write below recreates it.
|
|
422
|
+
if (error.code !== 'ENOENT') throw error;
|
|
280
423
|
}
|
|
424
|
+
if (onDisk !== text) writeFileAtomic(entry.path, text);
|
|
425
|
+
setLastDiskText(entry, text, 'flush');
|
|
426
|
+
persistDocState(Y, entry);
|
|
281
427
|
}
|
|
282
428
|
|
|
283
429
|
function isConflictSidecar(name) {
|
|
@@ -349,13 +495,15 @@ function reconcileFromDisk(Y, entry) {
|
|
|
349
495
|
at: new Date().toISOString(),
|
|
350
496
|
};
|
|
351
497
|
}
|
|
352
|
-
} else if (
|
|
353
|
-
// Wholesale accept, but the
|
|
354
|
-
// live channel and already flushed (ours ===
|
|
355
|
-
// gate above sees no divergence — this was
|
|
356
|
-
// raw fs write carries no base, so lineage
|
|
357
|
-
// writer keeps the freedom to rewrite the
|
|
358
|
-
// text is preserved first and the
|
|
498
|
+
} else if (liveEditsSinceLastDiskState(entry) && overwriteDeletesText(ours, onDisk)) {
|
|
499
|
+
// Wholesale accept, but the incoming write DELETES text that arrived
|
|
500
|
+
// through the live channel and was already flushed (ours ===
|
|
501
|
+
// lastDiskText, so the merge gate above sees no divergence — this was
|
|
502
|
+
// the silent-loss window). A raw fs write carries no base, so lineage
|
|
503
|
+
// is genuinely ambiguous: the writer keeps the freedom to rewrite the
|
|
504
|
+
// file, but the replaced live text is preserved first and the
|
|
505
|
+
// overwrite is surfaced, never silent. An insertion-only write (a vim
|
|
506
|
+
// append, a new paragraph) destroys nothing and applies silently.
|
|
359
507
|
const sidecarPath = writePreservationSidecar(
|
|
360
508
|
entry,
|
|
361
509
|
ours,
|
|
@@ -369,27 +517,18 @@ function reconcileFromDisk(Y, entry) {
|
|
|
369
517
|
};
|
|
370
518
|
}
|
|
371
519
|
setLastDiskText(entry, onDisk, 'disk');
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
//
|
|
376
|
-
//
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
});
|
|
384
|
-
entry.pendingConflict = null;
|
|
520
|
+
const edit = spliceDocText(Y, entry, nextText, 'doc:disk');
|
|
521
|
+
if (edit) {
|
|
522
|
+
// The external edit walks through the same door as a client mutation:
|
|
523
|
+
// journal row, materialization (which writes any merged text back — the
|
|
524
|
+
// file artifact follows the doc), event, and the cloud outbox when the
|
|
525
|
+
// doc is shared.
|
|
526
|
+
commitDocMutation(Y, entry, edit, 'doc:disk');
|
|
527
|
+
} else {
|
|
528
|
+
publishConflictOnly(entry, 'doc:disk');
|
|
529
|
+
// Even a no-op still refreshed lastDiskText; record its hash.
|
|
530
|
+
persistDocState(Y, entry);
|
|
385
531
|
}
|
|
386
|
-
// A merge that diverged from what's on disk must write back: disk still
|
|
387
|
-
// holds THEIRS (preserved in the sidecar when it conflicted); the file
|
|
388
|
-
// artifact follows the doc.
|
|
389
|
-
if (nextText !== onDisk) scheduleDiskWrite(Y, entry);
|
|
390
|
-
// Persist even for a no-op splice: the refreshed lastDiskText hash still
|
|
391
|
-
// needs recording.
|
|
392
|
-
persistDocState(Y, entry);
|
|
393
532
|
} catch (error) {
|
|
394
533
|
// File deleted or unreadable — the doc simply stops following the disk.
|
|
395
534
|
console.warn(`[LiveDoc] Reconcile from disk failed for "${entry.path}":`, error?.message || error);
|
|
@@ -493,7 +632,13 @@ function closeDocEntry(Y, entry) {
|
|
|
493
632
|
clearTimeout(entry.reconcileTimer);
|
|
494
633
|
entry.reconcileTimer = null;
|
|
495
634
|
}
|
|
496
|
-
|
|
635
|
+
// Close must complete even when the disk is unwritable: every acknowledged
|
|
636
|
+
// edit is already durable in SQLite, and reopen flushes the file current.
|
|
637
|
+
try {
|
|
638
|
+
flushDocToDisk(Y, entry);
|
|
639
|
+
} catch (error) {
|
|
640
|
+
console.warn(`[LiveDoc] Close flush failed for "${entry.path}":`, error?.message || error);
|
|
641
|
+
}
|
|
497
642
|
unwatchDocFile(entry);
|
|
498
643
|
for (const alias of entry.pathAliases) resolvedPathCache.delete(alias);
|
|
499
644
|
entry.doc.destroy();
|
|
@@ -612,38 +757,15 @@ function loadDocEntry(pathInput) {
|
|
|
612
757
|
}
|
|
613
758
|
}
|
|
614
759
|
|
|
615
|
-
//
|
|
616
|
-
//
|
|
617
|
-
//
|
|
618
|
-
// the
|
|
619
|
-
//
|
|
620
|
-
//
|
|
621
|
-
//
|
|
622
|
-
// until the doc is next opened.
|
|
760
|
+
// One door: every change to this doc is applied by a journal-owned commit
|
|
761
|
+
// (client mutation, whole-text write, disk reconcile, cloud ingest,
|
|
762
|
+
// snapshot install, recovery), and that commit materializes state and
|
|
763
|
+
// projects the event itself. The observer's only job is handing the exact
|
|
764
|
+
// emitted update bytes back to the commit that applied it. Assigning to a
|
|
765
|
+
// non-object origin throws in strict mode — any future path that mutates
|
|
766
|
+
// the doc outside the door fails loudly instead of losing its event.
|
|
623
767
|
doc.on('update', (update, origin) => {
|
|
624
|
-
|
|
625
|
-
// Disk reconciles and versioned writes both splice synchronously right
|
|
626
|
-
// after setting pendingConflict, so the conflict rides this event.
|
|
627
|
-
if ((origin === 'disk' || origin === 'write') && entry.pendingConflict) {
|
|
628
|
-
patch.conflict = entry.pendingConflict;
|
|
629
|
-
entry.pendingConflict = null;
|
|
630
|
-
}
|
|
631
|
-
// SDK mutations own their journal row and event projection explicitly:
|
|
632
|
-
// durable journal first, then Y.Doc + file materialization, then event.
|
|
633
|
-
// The observer still captures the exact emitted update, but must not
|
|
634
|
-
// append an early event or schedule the shared file write after ack.
|
|
635
|
-
if (origin && typeof origin === 'object' && origin.amalgmMutation === true) {
|
|
636
|
-
origin.patch = patch;
|
|
637
|
-
return;
|
|
638
|
-
}
|
|
639
|
-
appendStateEvent({
|
|
640
|
-
resource: entry.resource,
|
|
641
|
-
op: 'update',
|
|
642
|
-
id: entry.path,
|
|
643
|
-
patch,
|
|
644
|
-
source: origin === 'disk' ? 'doc:disk' : origin === 'write' ? 'doc:write' : 'doc:update',
|
|
645
|
-
});
|
|
646
|
-
if (origin !== 'disk') scheduleDiskWrite(Y, entry);
|
|
768
|
+
origin.patch = { yjs: Buffer.from(update).toString('base64') };
|
|
647
769
|
});
|
|
648
770
|
|
|
649
771
|
// Disk at open: an externally changed file folds in as an edit, but a file
|
|
@@ -743,6 +865,12 @@ function writeDocText(pathInput, textInput, options = {}) {
|
|
|
743
865
|
|
|
744
866
|
const entry = loadDocEntry(pathInput);
|
|
745
867
|
entry.lastAccess = Date.now();
|
|
868
|
+
// Every respond() below acknowledges saved state, so it must stand on a
|
|
869
|
+
// settled journal — most pointedly the noop return: a retry of a write
|
|
870
|
+
// whose disk flush failed finds the text already in memory, and without
|
|
871
|
+
// this the original mutation would stay stuck while the caller is told all
|
|
872
|
+
// is well. (Edits that DO reach commitDocMutation settle there.)
|
|
873
|
+
recoverPendingDocMutations(Y, entry);
|
|
746
874
|
const ytext = entry.doc.getText(TEXT_KEY);
|
|
747
875
|
const ours = ytext.toString();
|
|
748
876
|
|
|
@@ -788,10 +916,14 @@ function writeDocText(pathInput, textInput, options = {}) {
|
|
|
788
916
|
}
|
|
789
917
|
} else {
|
|
790
918
|
// No base declared: same conservative posture as a raw disk write —
|
|
791
|
-
// silent when the replaced text is just the last
|
|
792
|
-
// and surfaced when live-channel edits would be
|
|
919
|
+
// silent when nothing is destroyed or the replaced text is just the last
|
|
920
|
+
// disk state, preserved and surfaced when live-channel edits would be
|
|
921
|
+
// deleted.
|
|
793
922
|
merge = 'overwrite';
|
|
794
|
-
if (
|
|
923
|
+
if (
|
|
924
|
+
overwriteDeletesText(ours, textInput)
|
|
925
|
+
&& (ours !== entry.lastDiskText || liveEditsSinceLastDiskState(entry))
|
|
926
|
+
) {
|
|
795
927
|
const sidecarPath = writePreservationSidecar(
|
|
796
928
|
entry,
|
|
797
929
|
ours,
|
|
@@ -802,22 +934,9 @@ function writeDocText(pathInput, textInput, options = {}) {
|
|
|
802
934
|
}
|
|
803
935
|
|
|
804
936
|
entry.pendingConflict = conflict;
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
if (!spliced && entry.pendingConflict) {
|
|
809
|
-
// Every incoming hunk conflicted: the doc text is unchanged, no update
|
|
810
|
-
// event fired — publish the conflict on its own (mirrors reconcile).
|
|
811
|
-
appendStateEvent({
|
|
812
|
-
resource: entry.resource,
|
|
813
|
-
op: 'update',
|
|
814
|
-
id: entry.path,
|
|
815
|
-
patch: { conflict: entry.pendingConflict },
|
|
816
|
-
source: 'doc:write',
|
|
817
|
-
});
|
|
818
|
-
entry.pendingConflict = null;
|
|
819
|
-
}
|
|
820
|
-
persistDocState(Y, entry);
|
|
937
|
+
const edit = spliceDocText(Y, entry, nextText, 'doc:write');
|
|
938
|
+
if (edit) commitDocMutation(Y, entry, edit, 'doc:write');
|
|
939
|
+
else publishConflictOnly(entry, 'doc:write');
|
|
821
940
|
return respond(merge, nextText, conflict);
|
|
822
941
|
}
|
|
823
942
|
|
|
@@ -853,47 +972,7 @@ function applyDocMutations(pathInput, mutationsInput) {
|
|
|
853
972
|
if (mutations.length === 0) return { applied: 0, seq: currentSeq(), mutationIds: [] };
|
|
854
973
|
const entry = loadDocEntry(pathInput);
|
|
855
974
|
entry.lastAccess = Date.now();
|
|
856
|
-
const results =
|
|
857
|
-
|
|
858
|
-
for (const mutation of mutations) {
|
|
859
|
-
const replica = require('./replicas').getReplicaByLocalResource(entry.resource);
|
|
860
|
-
const operation = { kind: 'yjs.update', update: mutation.update };
|
|
861
|
-
const origin = { amalgmMutation: true, source: 'local', patch: null };
|
|
862
|
-
const result = require('./mutations').commitLocalMutation({
|
|
863
|
-
channelId: replica ? `resource:${replica.resourceId}` : entry.resource,
|
|
864
|
-
localResource: entry.resource,
|
|
865
|
-
sharedResourceId: replica?.resourceId,
|
|
866
|
-
mutationId: mutation.mutationId,
|
|
867
|
-
deviceId: replica ? process.env.AMALGM_DEVICE_ID : null,
|
|
868
|
-
authorityEpoch: replica?.authorityEpoch || 0,
|
|
869
|
-
baseCloudVersion: replica?.appliedVersion || 0,
|
|
870
|
-
contract: replica?.contract || 'text-yjs@1',
|
|
871
|
-
schemaVersion: replica?.schemaVersion || 1,
|
|
872
|
-
operationKind: 'yjs.update',
|
|
873
|
-
operation,
|
|
874
|
-
}, {
|
|
875
|
-
materialize() {
|
|
876
|
-
Y.applyUpdate(entry.doc, new Uint8Array(mutation.bytes), origin);
|
|
877
|
-
persistDocState(Y, entry);
|
|
878
|
-
if (replica) {
|
|
879
|
-
// Shared files have a strict saved-local boundary: the user-visible
|
|
880
|
-
// file is current before the event/HTTP acknowledgement.
|
|
881
|
-
flushDocToDisk(Y, entry);
|
|
882
|
-
} else {
|
|
883
|
-
// Preserve the existing local-only write-back behavior.
|
|
884
|
-
scheduleDiskWrite(Y, entry);
|
|
885
|
-
}
|
|
886
|
-
},
|
|
887
|
-
event: () => ({
|
|
888
|
-
resource: entry.resource,
|
|
889
|
-
op: 'update',
|
|
890
|
-
id: entry.path,
|
|
891
|
-
patch: origin.patch || { yjs: mutation.update },
|
|
892
|
-
source: 'doc:update',
|
|
893
|
-
}),
|
|
894
|
-
});
|
|
895
|
-
results.push(result);
|
|
896
|
-
}
|
|
975
|
+
const results = mutations.map((mutation) => commitDocMutation(Y, entry, mutation, 'doc:update'));
|
|
897
976
|
|
|
898
977
|
return {
|
|
899
978
|
applied: mutations.length,
|
|
@@ -14,6 +14,7 @@
|
|
|
14
14
|
|
|
15
15
|
const { cachedStatement, openLocalDb } = require('./db');
|
|
16
16
|
const { insertStateEvent, publishStateEvent } = require('./events');
|
|
17
|
+
const { trace } = require('./trace');
|
|
17
18
|
|
|
18
19
|
const MAX_MUTATION_BYTES = 4 * 1024 * 1024;
|
|
19
20
|
const DEFAULT_CLAIM_LIMIT = 100;
|
|
@@ -253,6 +254,11 @@ function journalMutation(input, options = {}) {
|
|
|
253
254
|
|
|
254
255
|
const row = cachedStatement(database, 'SELECT * FROM mutation_journal WHERE journal_id = ?')
|
|
255
256
|
.get(Number(info.lastInsertRowid));
|
|
257
|
+
trace('journal', {
|
|
258
|
+
mutationId: mutation.mutationId,
|
|
259
|
+
channel: mutation.channelId,
|
|
260
|
+
shared: mutation.sharedResourceId,
|
|
261
|
+
});
|
|
256
262
|
return { mutation: normalizeJournalRow(row), duplicate: false };
|
|
257
263
|
})();
|
|
258
264
|
}
|
|
@@ -343,6 +349,12 @@ function completeMaterializedMutation(channelIdInput, mutationIdInput, eventInpu
|
|
|
343
349
|
advanceReplicaCursor(database, row.shared_resource_id);
|
|
344
350
|
}
|
|
345
351
|
|
|
352
|
+
trace('materialized', {
|
|
353
|
+
mutationId: row.mutation_id,
|
|
354
|
+
origin: row.mutation_origin,
|
|
355
|
+
state: nextState,
|
|
356
|
+
eventSeq: event.seq,
|
|
357
|
+
});
|
|
346
358
|
return {
|
|
347
359
|
mutation: normalizeJournalRow(
|
|
348
360
|
cachedStatement(database, 'SELECT * FROM mutation_journal WHERE journal_id = ?')
|
|
@@ -492,6 +504,7 @@ function journalCloudMutation(input, options = {}) {
|
|
|
492
504
|
now,
|
|
493
505
|
now,
|
|
494
506
|
);
|
|
507
|
+
trace('cloud-ingest', { mutationId: mutation.mutationId, shared: sharedResourceId, version });
|
|
495
508
|
return {
|
|
496
509
|
mutation: normalizeJournalRow(
|
|
497
510
|
cachedStatement(database, 'SELECT * FROM mutation_journal WHERE journal_id = ?')
|
|
@@ -629,6 +642,11 @@ function claimCloudMutations(options = {}) {
|
|
|
629
642
|
return rows.map((row) => {
|
|
630
643
|
claim.run(leaseUntil, now, row.journal_id);
|
|
631
644
|
const mutation = normalizeJournalRow(read.get(row.journal_id));
|
|
645
|
+
trace('claim', {
|
|
646
|
+
mutationId: mutation.mutationId,
|
|
647
|
+
shared: mutation.sharedResourceId,
|
|
648
|
+
attempt: mutation.attemptCount,
|
|
649
|
+
});
|
|
632
650
|
return { mutation, envelope: cloudEnvelope(mutation) };
|
|
633
651
|
});
|
|
634
652
|
})();
|
|
@@ -677,6 +695,7 @@ function acknowledgeCloudMutation(sharedResourceIdInput, mutationIdInput, acknow
|
|
|
677
695
|
WHERE journal_id = ?
|
|
678
696
|
`).run(version, committedAt, now, row.journal_id);
|
|
679
697
|
advanceReplicaCursor(database, sharedResourceId);
|
|
698
|
+
trace('cloud-ack', { mutationId, shared: sharedResourceId, version });
|
|
680
699
|
return {
|
|
681
700
|
mutation: normalizeJournalRow(
|
|
682
701
|
cachedStatement(database, 'SELECT * FROM mutation_journal WHERE journal_id = ?')
|
|
@@ -709,6 +728,7 @@ function failCloudMutation(sharedResourceIdInput, mutationIdInput, failure, opti
|
|
|
709
728
|
updated_at = ?
|
|
710
729
|
WHERE journal_id = ?
|
|
711
730
|
`).run(retryable ? 'syncing' : 'rejected', retryAt, message, now, row.journal_id);
|
|
731
|
+
trace('cloud-fail', { mutationId, shared: sharedResourceId, retryable, message });
|
|
712
732
|
return normalizeJournalRow(
|
|
713
733
|
cachedStatement(database, 'SELECT * FROM mutation_journal WHERE journal_id = ?')
|
|
714
734
|
.get(row.journal_id),
|
|
@@ -5,6 +5,7 @@ const path = require('path');
|
|
|
5
5
|
const { cachedStatement, openLocalDb } = require('./db');
|
|
6
6
|
const { appendStateEvent } = require('./events');
|
|
7
7
|
const { registerReplica } = require('./replicas');
|
|
8
|
+
const { trace } = require('./trace');
|
|
8
9
|
|
|
9
10
|
const SHARED_RESOURCE_ID = /^shr_[A-Za-z0-9_-]{16,128}$/;
|
|
10
11
|
const PROMOTION_ID = /^[A-Za-z0-9_-]{16,160}$/;
|
|
@@ -41,6 +42,7 @@ function normalizeRow(row) {
|
|
|
41
42
|
nextAttemptAt: row.next_attempt_at || null,
|
|
42
43
|
lastError: row.last_error || null,
|
|
43
44
|
snapshotKey: row.snapshot_key || null,
|
|
45
|
+
journalCursor: Number(row.journal_cursor || 0),
|
|
44
46
|
createdAt: row.created_at,
|
|
45
47
|
updatedAt: row.updated_at,
|
|
46
48
|
};
|
|
@@ -82,6 +84,14 @@ function enqueuePromotion(input, options = {}) {
|
|
|
82
84
|
return { promotion: row, duplicate: true };
|
|
83
85
|
}
|
|
84
86
|
|
|
87
|
+
// The fence: every local mutation already journaled is contained in the
|
|
88
|
+
// snapshot captured above; anything journaled past this cursor happened
|
|
89
|
+
// while the promotion was in flight and is re-homed at acknowledgement.
|
|
90
|
+
const journalCursor = Number(
|
|
91
|
+
cachedStatement(database, 'SELECT COALESCE(MAX(journal_id), 0) AS cursor FROM mutation_journal')
|
|
92
|
+
.get().cursor,
|
|
93
|
+
);
|
|
94
|
+
|
|
85
95
|
const now = new Date().toISOString();
|
|
86
96
|
cachedStatement(database, `
|
|
87
97
|
INSERT INTO shared_resource_promotions (
|
|
@@ -97,9 +107,10 @@ function enqueuePromotion(input, options = {}) {
|
|
|
97
107
|
content_bytes,
|
|
98
108
|
name,
|
|
99
109
|
media_type,
|
|
110
|
+
journal_cursor,
|
|
100
111
|
created_at,
|
|
101
112
|
updated_at
|
|
102
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
113
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
103
114
|
`).run(
|
|
104
115
|
promotionId,
|
|
105
116
|
resourceId,
|
|
@@ -113,6 +124,7 @@ function enqueuePromotion(input, options = {}) {
|
|
|
113
124
|
contentBytes,
|
|
114
125
|
input?.name || path.basename(localPath),
|
|
115
126
|
input?.mediaType || 'application/vnd.amalgm.text-yjs-snapshot',
|
|
127
|
+
journalCursor,
|
|
116
128
|
now,
|
|
117
129
|
now,
|
|
118
130
|
);
|
|
@@ -121,6 +133,12 @@ function enqueuePromotion(input, options = {}) {
|
|
|
121
133
|
.get(promotionId),
|
|
122
134
|
);
|
|
123
135
|
|
|
136
|
+
trace('promotion-enqueue', {
|
|
137
|
+
promotionId,
|
|
138
|
+
resourceId,
|
|
139
|
+
path: localPath,
|
|
140
|
+
cursor: journalCursor,
|
|
141
|
+
});
|
|
124
142
|
// Wake the already-open internal Local Live stream used by the tunnel
|
|
125
143
|
// process. This is a control event, not a cloud mutation/version.
|
|
126
144
|
appendStateEvent({
|
|
@@ -196,7 +214,7 @@ function acknowledgePromotion(promotionIdInput, acknowledgement, options = {}) {
|
|
|
196
214
|
}
|
|
197
215
|
if (promotion.state === 'promoted') return { promotion, duplicate: true };
|
|
198
216
|
|
|
199
|
-
registerReplica({
|
|
217
|
+
const { replica } = registerReplica({
|
|
200
218
|
resourceId: promotion.resourceId,
|
|
201
219
|
localResource: promotion.localResource,
|
|
202
220
|
localPath: promotion.localPath,
|
|
@@ -209,6 +227,40 @@ function acknowledgePromotion(promotionIdInput, acknowledgement, options = {}) {
|
|
|
209
227
|
}, { database });
|
|
210
228
|
|
|
211
229
|
const now = new Date().toISOString();
|
|
230
|
+
|
|
231
|
+
// Close the promotion fence: edits journaled while the promotion was in
|
|
232
|
+
// flight are local-only rows past the enqueue cursor. Re-home them onto
|
|
233
|
+
// the shared channel — their base is the promoted snapshot — so the
|
|
234
|
+
// normal outbox claim delivers them. Their journal identity, order, and
|
|
235
|
+
// already-projected local events are untouched.
|
|
236
|
+
const rehomed = cachedStatement(database, `
|
|
237
|
+
UPDATE mutation_journal
|
|
238
|
+
SET channel_id = ?,
|
|
239
|
+
shared_resource_id = ?,
|
|
240
|
+
device_id = COALESCE(device_id, ?),
|
|
241
|
+
authority_epoch = ?,
|
|
242
|
+
base_cloud_version = ?,
|
|
243
|
+
updated_at = ?
|
|
244
|
+
WHERE local_resource = ?
|
|
245
|
+
AND shared_resource_id IS NULL
|
|
246
|
+
AND mutation_origin = 'local'
|
|
247
|
+
AND journal_id > ?
|
|
248
|
+
`).run(
|
|
249
|
+
`resource:${replica.resourceId}`,
|
|
250
|
+
replica.resourceId,
|
|
251
|
+
process.env.AMALGM_DEVICE_ID || null,
|
|
252
|
+
replica.authorityEpoch,
|
|
253
|
+
replica.snapshotVersion,
|
|
254
|
+
now,
|
|
255
|
+
promotion.localResource,
|
|
256
|
+
promotion.journalCursor,
|
|
257
|
+
);
|
|
258
|
+
trace('promotion-ack', {
|
|
259
|
+
promotionId,
|
|
260
|
+
resourceId: replica.resourceId,
|
|
261
|
+
epoch: replica.authorityEpoch,
|
|
262
|
+
rehomed: rehomed.changes,
|
|
263
|
+
});
|
|
212
264
|
cachedStatement(database, `
|
|
213
265
|
UPDATE shared_resource_promotions
|
|
214
266
|
SET promotion_state = 'promoted',
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
11
|
const { cachedStatement, openLocalDb } = require('./db');
|
|
12
|
+
const { trace } = require('./trace');
|
|
12
13
|
|
|
13
14
|
const SHARED_RESOURCE_ID = /^shr_[A-Za-z0-9_-]{16,128}$/;
|
|
14
15
|
const CONTRACT = /^[A-Za-z0-9][A-Za-z0-9._:@/-]{0,127}$/;
|
|
@@ -126,6 +127,7 @@ function registerReplica(input, options = {}) {
|
|
|
126
127
|
}
|
|
127
128
|
throw error;
|
|
128
129
|
}
|
|
130
|
+
trace('replica-bind', { resourceId, path: localPath, epoch: authorityEpoch });
|
|
129
131
|
return {
|
|
130
132
|
replica: normalizeReplica(
|
|
131
133
|
cachedStatement(database, 'SELECT * FROM shared_resource_replicas WHERE resource_id = ?')
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared-realtime lifecycle tracing. Off unless AMALGM_SHARED_TRACE=1 — the
|
|
5
|
+
* guard is one boolean, so instrumented paths cost nothing in normal
|
|
6
|
+
* operation, and the instrumentation ships in production for live debugging
|
|
7
|
+
* instead of being stripped before release.
|
|
8
|
+
*
|
|
9
|
+
* Every line is `[SharedTrace] <step> key=value …` with stable identifiers
|
|
10
|
+
* (mutationId, resourceId, promotionId), so grepping one id reconstructs the
|
|
11
|
+
* full journey of one edit: doc-commit → journal → materialized → claim →
|
|
12
|
+
* cloud-ack (or cloud-ingest → materialized on the recipient).
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const enabled = process.env.AMALGM_SHARED_TRACE === '1';
|
|
16
|
+
|
|
17
|
+
function trace(step, fields = {}) {
|
|
18
|
+
if (!enabled) return;
|
|
19
|
+
let line = `[SharedTrace] ${step}`;
|
|
20
|
+
for (const [key, value] of Object.entries(fields)) {
|
|
21
|
+
if (value !== undefined && value !== null) line += ` ${key}=${value}`;
|
|
22
|
+
}
|
|
23
|
+
console.log(line);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module.exports = { trace };
|
|
@@ -176,6 +176,67 @@ test('agent rewrites with no live edits since last sync stay silent: no sidecar,
|
|
|
176
176
|
assert.equal(conflictEventsFor(opened.resource, before).length, 0, 'no conflict events either');
|
|
177
177
|
});
|
|
178
178
|
|
|
179
|
+
test('a raw append after flushed live edits applies silently: nothing destroyed, no sidecar', async () => {
|
|
180
|
+
const target = writeDoc('vim-append.md', 'v1\n');
|
|
181
|
+
const opened = docs.openDoc(target);
|
|
182
|
+
const client = clientFromState(opened.state);
|
|
183
|
+
|
|
184
|
+
docs.applyDocUpdates(target, [
|
|
185
|
+
encodeLocalEdit(client, (ytext) => ytext.insert('v1'.length, ' human')),
|
|
186
|
+
]);
|
|
187
|
+
await sleep(500);
|
|
188
|
+
assert.equal(fs.readFileSync(target, 'utf8'), 'v1 human\n', 'precondition: live edit flushed');
|
|
189
|
+
|
|
190
|
+
// A vim user reads the CURRENT file and appends: every existing character
|
|
191
|
+
// survives, so there is nothing to preserve and nothing to surface.
|
|
192
|
+
const before = currentSeq();
|
|
193
|
+
fs.writeFileSync(target, 'v1 human\nFrom vim\n', 'utf8');
|
|
194
|
+
await sleep(700);
|
|
195
|
+
|
|
196
|
+
assert.equal(docs.getDocText(target), 'v1 human\nFrom vim\n');
|
|
197
|
+
assert.equal(sidecarsFor(target).length, 0, 'an insertion-only write is not a conflict');
|
|
198
|
+
assert.equal(conflictEventsFor(opened.resource, before).length, 0);
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('insertions at several points still delete nothing: silent, no sidecar', async () => {
|
|
202
|
+
const target = writeDoc('multi-insert.md', 'm1\nm2\nm3\n');
|
|
203
|
+
const opened = docs.openDoc(target);
|
|
204
|
+
const client = clientFromState(opened.state);
|
|
205
|
+
|
|
206
|
+
docs.applyDocUpdates(target, [
|
|
207
|
+
encodeLocalEdit(client, (ytext) => ytext.insert('m1\nm2'.length, ' human')),
|
|
208
|
+
]);
|
|
209
|
+
await sleep(500);
|
|
210
|
+
assert.equal(fs.readFileSync(target, 'utf8'), 'm1\nm2 human\nm3\n');
|
|
211
|
+
|
|
212
|
+
// Not a single splice: new text lands both between m1/m2 and after m3.
|
|
213
|
+
// Every original character survives in order — nothing was destroyed.
|
|
214
|
+
const before = currentSeq();
|
|
215
|
+
fs.writeFileSync(target, 'm1\ninserted\nm2 human\nm3\nappended\n', 'utf8');
|
|
216
|
+
await sleep(700);
|
|
217
|
+
|
|
218
|
+
assert.equal(docs.getDocText(target), 'm1\ninserted\nm2 human\nm3\nappended\n');
|
|
219
|
+
assert.equal(sidecarsFor(target).length, 0, 'multi-point insertion is not a conflict');
|
|
220
|
+
assert.equal(conflictEventsFor(opened.resource, before).length, 0);
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
test('an unversioned API append that preserves all live text applies silently', async () => {
|
|
224
|
+
const target = writeDoc('api-append.md', 'p1\n');
|
|
225
|
+
const opened = docs.openDoc(target);
|
|
226
|
+
const client = clientFromState(opened.state);
|
|
227
|
+
|
|
228
|
+
// Live edit still inside the debounce window — the strictest case.
|
|
229
|
+
docs.applyDocUpdates(target, [
|
|
230
|
+
encodeLocalEdit(client, (ytext) => ytext.insert('p1'.length, ' human')),
|
|
231
|
+
]);
|
|
232
|
+
const result = docs.writeDocText(target, 'p1 human\nappended\n');
|
|
233
|
+
assert.equal(result.merge, 'overwrite');
|
|
234
|
+
assert.equal(result.conflict, null, 'nothing was deleted, so nothing to surface');
|
|
235
|
+
assert.equal(docs.getDocText(target), 'p1 human\nappended\n');
|
|
236
|
+
assert.equal(sidecarsFor(target).length, 0);
|
|
237
|
+
await sleep(500);
|
|
238
|
+
});
|
|
239
|
+
|
|
179
240
|
test('unflushed live edits still merge exactly as before (step-5 regression guard)', async () => {
|
|
180
241
|
const base = 'k1\nk2\nk3\nk4\nk5\n';
|
|
181
242
|
const target = writeDoc('unflushed-window.md', base);
|
|
@@ -20,6 +20,7 @@ const {
|
|
|
20
20
|
claimCloudMutations,
|
|
21
21
|
getMutation,
|
|
22
22
|
journalMutation,
|
|
23
|
+
listMutations,
|
|
23
24
|
} = require('../state/mutations');
|
|
24
25
|
const {
|
|
25
26
|
acknowledgePromotion,
|
|
@@ -239,6 +240,13 @@ test('reopen recovers a journaled shared edit that died before materialization',
|
|
|
239
240
|
listEventsAfter(0).filter((event) => event.mutationId === mutationId).length,
|
|
240
241
|
1,
|
|
241
242
|
);
|
|
243
|
+
|
|
244
|
+
// Drain the outbox so later tests observe only their own deliveries.
|
|
245
|
+
const [claim] = claimCloudMutations().filter((item) => item.envelope.mutationId === mutationId);
|
|
246
|
+
acknowledgeCloudMutation(resourceId, claim.envelope.mutationId, {
|
|
247
|
+
version: 4,
|
|
248
|
+
committedAt: '2026-07-14T20:00:04.000Z',
|
|
249
|
+
});
|
|
242
250
|
});
|
|
243
251
|
|
|
244
252
|
test('authorized snapshot attachment creates the recipient file before its UI event', () => {
|
|
@@ -281,3 +289,177 @@ test('authorized snapshot attachment creates the recipient file before its UI ev
|
|
|
281
289
|
resourceId: recipientResourceId,
|
|
282
290
|
}).duplicate, true);
|
|
283
291
|
});
|
|
292
|
+
|
|
293
|
+
function sleep(ms) {
|
|
294
|
+
return new Promise((resolve) => { setTimeout(resolve, ms); });
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function sha256(text) {
|
|
298
|
+
return require('crypto').createHash('sha256').update(text, 'utf8').digest('hex');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
// One door: an agent writing the shared file directly (no doc API at all)
|
|
302
|
+
// must produce a cloud-deliverable mutation, not just a local event.
|
|
303
|
+
test('an external disk write on a shared doc enters the cloud outbox', async () => {
|
|
304
|
+
docs.openDoc(target);
|
|
305
|
+
const beforeText = fs.readFileSync(target, 'utf8');
|
|
306
|
+
fs.writeFileSync(target, `${beforeText}From vim\n`, 'utf8');
|
|
307
|
+
await sleep(700);
|
|
308
|
+
|
|
309
|
+
assert.equal(docs.getDocText(target), `${beforeText}From vim\n`);
|
|
310
|
+
const claims = claimCloudMutations().filter((claim) => claim.envelope.resourceId === resourceId);
|
|
311
|
+
assert.equal(claims.length, 1, 'the raw disk write must be claimable for cloud delivery');
|
|
312
|
+
assert.equal(claims[0].envelope.operationKind, 'yjs.update');
|
|
313
|
+
const event = listEventsAfter(0).find((e) => e.mutationId === claims[0].envelope.mutationId);
|
|
314
|
+
assert.equal(event.source, 'doc:disk');
|
|
315
|
+
acknowledgeCloudMutation(resourceId, claims[0].envelope.mutationId, {
|
|
316
|
+
version: getReplicaByResourceId(resourceId).appliedVersion + 1,
|
|
317
|
+
committedAt: '2026-07-15T20:00:00.000Z',
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// One door: the whole-text write API (agents/tools) on a shared doc.
|
|
322
|
+
test('a versioned whole-text write on a shared doc enters the cloud outbox', () => {
|
|
323
|
+
const ours = docs.getDocText(target);
|
|
324
|
+
const written = docs.writeDocText(target, `${ours}From the write API\n`, { baseHash: sha256(ours) });
|
|
325
|
+
assert.equal(written.merge, 'clean');
|
|
326
|
+
|
|
327
|
+
const claims = claimCloudMutations().filter((claim) => claim.envelope.resourceId === resourceId);
|
|
328
|
+
assert.equal(claims.length, 1, 'the API write must be claimable for cloud delivery');
|
|
329
|
+
const event = listEventsAfter(0).find((e) => e.mutationId === claims[0].envelope.mutationId);
|
|
330
|
+
assert.equal(event.source, 'doc:write');
|
|
331
|
+
acknowledgeCloudMutation(resourceId, claims[0].envelope.mutationId, {
|
|
332
|
+
version: getReplicaByResourceId(resourceId).appliedVersion + 1,
|
|
333
|
+
committedAt: '2026-07-15T20:00:01.000Z',
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// Promotion fence: edits made while sharing is being set up may not strand.
|
|
338
|
+
test('edits journaled during the promotion window are re-homed into the outbox', () => {
|
|
339
|
+
const fenceResourceId = 'shr_fencefencefence1';
|
|
340
|
+
const fencePath = path.join(docDir, 'fence.md');
|
|
341
|
+
fs.writeFileSync(fencePath, 'fence base\n', 'utf8');
|
|
342
|
+
const opened = docs.openDoc(fencePath);
|
|
343
|
+
|
|
344
|
+
enqueuePromotion({
|
|
345
|
+
promotionId: 'prm_fencefencefence1',
|
|
346
|
+
resourceId: fenceResourceId,
|
|
347
|
+
localResource: opened.resource,
|
|
348
|
+
localPath: opened.path,
|
|
349
|
+
contract: 'text-yjs@1',
|
|
350
|
+
schemaVersion: 1,
|
|
351
|
+
snapshotBase64: opened.state,
|
|
352
|
+
contentBytes: fs.statSync(fencePath).size,
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// The user keeps typing while the promotion is in flight: no replica yet,
|
|
356
|
+
// so this journals as a local-only mutation.
|
|
357
|
+
const client = clientFromState(opened.state);
|
|
358
|
+
const update = captureEdit(client, (text) => text.insert(text.length, 'typed during promotion\n'));
|
|
359
|
+
docs.applyDocMutations(fencePath, [{ mutationId: 'mut_during_promotion1', update }]);
|
|
360
|
+
assert.equal(getMutation(opened.resource, 'mut_during_promotion1').sharedResourceId, null);
|
|
361
|
+
|
|
362
|
+
const [claimed] = claimPromotions();
|
|
363
|
+
acknowledgePromotion('prm_fencefencefence1', {
|
|
364
|
+
resourceId: fenceResourceId,
|
|
365
|
+
authorityEpoch: 1,
|
|
366
|
+
headVersion: 0,
|
|
367
|
+
snapshotVersion: 0,
|
|
368
|
+
snapshotKey: `shared/${fenceResourceId}/snapshots/v0/${claimed.frame.snapshotChecksum}.bin`,
|
|
369
|
+
snapshotChecksum: claimed.frame.snapshotChecksum,
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
const rehomed = getMutation(`resource:${fenceResourceId}`, 'mut_during_promotion1');
|
|
373
|
+
assert.equal(rehomed.sharedResourceId, fenceResourceId);
|
|
374
|
+
assert.equal(rehomed.authorityEpoch, 1);
|
|
375
|
+
assert.equal(rehomed.baseCloudVersion, 0);
|
|
376
|
+
assert.equal(rehomed.deviceId, process.env.AMALGM_DEVICE_ID);
|
|
377
|
+
const claims = claimCloudMutations().filter((claim) => claim.envelope.resourceId === fenceResourceId);
|
|
378
|
+
assert.deepEqual(claims.map((claim) => claim.envelope.mutationId), ['mut_during_promotion1']);
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
// Truthful saves: a failed file write must fail the edit — no journal
|
|
382
|
+
// completion, no event, no acknowledgement — and recover once disk works.
|
|
383
|
+
test('a failed shared-file write reports failure and recovers on reopen', async () => {
|
|
384
|
+
const beforeEvents = listEventsAfter(0).length;
|
|
385
|
+
const client = clientFromState(docs.openDoc(target).state);
|
|
386
|
+
const update = captureEdit(client, (text) => text.insert(text.length, 'Unwritable\n'));
|
|
387
|
+
|
|
388
|
+
fs.chmodSync(docDir, 0o555);
|
|
389
|
+
try {
|
|
390
|
+
assert.throws(
|
|
391
|
+
() => docs.applyDocMutations(target, [{ mutationId: 'mut_unwritable_00001', update }]),
|
|
392
|
+
/EACCES|EPERM/,
|
|
393
|
+
);
|
|
394
|
+
} finally {
|
|
395
|
+
fs.chmodSync(docDir, 0o755);
|
|
396
|
+
}
|
|
397
|
+
assert.equal(listEventsAfter(0).length, beforeEvents, 'a failed save must not project an event');
|
|
398
|
+
const stalled = getMutation(`resource:${resourceId}`, 'mut_unwritable_00001');
|
|
399
|
+
assert.equal(stalled.state, 'saving-local');
|
|
400
|
+
assert.match(stalled.lastError, /EACCES|EPERM/);
|
|
401
|
+
|
|
402
|
+
docs.closeAllDocs();
|
|
403
|
+
docs.openDoc(target);
|
|
404
|
+
assert.equal(getMutation(`resource:${resourceId}`, 'mut_unwritable_00001').state, 'saved-local');
|
|
405
|
+
assert.match(fs.readFileSync(target, 'utf8'), /Unwritable\n$/);
|
|
406
|
+
|
|
407
|
+
// Drain the outbox so later tests observe only their own deliveries.
|
|
408
|
+
claimCloudMutations();
|
|
409
|
+
acknowledgeCloudMutation(resourceId, 'mut_unwritable_00001', {
|
|
410
|
+
version: getReplicaByResourceId(resourceId).appliedVersion + 1,
|
|
411
|
+
committedAt: '2026-07-15T20:00:01.500Z',
|
|
412
|
+
});
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
// Truthful saves, the retry-while-open half: after a failed flush the desired
|
|
416
|
+
// text already lives in memory, so a naive retry looks like a noop. The retry
|
|
417
|
+
// must instead settle the ORIGINAL mutation — file current, journal row
|
|
418
|
+
// completed, exactly one event, exactly one cloud delivery — before answering.
|
|
419
|
+
test('retrying a failed whole-text write settles the original mutation: disk, journal, event, outbox agree', () => {
|
|
420
|
+
const ours = docs.getDocText(target);
|
|
421
|
+
const nextText = `${ours}Retry survives\n`;
|
|
422
|
+
const baseHash = sha256(ours);
|
|
423
|
+
const beforeDisk = fs.readFileSync(target, 'utf8');
|
|
424
|
+
|
|
425
|
+
fs.chmodSync(docDir, 0o555);
|
|
426
|
+
try {
|
|
427
|
+
assert.throws(() => docs.writeDocText(target, nextText, { baseHash }), /EACCES|EPERM/);
|
|
428
|
+
} finally {
|
|
429
|
+
fs.chmodSync(docDir, 0o755);
|
|
430
|
+
}
|
|
431
|
+
assert.equal(fs.readFileSync(target, 'utf8'), beforeDisk, 'failed save leaves the file untouched');
|
|
432
|
+
const [stuck] = listMutations({ state: 'saving-local' });
|
|
433
|
+
assert.ok(stuck, 'the failed write is journaled, not lost');
|
|
434
|
+
|
|
435
|
+
const retried = docs.writeDocText(target, nextText, { baseHash });
|
|
436
|
+
assert.equal(retried.sha256, sha256(nextText));
|
|
437
|
+
assert.equal(fs.readFileSync(target, 'utf8'), nextText, 'retry really saved the file');
|
|
438
|
+
assert.equal(getMutation(stuck.channelId, stuck.mutationId).state, 'saved-local');
|
|
439
|
+
assert.equal(
|
|
440
|
+
listEventsAfter(0).filter((event) => event.mutationId === stuck.mutationId).length,
|
|
441
|
+
1,
|
|
442
|
+
'exactly one projected event for the settled mutation',
|
|
443
|
+
);
|
|
444
|
+
const claims = claimCloudMutations().filter((claim) => claim.envelope.resourceId === resourceId);
|
|
445
|
+
assert.deepEqual(claims.map((claim) => claim.envelope.mutationId), [stuck.mutationId]);
|
|
446
|
+
acknowledgeCloudMutation(resourceId, stuck.mutationId, {
|
|
447
|
+
version: getReplicaByResourceId(resourceId).appliedVersion + 1,
|
|
448
|
+
committedAt: '2026-07-15T20:00:02.000Z',
|
|
449
|
+
});
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
// A live edit must not strip the file's permission bits: replace-by-rename
|
|
453
|
+
// carries the original mode onto the replacement inode.
|
|
454
|
+
test('an executable file stays executable through a live write', () => {
|
|
455
|
+
fs.chmodSync(target, 0o755);
|
|
456
|
+
const ours = docs.getDocText(target);
|
|
457
|
+
docs.writeDocText(target, `${ours}chmod check\n`, { baseHash: sha256(ours) });
|
|
458
|
+
assert.equal(fs.statSync(target).mode & 0o777, 0o755);
|
|
459
|
+
|
|
460
|
+
const claims = claimCloudMutations().filter((claim) => claim.envelope.resourceId === resourceId);
|
|
461
|
+
acknowledgeCloudMutation(resourceId, claims[0].envelope.mutationId, {
|
|
462
|
+
version: getReplicaByResourceId(resourceId).appliedVersion + 1,
|
|
463
|
+
committedAt: '2026-07-15T20:00:03.000Z',
|
|
464
|
+
});
|
|
465
|
+
});
|