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.
- package/lib/shared-realtime-tunnel.js +18 -4
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/browser/cookie-jar.js +68 -20
- package/runtime/scripts/amalgm-mcp/project-context/store.js +87 -19
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +13 -0
- package/runtime/scripts/amalgm-mcp/state/attachments.js +40 -12
- package/runtime/scripts/amalgm-mcp/state/db.js +216 -17
- package/runtime/scripts/amalgm-mcp/state/doc-disk.js +550 -0
- package/runtime/scripts/amalgm-mcp/state/docs.js +1101 -301
- package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +99 -0
- package/runtime/scripts/amalgm-mcp/state/mutations.js +507 -130
- package/runtime/scripts/amalgm-mcp/state/promotions.js +21 -1
- package/runtime/scripts/amalgm-mcp/state/replicas.js +134 -13
- package/runtime/scripts/amalgm-mcp/state/shared-rest.js +14 -8
- package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +193 -0
- package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +313 -94
- package/runtime/scripts/amalgm-mcp/tests/fixtures/code-project-stream-v2.json +1 -0
- package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +32 -17
- package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +84 -0
- package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +376 -13
- package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +309 -0
- package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +758 -30
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +72 -3
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +33 -6
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +27 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +334 -191
- package/runtime/scripts/amalgm-mcp/tests/workspace-durable.test.js +24 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-local-intent.test.js +1999 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-native-file-transaction.test.js +364 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +98 -4
- package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +156 -8
- package/runtime/scripts/amalgm-mcp/tests/workspace-provenance.test.js +185 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +174 -269
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer-liveness.test.js +26 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +2060 -407
- package/runtime/scripts/amalgm-mcp/tests/workspace-sync-stats.test.js +119 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-transition.test.js +1446 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +5319 -3
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +191 -1
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +3140 -17
- package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +135 -637
- package/runtime/scripts/amalgm-mcp/workspace/access-store.js +5 -1
- package/runtime/scripts/amalgm-mcp/workspace/card.js +7 -7
- package/runtime/scripts/amalgm-mcp/workspace/cards.js +36 -12
- package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +436 -173
- package/runtime/scripts/amalgm-mcp/workspace/content.js +32 -0
- package/runtime/scripts/amalgm-mcp/workspace/durable.js +45 -0
- package/runtime/scripts/amalgm-mcp/workspace/git.js +391 -37
- package/runtime/scripts/amalgm-mcp/workspace/intent.js +535 -0
- package/runtime/scripts/amalgm-mcp/workspace/merge.js +89 -6
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/BUILD.md +31 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/darwin-arm64/amalgm-file-transaction +0 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/linux-x64/amalgm-file-transaction +0 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/build.mjs +72 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/client.js +165 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.mod +5 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.sum +2 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/main.go +2201 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/manifest.json +19 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_darwin.go +18 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_linux.go +18 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_darwin.go +33 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_linux.go +47 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/verify.mjs +43 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_darwin.go +114 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_linux.go +84 -0
- package/runtime/scripts/amalgm-mcp/workspace/objects.js +185 -55
- package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +616 -168
- package/runtime/scripts/amalgm-mcp/workspace/provenance.js +240 -0
- package/runtime/scripts/amalgm-mcp/workspace/reducer.js +1979 -570
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +49 -28
- package/runtime/scripts/amalgm-mcp/workspace/store.js +77 -4
- package/runtime/scripts/amalgm-mcp/workspace/sync-stats.js +240 -0
- package/runtime/scripts/amalgm-mcp/workspace/transition.js +1729 -192
- package/runtime/scripts/amalgm-mcp/workspace/tree/floor.js +100 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +7 -1
- package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +73 -61
- package/runtime/scripts/amalgm-mcp/workspace/tree/observation-holds.js +127 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/path-state.js +12 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +51 -6
- package/runtime/scripts/amalgm-mcp/workspace/tree/root-binding.js +125 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +79 -8
- package/runtime/scripts/amalgm-mcp/workspace/tree/symlinks.js +76 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +3480 -165
- package/runtime/scripts/amalgm-mcp/workspace/tree-materialization.js +3766 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +899 -71
- 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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
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('
|
|
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
|
-
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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
|
-
|
|
420
|
-
|
|
421
|
-
|
|
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(
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
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
|
-
|
|
472
|
-
|
|
473
|
-
|
|
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
|
+
});
|