amalgm 0.1.239 → 0.1.241

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.
Files changed (87) hide show
  1. package/lib/shared-realtime-tunnel.js +18 -4
  2. package/package.json +1 -1
  3. package/runtime/scripts/amalgm-mcp/browser/cookie-jar.js +68 -20
  4. package/runtime/scripts/amalgm-mcp/project-context/store.js +87 -19
  5. package/runtime/scripts/amalgm-mcp/server/routes/state.js +13 -0
  6. package/runtime/scripts/amalgm-mcp/state/attachments.js +40 -12
  7. package/runtime/scripts/amalgm-mcp/state/db.js +216 -17
  8. package/runtime/scripts/amalgm-mcp/state/doc-disk.js +550 -0
  9. package/runtime/scripts/amalgm-mcp/state/docs.js +1101 -301
  10. package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +99 -0
  11. package/runtime/scripts/amalgm-mcp/state/mutations.js +507 -130
  12. package/runtime/scripts/amalgm-mcp/state/promotions.js +21 -1
  13. package/runtime/scripts/amalgm-mcp/state/replicas.js +134 -13
  14. package/runtime/scripts/amalgm-mcp/state/shared-rest.js +14 -8
  15. package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +193 -0
  16. package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +313 -94
  17. package/runtime/scripts/amalgm-mcp/tests/fixtures/code-project-stream-v2.json +1 -0
  18. package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +32 -17
  19. package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +84 -0
  20. package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +376 -13
  21. package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +309 -0
  22. package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +758 -30
  23. package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +72 -3
  24. package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +33 -6
  25. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +27 -0
  26. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +334 -191
  27. package/runtime/scripts/amalgm-mcp/tests/workspace-durable.test.js +24 -0
  28. package/runtime/scripts/amalgm-mcp/tests/workspace-local-intent.test.js +1999 -0
  29. package/runtime/scripts/amalgm-mcp/tests/workspace-native-file-transaction.test.js +364 -0
  30. package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +98 -4
  31. package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +156 -8
  32. package/runtime/scripts/amalgm-mcp/tests/workspace-provenance.test.js +185 -0
  33. package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +174 -269
  34. package/runtime/scripts/amalgm-mcp/tests/workspace-reducer-liveness.test.js +26 -0
  35. package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +2060 -407
  36. package/runtime/scripts/amalgm-mcp/tests/workspace-sync-stats.test.js +119 -0
  37. package/runtime/scripts/amalgm-mcp/tests/workspace-transition.test.js +1446 -0
  38. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +5319 -3
  39. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +191 -1
  40. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +3140 -17
  41. package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +135 -637
  42. package/runtime/scripts/amalgm-mcp/workspace/access-store.js +5 -1
  43. package/runtime/scripts/amalgm-mcp/workspace/card.js +7 -7
  44. package/runtime/scripts/amalgm-mcp/workspace/cards.js +36 -12
  45. package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +436 -173
  46. package/runtime/scripts/amalgm-mcp/workspace/content.js +32 -0
  47. package/runtime/scripts/amalgm-mcp/workspace/durable.js +45 -0
  48. package/runtime/scripts/amalgm-mcp/workspace/git.js +391 -37
  49. package/runtime/scripts/amalgm-mcp/workspace/intent.js +535 -0
  50. package/runtime/scripts/amalgm-mcp/workspace/merge.js +89 -6
  51. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/BUILD.md +31 -0
  52. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/darwin-arm64/amalgm-file-transaction +0 -0
  53. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/linux-x64/amalgm-file-transaction +0 -0
  54. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/build.mjs +72 -0
  55. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/client.js +165 -0
  56. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.mod +5 -0
  57. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.sum +2 -0
  58. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/main.go +2201 -0
  59. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/manifest.json +19 -0
  60. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_darwin.go +18 -0
  61. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_linux.go +18 -0
  62. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_darwin.go +33 -0
  63. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_linux.go +47 -0
  64. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/verify.mjs +43 -0
  65. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_darwin.go +114 -0
  66. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_linux.go +84 -0
  67. package/runtime/scripts/amalgm-mcp/workspace/objects.js +185 -55
  68. package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +616 -168
  69. package/runtime/scripts/amalgm-mcp/workspace/provenance.js +240 -0
  70. package/runtime/scripts/amalgm-mcp/workspace/reducer.js +1979 -570
  71. package/runtime/scripts/amalgm-mcp/workspace/rest.js +49 -28
  72. package/runtime/scripts/amalgm-mcp/workspace/store.js +77 -4
  73. package/runtime/scripts/amalgm-mcp/workspace/sync-stats.js +240 -0
  74. package/runtime/scripts/amalgm-mcp/workspace/transition.js +1729 -192
  75. package/runtime/scripts/amalgm-mcp/workspace/tree/floor.js +100 -0
  76. package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +7 -1
  77. package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +73 -61
  78. package/runtime/scripts/amalgm-mcp/workspace/tree/observation-holds.js +127 -0
  79. package/runtime/scripts/amalgm-mcp/workspace/tree/path-state.js +12 -0
  80. package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +51 -6
  81. package/runtime/scripts/amalgm-mcp/workspace/tree/root-binding.js +125 -0
  82. package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +79 -8
  83. package/runtime/scripts/amalgm-mcp/workspace/tree/symlinks.js +76 -0
  84. package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +3480 -165
  85. package/runtime/scripts/amalgm-mcp/workspace/tree-materialization.js +3766 -0
  86. package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +899 -71
  87. package/runtime/scripts/amalgm-mcp/workspace/wall.js +352 -497
@@ -5,6 +5,7 @@ const assert = require('node:assert/strict');
5
5
  const fs = require('fs');
6
6
  const os = require('os');
7
7
  const path = require('path');
8
+ const { spawn } = require('node:child_process');
8
9
 
9
10
  const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-shared-replica-test-'));
10
11
  process.env.AMALGM_DIR = path.join(tempRoot, '.amalgm');
@@ -13,13 +14,14 @@ process.env.AMALGM_DEVICE_ID = 'device-shared-replica-test';
13
14
  process.env.AMALGM_EVENT_LOG_PRUNE_INTERVAL_MS = '0';
14
15
 
15
16
  const Y = require('yjs');
16
- const { closeLocalDb } = require('../state/db');
17
+ const { closeLocalDb, openLocalDb } = require('../state/db');
17
18
  const docs = require('../state/docs');
18
19
  const { listEventsAfter, subscribeStateEvents } = require('../state/events');
19
20
  const {
20
21
  acknowledgeCloudMutation,
21
22
  claimCloudMutations,
22
23
  getMutation,
24
+ journalCloudMutation,
23
25
  journalMutation,
24
26
  listMutations,
25
27
  } = require('../state/mutations');
@@ -61,6 +63,27 @@ function captureEdit(doc, mutate) {
61
63
  return Buffer.from(captured).toString('base64');
62
64
  }
63
65
 
66
+ function acknowledgeAndApplyClaim(claim, version, committedAt) {
67
+ acknowledgeCloudMutation(claim.envelope.resourceId, claim.envelope.mutationId, {
68
+ version,
69
+ committedAt,
70
+ });
71
+ return docs.applyCloudDocMutation({
72
+ envelope: claim.envelope,
73
+ version,
74
+ committedAt,
75
+ });
76
+ }
77
+
78
+ function writeFromAnotherProcessAfter(file, text, delayMs) {
79
+ return spawn(process.execPath, [
80
+ '-e',
81
+ 'const fs=require("fs");'
82
+ + `setTimeout(()=>fs.writeFileSync(${JSON.stringify(file)},`
83
+ + `${JSON.stringify(text)},"utf8"),${delayMs});`,
84
+ ], { stdio: 'ignore' });
85
+ }
86
+
64
87
  test.after(() => {
65
88
  docs.closeAllDocs();
66
89
  closeLocalDb();
@@ -151,10 +174,25 @@ test('batched surface edits retain separate IDs/versions and recipient file prec
151
174
  version: 1,
152
175
  committedAt: '2026-07-14T20:00:00.000Z',
153
176
  });
177
+ assert.equal(
178
+ getReplicaByResourceId(resourceId).appliedVersion,
179
+ 0,
180
+ 'an acknowledgement records order; the official inbox advances it',
181
+ );
182
+ docs.applyCloudDocMutation({
183
+ envelope: claims[0].envelope,
184
+ version: 1,
185
+ committedAt: '2026-07-14T20:00:00.000Z',
186
+ });
154
187
  acknowledgeCloudMutation(resourceId, 'mut_shared_batch_002', {
155
188
  version: 2,
156
189
  committedAt: '2026-07-14T20:00:01.000Z',
157
190
  });
191
+ docs.applyCloudDocMutation({
192
+ envelope: claims[1].envelope,
193
+ version: 2,
194
+ committedAt: '2026-07-14T20:00:01.000Z',
195
+ });
158
196
  assert.equal(getReplicaByResourceId(resourceId).appliedVersion, 2);
159
197
 
160
198
  const remoteUpdate = captureEdit(client, (text) => text.insert(text.length, 'Remote\n'));
@@ -253,10 +291,153 @@ test('reopen recovers a journaled shared edit that died before materialization',
253
291
 
254
292
  // Drain the outbox so later tests observe only their own deliveries.
255
293
  const [claim] = claimCloudMutations().filter((item) => item.envelope.mutationId === mutationId);
256
- acknowledgeCloudMutation(resourceId, claim.envelope.mutationId, {
257
- version: 4,
258
- committedAt: '2026-07-14T20:00:04.000Z',
294
+ acknowledgeAndApplyClaim(claim, 4, '2026-07-14T20:00:04.000Z');
295
+ });
296
+
297
+ test('restart merges a downtime disk edit over the last proven base before official recovery', () => {
298
+ const restartResourceId = 'shr_restartbasefold1';
299
+ const restartPath = path.join(docDir, 'restart-base-fold.md');
300
+ fs.writeFileSync(restartPath, '', 'utf8');
301
+ const cloud = new Y.Doc();
302
+ cloud.getText('content').insert(0, 'left\nright\n');
303
+ const snapshot = Buffer.from(Y.encodeStateAsUpdate(cloud));
304
+ const checksum = require('crypto').createHash('sha256').update(snapshot).digest('hex');
305
+ docs.installCloudDocSnapshot(restartPath, {
306
+ resourceId: restartResourceId,
307
+ authorityEpoch: 1,
308
+ headVersion: 0,
309
+ snapshotVersion: 0,
310
+ snapshotChecksum: checksum,
311
+ snapshotBase64: snapshot.toString('base64'),
312
+ contract: 'text-yjs@1',
313
+ schemaVersion: 1,
259
314
  });
315
+ const opened = docs.openDoc(restartPath);
316
+ const remote = clientFromState(opened.state);
317
+ const remoteUpdate = captureEdit(remote, (text) => {
318
+ text.insert('left\nright'.length, ' cloud');
319
+ });
320
+ docs.closeAllDocs();
321
+
322
+ const base = require('../state/doc-disk').observe(restartPath);
323
+ journalCloudMutation({
324
+ envelope: {
325
+ resourceId: restartResourceId,
326
+ authorityEpoch: 1,
327
+ mutationId: 'mut_restart_base_cloud_001',
328
+ deviceId: 'device-remote-restart-base',
329
+ baseVersion: 0,
330
+ contract: 'text-yjs@1',
331
+ schemaVersion: 1,
332
+ operationKind: 'yjs.update',
333
+ operation: { kind: 'yjs.update', update: remoteUpdate },
334
+ },
335
+ version: 1,
336
+ committedAt: '2026-07-28T10:30:00.000Z',
337
+ localResource: opened.resource,
338
+ }, {
339
+ persist(database, mutation) {
340
+ require('../state/doc-disk').insertBase(database, mutation, base, 'doc:cloud');
341
+ },
342
+ });
343
+
344
+ // Model the later crash window too: the desired official Y.Doc target was
345
+ // persisted, but its native file transaction never crossed. The persisted
346
+ // hash still names the old disk base, so recovery needs the exact base text
347
+ // sealed in the physical plan rather than trying to recover it from the
348
+ // newer Y.Doc state.
349
+ const persistedTarget = clientFromState(opened.state);
350
+ Y.applyUpdate(persistedTarget, new Uint8Array(Buffer.from(remoteUpdate, 'base64')));
351
+ openLocalDb().prepare('UPDATE doc_states SET state = ? WHERE path = ?').run(
352
+ Buffer.from(Y.encodeStateAsUpdate(persistedTarget)),
353
+ restartPath,
354
+ );
355
+
356
+ // This save is born while the process is down and changes only the other
357
+ // line. Recovery must merge both deltas over the persisted physical base.
358
+ fs.writeFileSync(restartPath, 'left local\nright\n', 'utf8');
359
+ const recovered = docs.openDoc(restartPath);
360
+ const finalText = fs.readFileSync(restartPath, 'utf8');
361
+ assert.equal(finalText, 'left local\nright cloud\n');
362
+ assert.equal(clientFromState(recovered.state).getText('content').toString(), finalText);
363
+ assert.equal(getReplicaByResourceId(restartResourceId).appliedVersion, 1);
364
+
365
+ const rows = listMutations({ localResource: opened.resource });
366
+ const cloudRow = rows.find(
367
+ (mutation) => mutation.mutationId === 'mut_restart_base_cloud_001',
368
+ );
369
+ assert.equal(cloudRow.state, 'synced');
370
+ const localRows = rows.filter((mutation) => (
371
+ mutation.origin === 'local'
372
+ && mutation.mutationId !== 'mut_restart_base_cloud_001'
373
+ ));
374
+ assert.equal(localRows.length, 1, 'one downtime disk save receives one local mutation ID');
375
+ assert.ok(
376
+ claimCloudMutations().some((claim) => (
377
+ claim.envelope.resourceId === restartResourceId
378
+ && claim.envelope.mutationId === localRows[0].mutationId
379
+ )),
380
+ 'the merged downtime save remains in the outbox',
381
+ );
382
+ });
383
+
384
+ test('a held viewer disk save blocks official materialization instead of becoming permission', () => {
385
+ const heldResourceId = 'shr_viewerheldsave01';
386
+ const heldPath = path.join(docDir, 'viewer-held-save.md');
387
+ fs.writeFileSync(heldPath, '', 'utf8');
388
+ const cloud = new Y.Doc();
389
+ cloud.getText('content').insert(0, 'official base\n');
390
+ const snapshot = Buffer.from(Y.encodeStateAsUpdate(cloud));
391
+ const checksum = require('crypto').createHash('sha256').update(snapshot).digest('hex');
392
+ docs.installCloudDocSnapshot(heldPath, {
393
+ resourceId: heldResourceId,
394
+ authorityEpoch: 1,
395
+ headVersion: 0,
396
+ snapshotVersion: 0,
397
+ snapshotChecksum: checksum,
398
+ snapshotBase64: snapshot.toString('base64'),
399
+ contract: 'text-yjs@1',
400
+ schemaVersion: 1,
401
+ });
402
+ const opened = docs.openDoc(heldPath);
403
+ const remote = clientFromState(opened.state);
404
+ const remoteUpdate = captureEdit(remote, (text) => text.insert(text.length, 'cloud edit\n'));
405
+ const entry = docs._private.openDocs.get(opened.path);
406
+ const accessStore = require('../workspace/access-store');
407
+ const originalCurrentUserRole = accessStore.currentUserRole;
408
+ entry.nodeId = '33333333-3333-4333-8333-333333333333';
409
+ accessStore.currentUserRole = () => 'viewer';
410
+ fs.writeFileSync(heldPath, 'LOCAL SAVE\n', 'utf8');
411
+
412
+ try {
413
+ assert.throws(() => docs.applyCloudDocMutation({
414
+ envelope: {
415
+ resourceId: heldResourceId,
416
+ authorityEpoch: 1,
417
+ mutationId: 'mut_cloud_blocked_by_viewer_save',
418
+ deviceId: 'device-remote-viewer-held',
419
+ baseVersion: 0,
420
+ contract: 'text-yjs@1',
421
+ schemaVersion: 1,
422
+ operationKind: 'yjs.update',
423
+ operation: { kind: 'yjs.update', update: remoteUpdate },
424
+ },
425
+ version: 1,
426
+ committedAt: '2026-07-28T11:00:00.000Z',
427
+ }), /disk change is held: forbidden/);
428
+ assert.equal(fs.readFileSync(heldPath, 'utf8'), 'LOCAL SAVE\n');
429
+ assert.equal(getReplicaByResourceId(heldResourceId).appliedVersion, 0);
430
+ assert.equal(
431
+ getMutation(`resource:${heldResourceId}`, 'mut_cloud_blocked_by_viewer_save'),
432
+ null,
433
+ 'the official cursor row is not journaled before held local bytes are resolved',
434
+ );
435
+ } finally {
436
+ accessStore.currentUserRole = originalCurrentUserRole;
437
+ entry.nodeId = null;
438
+ fs.writeFileSync(heldPath, 'official base\n', 'utf8');
439
+ docs._private.verifyOpenDocs();
440
+ }
260
441
  });
261
442
 
262
443
  test('authorized snapshot attachment creates the recipient file before its UI event', () => {
@@ -310,11 +491,11 @@ function sha256(text) {
310
491
 
311
492
  // One door: an agent writing the shared file directly (no doc API at all)
312
493
  // must produce a cloud-deliverable mutation, not just a local event.
313
- test('an external disk write on a shared doc enters the cloud outbox', async () => {
494
+ test('bounded verification puts a dropped external disk write in the cloud outbox', () => {
314
495
  docs.openDoc(target);
315
496
  const beforeText = fs.readFileSync(target, 'utf8');
316
497
  fs.writeFileSync(target, `${beforeText}From vim\n`, 'utf8');
317
- await sleep(700);
498
+ docs._private.verifyOpenDocs();
318
499
 
319
500
  assert.equal(docs.getDocText(target), `${beforeText}From vim\n`);
320
501
  const claims = claimCloudMutations().filter((claim) => claim.envelope.resourceId === resourceId);
@@ -322,10 +503,11 @@ test('an external disk write on a shared doc enters the cloud outbox', async ()
322
503
  assert.equal(claims[0].envelope.operationKind, 'yjs.update');
323
504
  const event = listEventsAfter(0).find((e) => e.mutationId === claims[0].envelope.mutationId);
324
505
  assert.equal(event.source, 'doc:disk');
325
- acknowledgeCloudMutation(resourceId, claims[0].envelope.mutationId, {
326
- version: getReplicaByResourceId(resourceId).appliedVersion + 1,
327
- committedAt: '2026-07-15T20:00:00.000Z',
328
- });
506
+ acknowledgeAndApplyClaim(
507
+ claims[0],
508
+ getReplicaByResourceId(resourceId).appliedVersion + 1,
509
+ '2026-07-15T20:00:00.000Z',
510
+ );
329
511
  });
330
512
 
331
513
  // One door: the whole-text write API (agents/tools) on a shared doc.
@@ -338,10 +520,11 @@ test('a versioned whole-text write on a shared doc enters the cloud outbox', ()
338
520
  assert.equal(claims.length, 1, 'the API write must be claimable for cloud delivery');
339
521
  const event = listEventsAfter(0).find((e) => e.mutationId === claims[0].envelope.mutationId);
340
522
  assert.equal(event.source, 'doc:write');
341
- acknowledgeCloudMutation(resourceId, claims[0].envelope.mutationId, {
342
- version: getReplicaByResourceId(resourceId).appliedVersion + 1,
343
- committedAt: '2026-07-15T20:00:01.000Z',
344
- });
523
+ acknowledgeAndApplyClaim(
524
+ claims[0],
525
+ getReplicaByResourceId(resourceId).appliedVersion + 1,
526
+ '2026-07-15T20:00:01.000Z',
527
+ );
345
528
  });
346
529
 
347
530
  // Promotion fence: edits made while sharing is being set up may not strand.
@@ -388,6 +571,165 @@ test('edits journaled during the promotion window are re-homed into the outbox',
388
571
  assert.deepEqual(claims.map((claim) => claim.envelope.mutationId), ['mut_during_promotion1']);
389
572
  });
390
573
 
574
+ test('promotion rehomes a saving mutation and its physical plan as one identity', () => {
575
+ const planResourceId = 'shr_planrehomecrash1';
576
+ const planPath = path.join(docDir, 'promotion-plan-rehome.md');
577
+ fs.writeFileSync(planPath, 'promotion base\n', 'utf8');
578
+ const opened = docs.openDoc(planPath);
579
+ const queued = enqueuePromotion({
580
+ promotionId: 'prm_plan_rehome_crash_01',
581
+ resourceId: planResourceId,
582
+ localResource: opened.resource,
583
+ localPath: opened.path,
584
+ contract: 'text-yjs@1',
585
+ schemaVersion: 1,
586
+ snapshotBase64: opened.state,
587
+ contentBytes: fs.statSync(planPath).size,
588
+ }).promotion;
589
+ const client = clientFromState(opened.state);
590
+ const update = captureEdit(client, (text) => text.insert(text.length, 'survives crash\n'));
591
+ const mutationId = 'mut_promotion_plan_rehome_01';
592
+ docs._private.setDiskTransactionTestHooks({ crashAfterInstall: true });
593
+ try {
594
+ assert.throws(
595
+ () => docs.applyDocMutations(planPath, [{ mutationId, update }]),
596
+ /native file transaction failed/,
597
+ );
598
+ } finally {
599
+ docs._private.setDiskTransactionTestHooks();
600
+ }
601
+ const docDisk = require('../state/doc-disk');
602
+ assert.ok(docDisk.get(opened.resource, mutationId));
603
+
604
+ acknowledgePromotion(queued.promotionId, {
605
+ resourceId: planResourceId,
606
+ authorityEpoch: 1,
607
+ headVersion: 0,
608
+ snapshotVersion: 0,
609
+ snapshotChecksum: queued.snapshotChecksum,
610
+ snapshotKey: `shared/${planResourceId}/snapshot.bin`,
611
+ });
612
+ const sharedChannel = `resource:${planResourceId}`;
613
+ assert.equal(docDisk.get(opened.resource, mutationId), null);
614
+ assert.ok(docDisk.get(sharedChannel, mutationId), 'the same physical plan follows the journal row');
615
+ assert.equal(getMutation(sharedChannel, mutationId).state, 'saving-local');
616
+
617
+ assert.doesNotThrow(() => docs.closeAllDocs());
618
+ const reopened = docs.openDoc(planPath);
619
+ assert.match(clientFromState(reopened.state).getText('content').toString(), /survives crash\n/);
620
+ assert.match(fs.readFileSync(planPath, 'utf8'), /survives crash\n/);
621
+ assert.equal(getMutation(sharedChannel, mutationId).state, 'saved-local');
622
+ });
623
+
624
+ test('an edit during first attachment stays overlay and joins after the snapshot', () => {
625
+ const attachedResourceId = 'shr_attachfenceedit1';
626
+ const attachedPath = path.join(docDir, 'attachment-fence-edit.md');
627
+ fs.writeFileSync(attachedPath, '', 'utf8');
628
+ const queued = enqueueAttachment({
629
+ attachmentId: 'att_first_attachment_fence_01',
630
+ resourceId: attachedResourceId,
631
+ localPath: attachedPath,
632
+ }).attachment;
633
+ const local = docs.writeDocText(attachedPath, 'local during request\n', {
634
+ baseHash: sha256(''),
635
+ });
636
+ const cloud = new Y.Doc();
637
+ cloud.getText('content').insert(0, 'official snapshot\n');
638
+ const snapshot = Buffer.from(Y.encodeStateAsUpdate(cloud));
639
+ const checksum = require('crypto').createHash('sha256').update(snapshot).digest('hex');
640
+
641
+ acknowledgeAttachment(queued.attachmentId, {
642
+ resourceId: attachedResourceId,
643
+ authorityEpoch: 1,
644
+ headVersion: 0,
645
+ snapshotVersion: 0,
646
+ snapshotChecksum: checksum,
647
+ snapshotBase64: snapshot.toString('base64'),
648
+ contract: 'text-yjs@1',
649
+ schemaVersion: 1,
650
+ });
651
+ const finalText = fs.readFileSync(attachedPath, 'utf8');
652
+ assert.match(finalText, /official snapshot\n/);
653
+ assert.match(finalText, /local during request\n/);
654
+ const rehomed = getMutation(`resource:${attachedResourceId}`, local.mutationId);
655
+ assert.ok(rehomed);
656
+ assert.equal(rehomed.sharedResourceId, attachedResourceId);
657
+ const claim = claimCloudMutations().find(
658
+ (item) => item.envelope.mutationId === local.mutationId,
659
+ );
660
+ assert.ok(claim, 'post-request local work joins the new cloud outbox');
661
+ acknowledgeAndApplyClaim(claim, 1, '2026-07-28T11:10:00.000Z');
662
+ });
663
+
664
+ test('a legacy nullable attachment fence migrates to durable zero', () => {
665
+ const Database = require('better-sqlite3');
666
+ const legacy = new Database(':memory:');
667
+ try {
668
+ legacy.exec(`
669
+ CREATE TABLE shared_resource_attachments (
670
+ attachment_id TEXT PRIMARY KEY,
671
+ resource_id TEXT NOT NULL UNIQUE,
672
+ local_path TEXT NOT NULL UNIQUE,
673
+ attachment_state TEXT NOT NULL DEFAULT 'pending',
674
+ attempt_count INTEGER NOT NULL DEFAULT 0,
675
+ next_attempt_at TEXT,
676
+ last_error TEXT,
677
+ journal_cursor INTEGER,
678
+ created_at TEXT NOT NULL,
679
+ updated_at TEXT NOT NULL
680
+ );
681
+ INSERT INTO shared_resource_attachments (
682
+ attachment_id,
683
+ resource_id,
684
+ local_path,
685
+ journal_cursor,
686
+ created_at,
687
+ updated_at
688
+ ) VALUES (
689
+ 'att_legacy_nullable_fence_01',
690
+ 'shr_legacyfence0001',
691
+ '/tmp/legacy-fence.md',
692
+ NULL,
693
+ '2026-07-28T00:00:00.000Z',
694
+ '2026-07-28T00:00:00.000Z'
695
+ );
696
+ `);
697
+ require('../state/db')._private.migrate(legacy);
698
+
699
+ const cursor = legacy.prepare(
700
+ 'PRAGMA table_info(shared_resource_attachments)',
701
+ ).all().find((column) => column.name === 'journal_cursor');
702
+ assert.equal(cursor.notnull, 1);
703
+ assert.equal(String(cursor.dflt_value), '0');
704
+ assert.equal(
705
+ legacy.prepare(`
706
+ SELECT journal_cursor AS cursor
707
+ FROM shared_resource_attachments
708
+ WHERE attachment_id = 'att_legacy_nullable_fence_01'
709
+ `).get().cursor,
710
+ 0,
711
+ );
712
+ assert.throws(() => legacy.prepare(`
713
+ INSERT INTO shared_resource_attachments (
714
+ attachment_id,
715
+ resource_id,
716
+ local_path,
717
+ journal_cursor,
718
+ created_at,
719
+ updated_at
720
+ ) VALUES (?, ?, ?, NULL, ?, ?)
721
+ `).run(
722
+ 'att_legacy_null_rejected_01',
723
+ 'shr_legacynull00001',
724
+ '/tmp/legacy-null-rejected.md',
725
+ '2026-07-28T00:00:00.000Z',
726
+ '2026-07-28T00:00:00.000Z',
727
+ ), /NOT NULL/);
728
+ } finally {
729
+ legacy.close();
730
+ }
731
+ });
732
+
391
733
  // Truthful saves: a failed file write must fail the edit — no journal
392
734
  // completion, no event, no acknowledgement — and recover once disk works.
393
735
  test('a failed shared-file write reports failure and recovers on reopen', async () => {
@@ -399,7 +741,7 @@ test('a failed shared-file write reports failure and recovers on reopen', async
399
741
  try {
400
742
  assert.throws(
401
743
  () => docs.applyDocMutations(target, [{ mutationId: 'mut_unwritable_00001', update }]),
402
- /EACCES|EPERM/,
744
+ /EACCES|EPERM|permission denied/,
403
745
  );
404
746
  } finally {
405
747
  fs.chmodSync(docDir, 0o755);
@@ -407,7 +749,7 @@ test('a failed shared-file write reports failure and recovers on reopen', async
407
749
  assert.equal(listEventsAfter(0).length, beforeEvents, 'a failed save must not project an event');
408
750
  const stalled = getMutation(`resource:${resourceId}`, 'mut_unwritable_00001');
409
751
  assert.equal(stalled.state, 'saving-local');
410
- assert.match(stalled.lastError, /EACCES|EPERM/);
752
+ assert.match(stalled.lastError, /EACCES|EPERM|permission denied/);
411
753
 
412
754
  docs.closeAllDocs();
413
755
  docs.openDoc(target);
@@ -415,11 +757,14 @@ test('a failed shared-file write reports failure and recovers on reopen', async
415
757
  assert.match(fs.readFileSync(target, 'utf8'), /Unwritable\n$/);
416
758
 
417
759
  // Drain the outbox so later tests observe only their own deliveries.
418
- claimCloudMutations();
419
- acknowledgeCloudMutation(resourceId, 'mut_unwritable_00001', {
420
- version: getReplicaByResourceId(resourceId).appliedVersion + 1,
421
- committedAt: '2026-07-15T20:00:01.500Z',
422
- });
760
+ const [claim] = claimCloudMutations().filter(
761
+ (item) => item.envelope.mutationId === 'mut_unwritable_00001',
762
+ );
763
+ acknowledgeAndApplyClaim(
764
+ claim,
765
+ getReplicaByResourceId(resourceId).appliedVersion + 1,
766
+ '2026-07-15T20:00:01.500Z',
767
+ );
423
768
  });
424
769
 
425
770
  // Truthful saves, the retry-while-open half: after a failed flush the desired
@@ -434,7 +779,10 @@ test('retrying a failed whole-text write settles the original mutation: disk, jo
434
779
 
435
780
  fs.chmodSync(docDir, 0o555);
436
781
  try {
437
- assert.throws(() => docs.writeDocText(target, nextText, { baseHash }), /EACCES|EPERM/);
782
+ assert.throws(
783
+ () => docs.writeDocText(target, nextText, { baseHash }),
784
+ /EACCES|EPERM|permission denied/,
785
+ );
438
786
  } finally {
439
787
  fs.chmodSync(docDir, 0o755);
440
788
  }
@@ -453,10 +801,11 @@ test('retrying a failed whole-text write settles the original mutation: disk, jo
453
801
  );
454
802
  const claims = claimCloudMutations().filter((claim) => claim.envelope.resourceId === resourceId);
455
803
  assert.deepEqual(claims.map((claim) => claim.envelope.mutationId), [stuck.mutationId]);
456
- acknowledgeCloudMutation(resourceId, stuck.mutationId, {
457
- version: getReplicaByResourceId(resourceId).appliedVersion + 1,
458
- committedAt: '2026-07-15T20:00:02.000Z',
459
- });
804
+ acknowledgeAndApplyClaim(
805
+ claims[0],
806
+ getReplicaByResourceId(resourceId).appliedVersion + 1,
807
+ '2026-07-15T20:00:02.000Z',
808
+ );
460
809
  });
461
810
 
462
811
  // A live edit must not strip the file's permission bits: replace-by-rename
@@ -468,10 +817,11 @@ test('an executable file stays executable through a live write', () => {
468
817
  assert.equal(fs.statSync(target).mode & 0o777, 0o755);
469
818
 
470
819
  const claims = claimCloudMutations().filter((claim) => claim.envelope.resourceId === resourceId);
471
- acknowledgeCloudMutation(resourceId, claims[0].envelope.mutationId, {
472
- version: getReplicaByResourceId(resourceId).appliedVersion + 1,
473
- committedAt: '2026-07-15T20:00:03.000Z',
474
- });
820
+ acknowledgeAndApplyClaim(
821
+ claims[0],
822
+ getReplicaByResourceId(resourceId).appliedVersion + 1,
823
+ '2026-07-15T20:00:03.000Z',
824
+ );
475
825
  });
476
826
 
477
827
  // Compaction on the authority prunes official rows the moment a verified
@@ -530,6 +880,71 @@ test('a pruned-tail replica rehydrates: snapshot merge keeps unsynced local edit
530
880
  assert.notEqual(pending.state, 'synced');
531
881
  });
532
882
 
883
+ test('equal snapshot version with different bytes fails before state or cursor movement', () => {
884
+ const equalResourceId = 'shr_equalsnapshot001';
885
+ const equalPath = path.join(docDir, 'equal-snapshot.md');
886
+ fs.writeFileSync(equalPath, '', 'utf8');
887
+ const first = new Y.Doc();
888
+ first.getText('content').insert(0, 'state A\n');
889
+ const firstBytes = Buffer.from(Y.encodeStateAsUpdate(first));
890
+ const firstChecksum = require('crypto').createHash('sha256').update(firstBytes).digest('hex');
891
+ docs.installCloudDocSnapshot(equalPath, {
892
+ resourceId: equalResourceId,
893
+ authorityEpoch: 1,
894
+ headVersion: 1,
895
+ snapshotVersion: 1,
896
+ snapshotChecksum: firstChecksum,
897
+ snapshotBase64: firstBytes.toString('base64'),
898
+ contract: 'text-yjs@1',
899
+ schemaVersion: 1,
900
+ });
901
+ const beforeEvents = listEventsAfter(0).length;
902
+ const second = new Y.Doc();
903
+ second.getText('content').insert(0, 'state B\n');
904
+ const secondBytes = Buffer.from(Y.encodeStateAsUpdate(second));
905
+ const secondChecksum = require('crypto').createHash('sha256').update(secondBytes).digest('hex');
906
+
907
+ assert.throws(() => docs.installCloudDocSnapshot(equalPath, {
908
+ resourceId: equalResourceId,
909
+ authorityEpoch: 1,
910
+ headVersion: 1,
911
+ snapshotVersion: 1,
912
+ snapshotChecksum: secondChecksum,
913
+ snapshotBase64: secondBytes.toString('base64'),
914
+ contract: 'text-yjs@1',
915
+ schemaVersion: 1,
916
+ }), /unequal state at version 1/);
917
+ assert.equal(fs.readFileSync(equalPath, 'utf8'), 'state A\n');
918
+ assert.equal(getReplicaByResourceId(equalResourceId).snapshotChecksum, firstChecksum);
919
+ assert.equal(listEventsAfter(0).length, beforeEvents);
920
+
921
+ openLocalDb().prepare(`
922
+ UPDATE shared_resource_replicas SET applied_version = 3 WHERE resource_id = ?
923
+ `).run(equalResourceId);
924
+ assert.throws(() => docs.installCloudDocSnapshot(equalPath, {
925
+ resourceId: equalResourceId,
926
+ authorityEpoch: 1,
927
+ headVersion: 3,
928
+ snapshotVersion: 1,
929
+ snapshotChecksum: secondChecksum,
930
+ snapshotBase64: secondBytes.toString('base64'),
931
+ contract: 'text-yjs@1',
932
+ schemaVersion: 1,
933
+ }), /unequal state at version 1/);
934
+ assert.throws(() => docs.installCloudDocSnapshot(equalPath, {
935
+ resourceId: equalResourceId,
936
+ authorityEpoch: 1,
937
+ headVersion: 3,
938
+ snapshotVersion: 4,
939
+ snapshotChecksum: secondChecksum,
940
+ snapshotBase64: secondBytes.toString('base64'),
941
+ contract: 'text-yjs@1',
942
+ schemaVersion: 1,
943
+ }), /cannot exceed headVersion/);
944
+ assert.equal(fs.readFileSync(equalPath, 'utf8'), 'state A\n');
945
+ assert.equal(getReplicaByResourceId(equalResourceId).appliedVersion, 3);
946
+ });
947
+
533
948
  test('a promotion-born replica (which never attached) gets an attachment row for rehydration', () => {
534
949
  const { requestRehydration } = require('../state/attachments');
535
950
  const requested = requestRehydration({ resourceId });
@@ -572,3 +987,316 @@ test('revoked authority pauses the outbox and a successful rejoin resumes it', (
572
987
  'the same durable edit resumes after authority is restored',
573
988
  );
574
989
  });
990
+
991
+ test('cloud apply first captures an unsent disk save and sends no machine-local metadata', () => {
992
+ const opened = docs.openDoc(target);
993
+ const beforeText = fs.readFileSync(target, 'utf8');
994
+ const priorEvents = listEventsAfter(0);
995
+ const beforeSeq = priorEvents.length ? priorEvents[priorEvents.length - 1].seq : 0;
996
+ fs.writeFileSync(target, `disk-before-cloud\n${beforeText}`, 'utf8');
997
+
998
+ const mutations = require('../state/mutations');
999
+ const originalCommit = mutations.commitLocalMutation;
1000
+ mutations.commitLocalMutation = () => {
1001
+ throw new Error('simulated local journal outage');
1002
+ };
1003
+ try {
1004
+ docs._private.verifyOpenDocs();
1005
+ } finally {
1006
+ mutations.commitLocalMutation = originalCommit;
1007
+ }
1008
+ assert.equal(docs.getDocText(target), beforeText);
1009
+
1010
+ const remote = clientFromState(opened.state);
1011
+ const update = captureEdit(remote, (text) => text.insert(text.length, 'cloud-after-disk\n'));
1012
+ const beforeVersion = getReplicaByResourceId(resourceId).appliedVersion;
1013
+ docs.applyCloudDocMutation({
1014
+ envelope: {
1015
+ resourceId,
1016
+ authorityEpoch: getReplicaByResourceId(resourceId).authorityEpoch,
1017
+ mutationId: 'mut_cloud_after_disk_1',
1018
+ deviceId: 'device-remote-disk-capture',
1019
+ baseVersion: beforeVersion,
1020
+ contract: 'text-yjs@1',
1021
+ schemaVersion: 1,
1022
+ operationKind: 'yjs.update',
1023
+ operation: { kind: 'yjs.update', update },
1024
+ },
1025
+ version: beforeVersion + 1,
1026
+ committedAt: '2026-07-28T08:00:00.000Z',
1027
+ });
1028
+
1029
+ const finalText = fs.readFileSync(target, 'utf8');
1030
+ assert.match(finalText, /^disk-before-cloud\n/);
1031
+ assert.match(finalText, /cloud-after-disk\n/);
1032
+ assert.equal(getReplicaByResourceId(resourceId).appliedVersion, beforeVersion + 1);
1033
+ const diskEvent = listEventsAfter(beforeSeq).find((event) => (
1034
+ event.resource === opened.resource && event.source === 'doc:disk'
1035
+ ));
1036
+ assert.ok(diskEvent, 'the disk save received its own durable mutation identity');
1037
+ const diskClaim = claimCloudMutations().find(
1038
+ (claim) => claim.envelope.mutationId === diskEvent.mutationId,
1039
+ );
1040
+ assert.ok(diskClaim, 'the formerly unsent disk save is now in the shared outbox');
1041
+ assert.deepEqual(Object.keys(diskClaim.envelope.operation).sort(), ['kind', 'update']);
1042
+ });
1043
+
1044
+ test('cloud materialization CAS keeps a disk save that lands at the install boundary', () => {
1045
+ const opened = docs.openDoc(target);
1046
+ const beforeText = fs.readFileSync(target, 'utf8');
1047
+ const remote = clientFromState(opened.state);
1048
+ const update = captureEdit(remote, (text) => text.insert(text.length, 'cloud-cas\n'));
1049
+ const beforeVersion = getReplicaByResourceId(resourceId).appliedVersion;
1050
+ const child = writeFromAnotherProcessAfter(target, `disk-cas\n${beforeText}`, 75);
1051
+ docs._private.setDiskTransactionTestHooks({ pauseBeforeInstall: 350 });
1052
+ try {
1053
+ docs.applyCloudDocMutation({
1054
+ envelope: {
1055
+ resourceId,
1056
+ authorityEpoch: getReplicaByResourceId(resourceId).authorityEpoch,
1057
+ mutationId: 'mut_cloud_cas_race_1',
1058
+ deviceId: 'device-remote-cas',
1059
+ baseVersion: beforeVersion,
1060
+ contract: 'text-yjs@1',
1061
+ schemaVersion: 1,
1062
+ operationKind: 'yjs.update',
1063
+ operation: { kind: 'yjs.update', update },
1064
+ },
1065
+ version: beforeVersion + 1,
1066
+ committedAt: '2026-07-28T08:00:01.000Z',
1067
+ });
1068
+ } finally {
1069
+ docs._private.setDiskTransactionTestHooks();
1070
+ }
1071
+ child.unref();
1072
+ const finalText = fs.readFileSync(target, 'utf8');
1073
+ assert.match(finalText, /^disk-cas\n/);
1074
+ assert.match(finalText, /cloud-cas\n/);
1075
+ assert.equal(getReplicaByResourceId(resourceId).appliedVersion, beforeVersion + 1);
1076
+ });
1077
+
1078
+ test('snapshot CAS preserves and re-homes a local save racing first attachment', () => {
1079
+ const recipientResourceId = 'shr_ponmlkjihgfedcba';
1080
+ const recipientPath = path.join(docDir, 'snapshot-cas-recipient.md');
1081
+ fs.writeFileSync(recipientPath, '', 'utf8');
1082
+ const cloud = new Y.Doc();
1083
+ cloud.getText('content').insert(0, 'official snapshot\n');
1084
+ const snapshot = Buffer.from(Y.encodeStateAsUpdate(cloud));
1085
+ const checksum = require('crypto').createHash('sha256').update(snapshot).digest('hex');
1086
+ const child = writeFromAnotherProcessAfter(recipientPath, 'local during snapshot\n', 75);
1087
+
1088
+ docs._private.setDiskTransactionTestHooks({ pauseBeforeInstall: 350 });
1089
+ try {
1090
+ docs.installCloudDocSnapshot(recipientPath, {
1091
+ resourceId: recipientResourceId,
1092
+ authorityEpoch: 1,
1093
+ headVersion: 8,
1094
+ snapshotVersion: 8,
1095
+ snapshotChecksum: checksum,
1096
+ snapshotBase64: snapshot.toString('base64'),
1097
+ contract: 'text-yjs@1',
1098
+ schemaVersion: 1,
1099
+ });
1100
+ } finally {
1101
+ docs._private.setDiskTransactionTestHooks();
1102
+ }
1103
+ child.unref();
1104
+ const finalText = fs.readFileSync(recipientPath, 'utf8');
1105
+ assert.match(finalText, /official snapshot\n/);
1106
+ assert.match(finalText, /local during snapshot\n/);
1107
+ assert.equal(getReplicaByResourceId(recipientResourceId).appliedVersion, 8);
1108
+ assert.ok(
1109
+ claimCloudMutations().some((claim) => (
1110
+ claim.envelope.resourceId === recipientResourceId
1111
+ && claim.envelope.operationKind === 'yjs.update'
1112
+ )),
1113
+ 'the local save born during attachment is re-homed into the new outbox',
1114
+ );
1115
+ });
1116
+
1117
+ test('snapshot plan pins the captured file instead of adopting a later save', () => {
1118
+ const recipientResourceId = 'shr_captureplanrace1';
1119
+ const recipientPath = path.join(docDir, 'snapshot-capture-plan-recipient.md');
1120
+ fs.writeFileSync(recipientPath, '', 'utf8');
1121
+ const cloud = new Y.Doc();
1122
+ cloud.getText('content').insert(0, 'official snapshot\n');
1123
+ const snapshot = Buffer.from(Y.encodeStateAsUpdate(cloud));
1124
+ const checksum = require('crypto').createHash('sha256').update(snapshot).digest('hex');
1125
+ const child = writeFromAnotherProcessAfter(
1126
+ recipientPath,
1127
+ 'local after snapshot capture\n',
1128
+ 75,
1129
+ );
1130
+
1131
+ docs._private.setDiskTransactionTestHooks({ pauseAfterSnapshotCapture: 350 });
1132
+ try {
1133
+ docs.installCloudDocSnapshot(recipientPath, {
1134
+ resourceId: recipientResourceId,
1135
+ authorityEpoch: 1,
1136
+ headVersion: 9,
1137
+ snapshotVersion: 9,
1138
+ snapshotChecksum: checksum,
1139
+ snapshotBase64: snapshot.toString('base64'),
1140
+ contract: 'text-yjs@1',
1141
+ schemaVersion: 1,
1142
+ });
1143
+ } finally {
1144
+ docs._private.setDiskTransactionTestHooks();
1145
+ }
1146
+ child.unref();
1147
+
1148
+ const finalText = fs.readFileSync(recipientPath, 'utf8');
1149
+ assert.match(finalText, /official snapshot\n/);
1150
+ assert.match(finalText, /local after snapshot capture\n/);
1151
+ assert.equal(getReplicaByResourceId(recipientResourceId).appliedVersion, 9);
1152
+ assert.ok(
1153
+ claimCloudMutations().some((claim) => (
1154
+ claim.envelope.resourceId === recipientResourceId
1155
+ && claim.envelope.operationKind === 'yjs.update'
1156
+ )),
1157
+ 'the save after capture keeps one durable local mutation identity',
1158
+ );
1159
+ });
1160
+
1161
+ test('snapshot replacement retries share one finite budget', () => {
1162
+ const boundedResourceId = 'shr_snapshotbounded1';
1163
+ const boundedPath = path.join(docDir, 'snapshot-bounded.md');
1164
+ fs.writeFileSync(boundedPath, '', 'utf8');
1165
+ const cloud = new Y.Doc();
1166
+ cloud.getText('content').insert(0, 'never installs\n');
1167
+ const snapshot = Buffer.from(Y.encodeStateAsUpdate(cloud));
1168
+ const checksum = require('crypto').createHash('sha256').update(snapshot).digest('hex');
1169
+ const docDisk = require('../state/doc-disk');
1170
+ const originalCross = docDisk.cross;
1171
+ let attempts = 0;
1172
+ docDisk.cross = () => {
1173
+ attempts += 1;
1174
+ return { status: 'changed', reason: 'simulated continuous replacer' };
1175
+ };
1176
+ try {
1177
+ assert.throws(() => docs.installCloudDocSnapshot(boundedPath, {
1178
+ resourceId: boundedResourceId,
1179
+ authorityEpoch: 1,
1180
+ headVersion: 1,
1181
+ snapshotVersion: 1,
1182
+ snapshotChecksum: checksum,
1183
+ snapshotBase64: snapshot.toString('base64'),
1184
+ contract: 'text-yjs@1',
1185
+ schemaVersion: 1,
1186
+ }), /kept changing during snapshot install/);
1187
+ } finally {
1188
+ docDisk.cross = originalCross;
1189
+ }
1190
+ assert.equal(attempts, 8);
1191
+ assert.equal(docDisk.listSnapshots(docs.docResourceName(boundedPath)).length, 0);
1192
+ assert.equal(fs.readFileSync(boundedPath, 'utf8'), '');
1193
+ });
1194
+
1195
+ test('snapshot install recovers exactly once after the native helper dies post-install', () => {
1196
+ const recipientResourceId = 'shr_crashrecoverabcd';
1197
+ const recipientPath = path.join(docDir, 'snapshot-crash-recipient.md');
1198
+ fs.writeFileSync(recipientPath, '', 'utf8');
1199
+ const cloud = new Y.Doc();
1200
+ cloud.getText('content').insert(0, 'snapshot survives helper crash\n');
1201
+ const snapshot = Buffer.from(Y.encodeStateAsUpdate(cloud));
1202
+ const checksum = require('crypto').createHash('sha256').update(snapshot).digest('hex');
1203
+ const input = {
1204
+ resourceId: recipientResourceId,
1205
+ authorityEpoch: 1,
1206
+ headVersion: 3,
1207
+ snapshotVersion: 3,
1208
+ snapshotChecksum: checksum,
1209
+ snapshotBase64: snapshot.toString('base64'),
1210
+ contract: 'text-yjs@1',
1211
+ schemaVersion: 1,
1212
+ };
1213
+
1214
+ docs._private.setDiskTransactionTestHooks({ crashAfterInstall: true });
1215
+ try {
1216
+ assert.throws(
1217
+ () => docs.installCloudDocSnapshot(recipientPath, input),
1218
+ /native file transaction failed/,
1219
+ );
1220
+ } finally {
1221
+ docs._private.setDiskTransactionTestHooks();
1222
+ }
1223
+ assert.equal(fs.readFileSync(recipientPath, 'utf8'), 'snapshot survives helper crash\n');
1224
+ assert.equal(getReplicaByResourceId(recipientResourceId), null);
1225
+
1226
+ const snapshotText = fs.readFileSync(recipientPath, 'utf8');
1227
+ docs.writeDocText(
1228
+ recipientPath,
1229
+ `${snapshotText}local write after helper crash\n`,
1230
+ {
1231
+ baseHash: require('crypto').createHash('sha256').update(snapshotText).digest('hex'),
1232
+ mutationId: 'mut_snapshot_crash_local_01',
1233
+ },
1234
+ );
1235
+ const retried = docs.installCloudDocSnapshot(recipientPath, input);
1236
+ assert.equal(retried.duplicate, true);
1237
+ assert.equal(getReplicaByResourceId(recipientResourceId).appliedVersion, 3);
1238
+ assert.equal(
1239
+ fs.readFileSync(recipientPath, 'utf8'),
1240
+ 'snapshot survives helper crash\nlocal write after helper crash\n',
1241
+ );
1242
+ assert.equal(
1243
+ getMutation(`resource:${recipientResourceId}`, 'mut_snapshot_crash_local_01')?.mutationId,
1244
+ 'mut_snapshot_crash_local_01',
1245
+ );
1246
+ assert.equal(
1247
+ listEventsAfter(0).filter((event) => (
1248
+ event.sharedResourceId === recipientResourceId
1249
+ && event.source === 'doc:cloud-snapshot'
1250
+ )).length,
1251
+ 1,
1252
+ );
1253
+ });
1254
+
1255
+ test('rebasing a physical predecessor also rebases its durable semantic base', () => {
1256
+ const docDisk = require('../state/doc-disk');
1257
+ const file = path.join(docDir, 'rebase-semantic-base.md');
1258
+ fs.writeFileSync(file, 'A\n', 'utf8');
1259
+ const mutation = {
1260
+ channelId: 'doc:rebase-semantic-base',
1261
+ mutationId: 'mut_rebase_semantic_01',
1262
+ localResource: 'doc:rebase-semantic-base',
1263
+ };
1264
+ docDisk.insertBase(openLocalDb(), mutation, docDisk.observe(file), 'doc:write');
1265
+ docDisk.prepare(mutation.channelId, mutation.mutationId, 'B\n');
1266
+ fs.writeFileSync(file, 'B\n', 'utf8');
1267
+ const rebased = docDisk.rebaseExpected(
1268
+ mutation.channelId,
1269
+ mutation.mutationId,
1270
+ docDisk.observe(file),
1271
+ );
1272
+ assert.equal(rebased.baseText, 'B\n');
1273
+ docDisk.cancel(mutation.channelId, mutation.mutationId);
1274
+ });
1275
+
1276
+ test('legacy restart recovery keeps a newer disk successor over an older saving row', () => {
1277
+ const restartPath = path.join(docDir, 'restart-successor.md');
1278
+ fs.writeFileSync(restartPath, 'base\n', 'utf8');
1279
+ const opened = docs.openDoc(restartPath);
1280
+ const client = clientFromState(opened.state);
1281
+ const update = captureEdit(client, (text) => text.insert(text.length, 'older journal\n'));
1282
+ docs.closeAllDocs();
1283
+
1284
+ journalMutation({
1285
+ channelId: opened.resource,
1286
+ localResource: opened.resource,
1287
+ mutationId: 'mut_restart_successor_1',
1288
+ authorityEpoch: 0,
1289
+ baseCloudVersion: 0,
1290
+ contract: 'text-yjs@1',
1291
+ schemaVersion: 1,
1292
+ operationKind: 'yjs.update',
1293
+ operation: { kind: 'yjs.update', update },
1294
+ });
1295
+ fs.writeFileSync(restartPath, 'newer disk\nbase\n', 'utf8');
1296
+
1297
+ docs.openDoc(restartPath);
1298
+ const recovered = fs.readFileSync(restartPath, 'utf8');
1299
+ assert.match(recovered, /^newer disk\n/);
1300
+ assert.match(recovered, /older journal\n/);
1301
+ assert.equal(getMutation(opened.resource, 'mut_restart_successor_1').state, 'saved-local');
1302
+ });