amalgm 0.1.238 → 0.1.240

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 (86) hide show
  1. package/lib/shared-realtime-tunnel.js +22 -14
  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/state/attachments.js +40 -12
  6. package/runtime/scripts/amalgm-mcp/state/db.js +216 -17
  7. package/runtime/scripts/amalgm-mcp/state/doc-disk.js +550 -0
  8. package/runtime/scripts/amalgm-mcp/state/docs.js +1101 -301
  9. package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +99 -0
  10. package/runtime/scripts/amalgm-mcp/state/mutations.js +507 -130
  11. package/runtime/scripts/amalgm-mcp/state/promotions.js +21 -1
  12. package/runtime/scripts/amalgm-mcp/state/replicas.js +134 -13
  13. package/runtime/scripts/amalgm-mcp/state/shared-rest.js +14 -8
  14. package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +193 -0
  15. package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +313 -94
  16. package/runtime/scripts/amalgm-mcp/tests/fixtures/code-project-stream-v2.json +1 -0
  17. package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +32 -17
  18. package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +84 -0
  19. package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +376 -13
  20. package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +309 -0
  21. package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +758 -30
  22. package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +72 -3
  23. package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +33 -6
  24. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +27 -0
  25. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +334 -191
  26. package/runtime/scripts/amalgm-mcp/tests/workspace-durable.test.js +24 -0
  27. package/runtime/scripts/amalgm-mcp/tests/workspace-local-intent.test.js +1999 -0
  28. package/runtime/scripts/amalgm-mcp/tests/workspace-native-file-transaction.test.js +364 -0
  29. package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +81 -3
  30. package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +114 -8
  31. package/runtime/scripts/amalgm-mcp/tests/workspace-provenance.test.js +185 -0
  32. package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +174 -269
  33. package/runtime/scripts/amalgm-mcp/tests/workspace-reducer-liveness.test.js +26 -0
  34. package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +2060 -407
  35. package/runtime/scripts/amalgm-mcp/tests/workspace-sync-stats.test.js +119 -0
  36. package/runtime/scripts/amalgm-mcp/tests/workspace-transition.test.js +1446 -0
  37. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +5319 -3
  38. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +191 -1
  39. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +3140 -17
  40. package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +135 -637
  41. package/runtime/scripts/amalgm-mcp/workspace/access-store.js +5 -1
  42. package/runtime/scripts/amalgm-mcp/workspace/card.js +7 -7
  43. package/runtime/scripts/amalgm-mcp/workspace/cards.js +36 -12
  44. package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +436 -173
  45. package/runtime/scripts/amalgm-mcp/workspace/content.js +32 -0
  46. package/runtime/scripts/amalgm-mcp/workspace/durable.js +45 -0
  47. package/runtime/scripts/amalgm-mcp/workspace/git.js +391 -37
  48. package/runtime/scripts/amalgm-mcp/workspace/intent.js +535 -0
  49. package/runtime/scripts/amalgm-mcp/workspace/merge.js +89 -6
  50. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/BUILD.md +31 -0
  51. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/darwin-arm64/amalgm-file-transaction +0 -0
  52. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/linux-x64/amalgm-file-transaction +0 -0
  53. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/build.mjs +72 -0
  54. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/client.js +165 -0
  55. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.mod +5 -0
  56. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.sum +2 -0
  57. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/main.go +2201 -0
  58. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/manifest.json +19 -0
  59. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_darwin.go +18 -0
  60. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_linux.go +18 -0
  61. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_darwin.go +33 -0
  62. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_linux.go +47 -0
  63. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/verify.mjs +43 -0
  64. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_darwin.go +114 -0
  65. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_linux.go +84 -0
  66. package/runtime/scripts/amalgm-mcp/workspace/objects.js +119 -54
  67. package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +616 -168
  68. package/runtime/scripts/amalgm-mcp/workspace/provenance.js +240 -0
  69. package/runtime/scripts/amalgm-mcp/workspace/reducer.js +1979 -570
  70. package/runtime/scripts/amalgm-mcp/workspace/rest.js +49 -28
  71. package/runtime/scripts/amalgm-mcp/workspace/store.js +77 -4
  72. package/runtime/scripts/amalgm-mcp/workspace/sync-stats.js +240 -0
  73. package/runtime/scripts/amalgm-mcp/workspace/transition.js +1729 -192
  74. package/runtime/scripts/amalgm-mcp/workspace/tree/floor.js +100 -0
  75. package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +7 -1
  76. package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +73 -61
  77. package/runtime/scripts/amalgm-mcp/workspace/tree/observation-holds.js +127 -0
  78. package/runtime/scripts/amalgm-mcp/workspace/tree/path-state.js +12 -0
  79. package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +51 -6
  80. package/runtime/scripts/amalgm-mcp/workspace/tree/root-binding.js +125 -0
  81. package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +79 -8
  82. package/runtime/scripts/amalgm-mcp/workspace/tree/symlinks.js +76 -0
  83. package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +3480 -165
  84. package/runtime/scripts/amalgm-mcp/workspace/tree-materialization.js +3766 -0
  85. package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +899 -71
  86. 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('child_process');
8
9
 
9
10
  const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-workspace-tree-test-'));
10
11
  process.env.AMALGM_DIR = path.join(tempRoot, '.amalgm');
@@ -12,10 +13,14 @@ process.env.AMALGM_WORKSPACES_DIR = path.join(tempRoot, 'workspaces');
12
13
  process.env.AMALGM_COMPUTER_ID = 'computer-tree-test';
13
14
  fs.mkdirSync(process.env.AMALGM_WORKSPACES_DIR, { recursive: true });
14
15
 
15
- const { closeLocalDb, openLocalDb } = require('../state/db');
16
+ const {
17
+ backfillWorkspaceTrashComponents,
18
+ closeLocalDb,
19
+ openLocalDb,
20
+ } = require('../state/db');
16
21
  const docs = require('../state/docs');
17
22
  const { handleProjectRegister, handleTreeList } = require('../workspace/rest');
18
- const { upsertWorkspace } = require('../workspace/store');
23
+ const { listWorkspaces, upsertWorkspace } = require('../workspace/store');
19
24
  const {
20
25
  commitPathCreate,
21
26
  commitPathRename,
@@ -24,9 +29,18 @@ const {
24
29
  enrollRegisteredWorkspaces,
25
30
  listWorkspaceNodes,
26
31
  nodeForPath,
32
+ reconcileRegisteredWorkspaceTrees,
27
33
  reconcileWorkspaceTree,
28
34
  } = require('../workspace/tree-store');
29
- const { isUuidV7 } = require('../workspace/tree/identity');
35
+ const {
36
+ crossTreeOperationWithRaceCapture,
37
+ finishTreeOperationPlan,
38
+ projectStoredTreeOperation,
39
+ recoverAllTreePlans,
40
+ } = require('../workspace/tree-cloud');
41
+ const treeMaterialization = require('../workspace/tree-materialization');
42
+ const { isUuidV7, uuidV7 } = require('../workspace/tree/identity');
43
+ const { trashPath } = require('../workspace/tree/local-state');
30
44
 
31
45
  test.after(() => {
32
46
  docs.closeAllDocs();
@@ -70,7 +84,7 @@ test('enrollment gives every node a UUID and open docs keep one resource through
70
84
  assert.equal(state.node_id, beforeNode.id);
71
85
  });
72
86
 
73
- test('bounded automatic tree passes skip large workspaces without blocking explicit enrollment', () => {
87
+ test('bounded discovery may skip a large tree but recurring reconciliation repairs it uncapped', () => {
74
88
  const root = path.join(process.env.AMALGM_WORKSPACES_DIR, 'bounded-observer');
75
89
  fs.mkdirSync(root, { recursive: true });
76
90
  fs.writeFileSync(path.join(root, 'one.txt'), 'one\n');
@@ -78,7 +92,15 @@ test('bounded automatic tree passes skip large workspaces without blocking expli
78
92
  const workspace = upsertWorkspace({ path: root, name: 'Bounded observer', source: 'computer' });
79
93
 
80
94
  const boundedEnrollment = enrollWorkspace(workspace.id, { maxNodes: 1 });
81
- assert.equal(boundedEnrollment.skipped, 'node_limit');
95
+ assert.equal(
96
+ boundedEnrollment.skipped,
97
+ 'workspace-root-enrollment-pending',
98
+ );
99
+ assert.equal(boundedEnrollment.state, 'held');
100
+ assert.ok(listWorkspaces().find((item) => item.id === workspace.id)
101
+ .treeObservation.holds.some((hold) => (
102
+ hold.reason === 'workspace-root-enrollment-pending'
103
+ )));
82
104
  assert.equal(listWorkspaceNodes(workspace.id).length, 1, 'bounded scan must not persist a partial tree');
83
105
 
84
106
  const explicitEnrollment = enrollWorkspace(workspace.id);
@@ -87,7 +109,156 @@ test('bounded automatic tree passes skip large workspaces without blocking expli
87
109
  fs.writeFileSync(path.join(root, 'three.txt'), 'three\n');
88
110
  const boundedReconcile = reconcileWorkspaceTree(workspace.id, { maxNodes: 1 });
89
111
  assert.equal(boundedReconcile.skipped, 'node_limit');
90
- assert.equal(nodeForPath(path.join(root, 'three.txt')), null, 'automatic reconciliation skips the oversized tree');
112
+ assert.equal(nodeForPath(path.join(root, 'three.txt')), null, 'an optional bounded pass may skip the oversized tree');
113
+
114
+ reconcileRegisteredWorkspaceTrees();
115
+ assert.ok(
116
+ nodeForPath(path.join(root, 'three.txt')),
117
+ 'the recurring correctness pass has no permanent size blind spot',
118
+ );
119
+ });
120
+
121
+ test('an unbound large root remains baseline state until one uncapped enrollment', () => {
122
+ const root = path.join(
123
+ process.env.AMALGM_WORKSPACES_DIR,
124
+ 'large-baseline-enrollment',
125
+ );
126
+ fs.mkdirSync(root, { recursive: true });
127
+ for (let index = 0; index < 2_001; index += 1) {
128
+ fs.writeFileSync(path.join(root, `file-${index}.txt`), `${index}\n`);
129
+ }
130
+ const workspace = upsertWorkspace({
131
+ path: root,
132
+ name: 'Large baseline enrollment',
133
+ source: 'computer',
134
+ });
135
+ const held = enrollRegisteredWorkspaces({
136
+ database: openLocalDb(),
137
+ maxNodes: 2_000,
138
+ }).find((result) => result.workspaceId === workspace.id);
139
+ assert.equal(held.state, 'held');
140
+ assert.equal(held.skipped, 'workspace-root-enrollment-pending');
141
+ assert.equal(openLocalDb().prepare(`
142
+ SELECT COUNT(*) AS count
143
+ FROM mutation_journal
144
+ WHERE local_resource = ?
145
+ `).get(`workspace-tree:${workspace.id}`).count, 0);
146
+
147
+ const [enrolled] = reconcileRegisteredWorkspaceTrees({
148
+ database: openLocalDb(),
149
+ }).filter((result) => result.workspaceId === workspace.id);
150
+ assert.equal(enrolled.state, 'ready');
151
+ assert.equal(listWorkspaceNodes(workspace.id).length, 2_002);
152
+ assert.equal(openLocalDb().prepare(`
153
+ SELECT COUNT(*) AS count
154
+ FROM mutation_journal
155
+ WHERE local_resource = ?
156
+ `).get(`workspace-tree:${workspace.id}`).count, 0);
157
+
158
+ fs.writeFileSync(path.join(root, 'after-baseline.txt'), 'new\n');
159
+ const observed = reconcileWorkspaceTree(workspace.id);
160
+ assert.deepEqual(observed.operations.map((operation) => operation.kind), [
161
+ 'tree.create',
162
+ ]);
163
+ });
164
+
165
+ test('a missing or replaced workspace root holds without lending its UUID', () => {
166
+ const root = path.join(
167
+ process.env.AMALGM_WORKSPACES_DIR,
168
+ 'durable-root-binding',
169
+ );
170
+ const movedRoot = `${root}-moved`;
171
+ const originalFile = path.join(root, 'original.txt');
172
+ fs.mkdirSync(root, { recursive: true });
173
+ fs.writeFileSync(originalFile, 'original\n');
174
+ const workspace = upsertWorkspace({
175
+ path: root,
176
+ name: 'Durable root binding',
177
+ source: 'computer',
178
+ });
179
+ enrollWorkspace(workspace.id);
180
+ const originalId = nodeForPath(originalFile).node.id;
181
+ const journalCount = () => openLocalDb().prepare(`
182
+ SELECT COUNT(*) AS count
183
+ FROM mutation_journal
184
+ WHERE local_resource = ?
185
+ `).get(`workspace-tree:${workspace.id}`).count;
186
+ const before = journalCount();
187
+
188
+ fs.renameSync(root, movedRoot);
189
+ assert.throws(() => commitTreeOperation({
190
+ kind: 'tree.rename',
191
+ workspaceId: workspace.id,
192
+ nodeId: originalId,
193
+ name: 'renamed.txt',
194
+ }), (error) => error?.code === 'WORKSPACE_TREE_ROOT_UNVERIFIED');
195
+ assert.ok(listWorkspaces().find((item) => item.id === workspace.id)
196
+ .treeObservation.holds.some((hold) => (
197
+ hold.reason === 'workspace-root-missing'
198
+ )));
199
+ const missing = reconcileWorkspaceTree(workspace.id);
200
+ assert.equal(missing.skipped, 'workspace-root-missing');
201
+ assert.equal(missing.state, 'held');
202
+ assert.equal(journalCount(), before);
203
+
204
+ fs.mkdirSync(root);
205
+ fs.writeFileSync(path.join(root, 'unrelated.txt'), 'unrelated\n');
206
+ const replaced = reconcileWorkspaceTree(workspace.id);
207
+ assert.equal(replaced.skipped, 'workspace-root-replaced');
208
+ assert.equal(replaced.state, 'held');
209
+ assert.equal(nodeForPath(path.join(root, 'unrelated.txt')), null);
210
+ assert.equal(journalCount(), before);
211
+ assert.equal(
212
+ listWorkspaces().find((item) => item.id === workspace.id).status,
213
+ 'held',
214
+ );
215
+
216
+ fs.rmSync(root, { recursive: true });
217
+ fs.renameSync(movedRoot, root);
218
+ const restored = reconcileWorkspaceTree(workspace.id);
219
+ assert.deepEqual(restored.operations, []);
220
+ assert.equal(nodeForPath(originalFile).node.id, originalId);
221
+ assert.equal(journalCount(), before);
222
+ assert.equal(
223
+ listWorkspaces().find((item) => item.id === workspace.id).treeObservation,
224
+ null,
225
+ );
226
+ });
227
+
228
+ test('an observed symlink escape is held before it can enter the mutation rail', () => {
229
+ const root = path.join(
230
+ process.env.AMALGM_WORKSPACES_DIR,
231
+ 'observed-symlink-escape',
232
+ );
233
+ fs.mkdirSync(root, { recursive: true });
234
+ const workspace = upsertWorkspace({
235
+ path: root,
236
+ name: 'Observed symlink escape',
237
+ source: 'computer',
238
+ });
239
+ enrollWorkspace(workspace.id);
240
+ const link = path.join(root, 'outside');
241
+ fs.symlinkSync('../../outside', link);
242
+
243
+ const held = reconcileWorkspaceTree(workspace.id);
244
+ assert.deepEqual(held.operations, []);
245
+ assert.ok(held.held.some((item) => (
246
+ item.reason === 'symlink-escapes-workspace'
247
+ && item.paths.includes('outside')
248
+ )));
249
+ assert.equal(openLocalDb().prepare(`
250
+ SELECT COUNT(*) AS count
251
+ FROM mutation_journal
252
+ WHERE local_resource = ?
253
+ `).get(`workspace-tree:${workspace.id}`).count, 0);
254
+ assert.equal(nodeForPath(link), null);
255
+
256
+ fs.unlinkSync(link);
257
+ assert.deepEqual(reconcileWorkspaceTree(workspace.id).operations, []);
258
+ assert.equal(
259
+ listWorkspaces().find((item) => item.id === workspace.id).treeObservation,
260
+ null,
261
+ );
91
262
  });
92
263
 
93
264
  test('first-update enrollment assigns UUIDs to existing files and ignores nested noise', async () => {
@@ -162,6 +333,13 @@ test('a git checkout never becomes per-file workspace operations', () => {
162
333
  const enrolled = enrollWorkspace(workspace.id);
163
334
  assert.equal(enrolled.skipped, 'git-owned');
164
335
  assert.equal(listWorkspaceNodes(workspace.id).length, 1);
336
+ const boundRoot = openLocalDb().prepare(`
337
+ SELECT device_id, inode
338
+ FROM workspace_nodes
339
+ WHERE workspace_id = ? AND id = ?
340
+ `).get(workspace.id, workspace.id);
341
+ assert.ok(boundRoot.device_id);
342
+ assert.ok(boundRoot.inode);
165
343
 
166
344
  fs.writeFileSync(path.join(root, 'more.txt'), 'more\n');
167
345
  const reconciled = reconcileWorkspaceTree(workspace.id);
@@ -172,6 +350,25 @@ test('a git checkout never becomes per-file workspace operations', () => {
172
350
  .get(`workspace-tree:${workspace.id}`).count,
173
351
  0,
174
352
  );
353
+
354
+ const originalRoot = `${root}-original`;
355
+ fs.renameSync(root, originalRoot);
356
+ fs.mkdirSync(path.join(root, '.git'), { recursive: true });
357
+ const unrelated = path.join(root, 'unrelated.txt');
358
+ fs.writeFileSync(unrelated, 'unrelated\n');
359
+ assert.equal(nodeForPath(unrelated), null);
360
+ assert.ok(listWorkspaces().find((item) => item.id === workspace.id)
361
+ .treeObservation.holds.some((hold) => (
362
+ hold.reason === 'workspace-root-replaced'
363
+ )));
364
+
365
+ fs.rmSync(root, { recursive: true });
366
+ fs.renameSync(originalRoot, root);
367
+ assert.equal(reconcileWorkspaceTree(workspace.id).skipped, 'git-owned');
368
+ assert.equal(
369
+ listWorkspaces().find((item) => item.id === workspace.id).treeObservation,
370
+ null,
371
+ );
175
372
  });
176
373
 
177
374
  test('trash is editor-class while permanent removal is owner-only', () => {
@@ -218,7 +415,285 @@ test('direct shell creates and renames enter the same UUID tree journal', () =>
218
415
  assert.deepEqual(rows.map((row) => row.operation_kind), ['tree.create', 'tree.rename']);
219
416
  });
220
417
 
221
- test('a missing node never lends its UUID to a recreated path with a recycled inode', () => {
418
+ test('a failed physical identity fence rolls back its UUID projection', () => {
419
+ const root = path.join(
420
+ process.env.AMALGM_WORKSPACES_DIR,
421
+ 'identity-fence-rollback',
422
+ );
423
+ const target = path.join(root, 'adopted.txt');
424
+ const retained = path.join(root, 'retained.txt');
425
+ fs.mkdirSync(root);
426
+ const workspace = upsertWorkspace({
427
+ path: root,
428
+ name: 'Identity fence rollback',
429
+ source: 'computer',
430
+ });
431
+ enrollWorkspace(workspace.id);
432
+ fs.writeFileSync(target, 'receipt-owned bytes\n');
433
+ const identity = fs.lstatSync(target);
434
+ const nodeId = uuidV7();
435
+ const mutationId = `mut_${'identityfencerollback'.padEnd(32, '0')}`;
436
+ const changedAt = new Date().toISOString();
437
+ const operation = {
438
+ kind: 'tree.create',
439
+ workspaceId: workspace.id,
440
+ nodeId,
441
+ parentId: workspace.id,
442
+ nodeKind: 'file',
443
+ name: path.basename(target),
444
+ createdAt: changedAt,
445
+ changedAt,
446
+ };
447
+
448
+ assert.throws(
449
+ () => commitTreeOperation(operation, {
450
+ role: 'owner',
451
+ mutationId,
452
+ diskAlreadyApplied: true,
453
+ expectedDiskIdentity: {
454
+ path: target,
455
+ kind: 'file',
456
+ deviceId: String(identity.dev),
457
+ inode: String(identity.ino),
458
+ },
459
+ testHooks: {
460
+ afterOperationDatabaseProjectionBeforeIdentityFence() {
461
+ fs.renameSync(target, retained);
462
+ fs.writeFileSync(target, 'same-kind replacement\n');
463
+ },
464
+ },
465
+ }),
466
+ (error) => error?.code === 'WORKSPACE_TREE_PHYSICAL_BINDING_CHANGED',
467
+ );
468
+
469
+ assert.equal(
470
+ openLocalDb().prepare(`
471
+ SELECT COUNT(*) AS count
472
+ FROM workspace_nodes
473
+ WHERE workspace_id = ? AND id = ?
474
+ `).get(workspace.id, nodeId).count,
475
+ 0,
476
+ 'the rejected replacement never keeps the adopted UUID',
477
+ );
478
+ assert.notEqual(nodeForPath(target)?.node.id, nodeId);
479
+
480
+ fs.unlinkSync(target);
481
+ fs.renameSync(retained, target);
482
+ commitTreeOperation(operation, {
483
+ role: 'owner',
484
+ mutationId,
485
+ diskAlreadyApplied: true,
486
+ expectedDiskIdentity: {
487
+ path: target,
488
+ kind: 'file',
489
+ deviceId: String(identity.dev),
490
+ inode: String(identity.ino),
491
+ },
492
+ });
493
+ assert.equal(nodeForPath(target).node.id, nodeId);
494
+ });
495
+
496
+ test('operation receipts keep endpoint resemblance independent', () => {
497
+ const root = path.join(process.env.AMALGM_WORKSPACES_DIR, 'receipt-not-resemblance');
498
+ fs.mkdirSync(root);
499
+ const workspace = upsertWorkspace({
500
+ path: root,
501
+ name: 'Receipt not resemblance',
502
+ source: 'computer',
503
+ });
504
+ enrollWorkspace(workspace.id);
505
+
506
+ const createdId = uuidV7();
507
+ const createdPath = path.join(root, 'created.txt');
508
+ commitTreeOperation({
509
+ kind: 'tree.create',
510
+ workspaceId: workspace.id,
511
+ nodeId: createdId,
512
+ parentId: workspace.id,
513
+ nodeKind: 'file',
514
+ name: 'created.txt',
515
+ }, {
516
+ role: 'owner',
517
+ testHooks: {
518
+ afterOperationPlan() {
519
+ fs.writeFileSync(createdPath, '');
520
+ },
521
+ },
522
+ });
523
+ assert.equal(nodeForPath(createdPath).node.id, createdId);
524
+ const retainedCreate = fs.readdirSync(root)
525
+ .find((name) => name.startsWith('created.amalgm-retained-'));
526
+ assert.ok(retainedCreate);
527
+ reconcileWorkspaceTree(workspace.id);
528
+ const retainedCreateNode = nodeForPath(path.join(root, retainedCreate)).node;
529
+ assert.notEqual(retainedCreateNode.id, createdId);
530
+
531
+ const source = path.join(root, 'before.txt');
532
+ const destination = path.join(root, 'after.txt');
533
+ fs.writeFileSync(source, 'same inode and bytes\n');
534
+ reconcileWorkspaceTree(workspace.id);
535
+ const movedId = nodeForPath(source).node.id;
536
+ commitTreeOperation({
537
+ kind: 'tree.rename',
538
+ workspaceId: workspace.id,
539
+ nodeId: movedId,
540
+ name: 'after.txt',
541
+ }, {
542
+ role: 'owner',
543
+ testHooks: {
544
+ afterOperationPlan() {
545
+ fs.renameSync(source, destination);
546
+ },
547
+ },
548
+ });
549
+ assert.equal(fs.existsSync(source), false);
550
+ assert.equal(nodeForPath(destination).node.id, movedId);
551
+ });
552
+
553
+ test('a target-parent symlink swap cannot send a tree move outside its workspace', () => {
554
+ const root = path.join(process.env.AMALGM_WORKSPACES_DIR, 'parent-symlink-race');
555
+ const outside = path.join(tempRoot, 'parent-symlink-outside');
556
+ const source = path.join(root, 'source.txt');
557
+ const folder = path.join(root, 'A');
558
+ const heldFolder = path.join(root, 'A-held');
559
+ fs.mkdirSync(folder, { recursive: true });
560
+ fs.mkdirSync(outside);
561
+ fs.writeFileSync(source, 'source bytes\n');
562
+ const workspace = upsertWorkspace({
563
+ path: root,
564
+ name: 'Parent symlink race',
565
+ source: 'computer',
566
+ });
567
+ enrollWorkspace(workspace.id);
568
+ const sourceId = nodeForPath(source).node.id;
569
+ const folderId = nodeForPath(folder).node.id;
570
+
571
+ assert.throws(
572
+ () => commitTreeOperation({
573
+ kind: 'tree.move',
574
+ workspaceId: workspace.id,
575
+ nodeId: sourceId,
576
+ parentId: folderId,
577
+ name: 'source.txt',
578
+ }, {
579
+ role: 'owner',
580
+ testHooks: {
581
+ afterOperationPlan() {
582
+ fs.renameSync(folder, heldFolder);
583
+ fs.symlinkSync(outside, folder, 'dir');
584
+ },
585
+ },
586
+ }),
587
+ (error) => (
588
+ error?.code === 'WORKSPACE_TREE_RETRY_PENDING'
589
+ && error.reason === 'symlink-escapes-workspace'
590
+ ),
591
+ );
592
+
593
+ assert.deepEqual(fs.readdirSync(outside), []);
594
+ assert.ok(listWorkspaces().find((item) => item.id === workspace.id)
595
+ .treeObservation.holds.some((hold) => (
596
+ hold.reason === 'symlink-escapes-workspace'
597
+ )));
598
+ fs.unlinkSync(folder);
599
+ recoverAllTreePlans();
600
+ assert.equal(
601
+ fs.readFileSync(path.join(heldFolder, 'source.txt'), 'utf8'),
602
+ 'source bytes\n',
603
+ );
604
+ assert.equal(nodeForPath(path.join(heldFolder, 'source.txt')).node.id, sourceId);
605
+ assert.equal(nodeForPath(heldFolder).node.id, folderId);
606
+ });
607
+
608
+ test('a both-retained source race is captured with every value visible', () => {
609
+ const root = path.join(process.env.AMALGM_WORKSPACES_DIR, 'source-both-retained');
610
+ const source = path.join(root, 'source.txt');
611
+ const movedOriginal = path.join(root, 'moved-original.txt');
612
+ fs.mkdirSync(root);
613
+ fs.writeFileSync(source, 'original source\n');
614
+ const workspace = upsertWorkspace({
615
+ path: root,
616
+ name: 'Source both retained',
617
+ source: 'computer',
618
+ });
619
+ enrollWorkspace(workspace.id);
620
+ const sourceId = nodeForPath(source).node.id;
621
+ let child;
622
+ commitTreeOperation({
623
+ kind: 'tree.rename',
624
+ workspaceId: workspace.id,
625
+ nodeId: sourceId,
626
+ name: 'official-name.txt',
627
+ }, {
628
+ role: 'owner',
629
+ testHooks: {
630
+ afterOperationPlan() {
631
+ child = spawn(process.execPath, [
632
+ '-e',
633
+ 'const fs=require("fs");'
634
+ + `setTimeout(()=>{`
635
+ + `fs.renameSync(${JSON.stringify(source)},${JSON.stringify(movedOriginal)});`
636
+ + `fs.writeFileSync(${JSON.stringify(source)},"first replacement\\n");`
637
+ + '},250);'
638
+ + `setTimeout(()=>{`
639
+ + `fs.writeFileSync(${JSON.stringify(source)},"second replacement\\n");`
640
+ + '},900);',
641
+ ], { stdio: 'ignore' });
642
+ },
643
+ operationStageRename: {
644
+ pauseBeforeInstall: 600,
645
+ pauseAfterTreeMove: 600,
646
+ },
647
+ },
648
+ });
649
+ child?.unref();
650
+ assert.equal(fs.readFileSync(movedOriginal, 'utf8'), 'original source\n');
651
+ assert.equal(fs.readFileSync(source, 'utf8'), 'second replacement\n');
652
+ const retained = fs.readdirSync(root)
653
+ .find((name) => name.startsWith('source.amalgm-retained-'));
654
+ assert.ok(retained);
655
+ assert.equal(fs.readFileSync(path.join(root, retained), 'utf8'), 'first replacement\n');
656
+ assert.equal(nodeForPath(movedOriginal).node.id, sourceId);
657
+ assert.equal(openLocalDb().prepare(`
658
+ SELECT COUNT(*) AS count
659
+ FROM workspace_tree_operation_materializations
660
+ WHERE resource_id = ?
661
+ `).get(workspace.id).count, 0);
662
+ });
663
+
664
+ test('a killed native stage resumes from its durable pre-rename receipt', () => {
665
+ const root = path.join(process.env.AMALGM_WORKSPACES_DIR, 'killed-tree-stage');
666
+ const source = path.join(root, 'source.txt');
667
+ const target = path.join(root, 'target.txt');
668
+ fs.mkdirSync(root);
669
+ fs.writeFileSync(source, 'survives kill\n');
670
+ const workspace = upsertWorkspace({
671
+ path: root,
672
+ name: 'Killed tree stage',
673
+ source: 'computer',
674
+ });
675
+ enrollWorkspace(workspace.id);
676
+ const sourceId = nodeForPath(source).node.id;
677
+ assert.throws(
678
+ () => commitTreeOperation({
679
+ kind: 'tree.rename',
680
+ workspaceId: workspace.id,
681
+ nodeId: sourceId,
682
+ name: 'target.txt',
683
+ }, {
684
+ role: 'owner',
685
+ testHooks: {
686
+ operationStageRename: { crashAfterTreeMove: true },
687
+ },
688
+ }),
689
+ /native file transaction failed/,
690
+ );
691
+ recoverAllTreePlans();
692
+ assert.equal(fs.readFileSync(target, 'utf8'), 'survives kill\n');
693
+ assert.equal(nodeForPath(target).node.id, sourceId);
694
+ });
695
+
696
+ test('a deleted node never lends its UUID to a recreated path with a recycled inode', () => {
222
697
  const root = path.join(process.env.AMALGM_WORKSPACES_DIR, 'inode-reuse');
223
698
  const target = path.join(root, 'note.md');
224
699
  fs.mkdirSync(root);
@@ -229,9 +704,9 @@ test('a missing node never lends its UUID to a recreated path with a recycled in
229
704
 
230
705
  fs.unlinkSync(target);
231
706
  enrollWorkspace(workspace.id);
232
- const missing = listWorkspaceNodes(workspace.id, { includeMissing: true })
707
+ const deleted = listWorkspaceNodes(workspace.id, { includeTrashed: true })
233
708
  .find((node) => node.id === original.id);
234
- assert.ok(missing.missingAt);
709
+ assert.ok(deleted.trashedAt);
235
710
 
236
711
  fs.writeFileSync(target, 'replacement identity\n');
237
712
  const replacementStats = fs.lstatSync(target);
@@ -244,11 +719,11 @@ test('a missing node never lends its UUID to a recreated path with a recycled in
244
719
  const replacement = nodeForPath(target).node;
245
720
  assert.notEqual(replacement.id, original.id);
246
721
  assert.equal(
247
- listWorkspaceNodes(workspace.id, { includeMissing: true }).some(
248
- (node) => node.id === original.id && node.missingAt,
722
+ listWorkspaceNodes(workspace.id, { includeTrashed: true }).some(
723
+ (node) => node.id === original.id && node.trashedAt,
249
724
  ),
250
725
  true,
251
- 'the old identity remains tombstoned',
726
+ 'the old identity remains durably trashed',
252
727
  );
253
728
  });
254
729
 
@@ -276,7 +751,7 @@ test('tree lookup enrolls an existing path on demand before sharing', () => {
276
751
  assert.equal(response.body.node.id, firstId, 'repeated sharing preserves identity');
277
752
  });
278
753
 
279
- test('moving a node requires write access at both source and destination', () => {
754
+ test('moving or restoring a node requires write access at both source and destination', () => {
280
755
  const root = path.join(process.env.AMALGM_WORKSPACES_DIR, 'move-authority');
281
756
  const sourcePath = path.join(root, 'source');
282
757
  const destinationPath = path.join(root, 'destination');
@@ -309,25 +784,76 @@ test('moving a node requires write access at both source and destination', () =>
309
784
  workspaceId: workspace.id,
310
785
  nodeId: file.id,
311
786
  parentId: destination.id,
787
+ }, {
788
+ role: 'editor',
312
789
  }), /tree destination is forbidden/);
313
790
  assert.equal(nodeForPath(filePath).node.id, file.id);
314
791
 
792
+ commitTreeOperation({
793
+ kind: 'tree.trash',
794
+ workspaceId: workspace.id,
795
+ nodeId: file.id,
796
+ });
797
+ assert.throws(() => commitTreeOperation({
798
+ kind: 'tree.restore',
799
+ workspaceId: workspace.id,
800
+ nodeId: file.id,
801
+ parentId: destination.id,
802
+ }, {
803
+ role: 'editor',
804
+ }), /tree destination is forbidden/);
805
+
315
806
  database.prepare(`
316
807
  UPDATE workspace_node_permissions SET role = 'editor'
317
808
  WHERE node_id = ? AND user_id = ?
318
809
  `).run(destination.id, memberId);
319
810
  commitTreeOperation({
320
- kind: 'tree.move',
811
+ kind: 'tree.restore',
321
812
  workspaceId: workspace.id,
322
813
  nodeId: file.id,
323
814
  parentId: destination.id,
324
- });
815
+ }, { role: 'editor' });
325
816
  assert.equal(nodeForPath(path.join(destinationPath, 'note.md')).node.id, file.id);
326
817
  } finally {
327
818
  identity.userId = currentUserId;
328
819
  }
329
820
  });
330
821
 
822
+ test('repeat enrollment journals an offline deletion as one durable trash birth', () => {
823
+ const root = path.join(process.env.AMALGM_WORKSPACES_DIR, 'offline-deletion');
824
+ const target = path.join(root, 'gone.txt');
825
+ fs.mkdirSync(root);
826
+ fs.writeFileSync(target, 'delete while stopped\n');
827
+ const workspace = upsertWorkspace({
828
+ path: root,
829
+ name: 'Offline deletion',
830
+ source: 'computer',
831
+ });
832
+ enrollWorkspace(workspace.id);
833
+ const nodeId = nodeForPath(target).node.id;
834
+
835
+ fs.unlinkSync(target);
836
+ const reconciled = enrollWorkspace(workspace.id);
837
+ assert.equal(reconciled.operations.length, 1);
838
+ assert.equal(reconciled.operations[0].kind, 'tree.trash');
839
+ const tombstone = listWorkspaceNodes(workspace.id, {
840
+ includeMissing: true,
841
+ includeTrashed: true,
842
+ }).find((node) => node.id === nodeId);
843
+ assert.equal(tombstone.missingAt, null);
844
+ assert.ok(tombstone.trashedAt);
845
+ assert.equal(openLocalDb().prepare(`
846
+ SELECT COUNT(*) AS count
847
+ FROM mutation_journal
848
+ WHERE local_resource = ?
849
+ AND mutation_origin = 'local'
850
+ AND operation_kind = 'tree.trash'
851
+ AND json_extract(operation_json, '$.nodeId') = ?
852
+ `).get(`workspace-tree:${workspace.id}`, nodeId).count, 1);
853
+
854
+ assert.deepEqual(reconcileWorkspaceTree(workspace.id).operations, []);
855
+ });
856
+
331
857
  test('recursive create enrolls missing parents without overwriting existing disk entries', () => {
332
858
  const root = path.join(process.env.AMALGM_WORKSPACES_DIR, 'recursive');
333
859
  fs.mkdirSync(root);
@@ -348,6 +874,350 @@ test('recursive create enrolls missing parents without overwriting existing disk
348
874
  assert.ok(nodeForPath(existing)?.node.id);
349
875
  });
350
876
 
877
+ test('purge retains a pre-existing writer as a new durable node', () => {
878
+ const root = path.join(process.env.AMALGM_WORKSPACES_DIR, 'purge-open-writer');
879
+ const file = path.join(root, 'draft.txt');
880
+ fs.mkdirSync(root);
881
+ fs.writeFileSync(file, 'still being written\n');
882
+ const workspace = upsertWorkspace({
883
+ path: root,
884
+ name: 'Purge open writer',
885
+ source: 'computer',
886
+ });
887
+ enrollWorkspace(workspace.id);
888
+ const originalId = nodeForPath(file).node.id;
889
+ const writer = fs.openSync(file, 'r+');
890
+ try {
891
+ commitTreeOperation({
892
+ kind: 'tree.trash',
893
+ workspaceId: workspace.id,
894
+ nodeId: originalId,
895
+ }, { role: 'owner' });
896
+ commitTreeOperation({
897
+ kind: 'tree.purge',
898
+ workspaceId: workspace.id,
899
+ nodeId: originalId,
900
+ }, { role: 'owner' });
901
+ const successor = listWorkspaceNodes(workspace.id)
902
+ .find((node) => node.id !== workspace.id && node.id !== originalId);
903
+ const successorPath = path.join(
904
+ root,
905
+ ...successor.relativePath.split('/'),
906
+ );
907
+ assert.match(successor.name, /^draft\.amalgm-retained-[0-9a-f]{12}\.txt$/);
908
+ assert.equal(fs.readFileSync(successorPath, 'utf8'), 'still being written\n');
909
+ assert.equal(isUuidV7(successor.id), true);
910
+ assert.notEqual(successor.id, originalId);
911
+ const purged = listWorkspaceNodes(workspace.id, {
912
+ includeTrashed: true,
913
+ includePurged: true,
914
+ }).find((node) => node.id === originalId);
915
+ assert.ok(purged.purgedAt);
916
+ const successorMutation = openLocalDb().prepare(`
917
+ SELECT mutation_state
918
+ FROM mutation_journal
919
+ WHERE local_resource = ?
920
+ AND mutation_origin = 'local'
921
+ AND operation_kind = 'tree.create'
922
+ ORDER BY journal_id DESC
923
+ LIMIT 1
924
+ `).get(`workspace-tree:${workspace.id}`);
925
+ assert.equal(successorMutation.mutation_state, 'saved-local');
926
+ } finally {
927
+ fs.closeSync(writer);
928
+ }
929
+ });
930
+
931
+ test('equal trash timestamps never merge independent physical components', () => {
932
+ const root = path.join(process.env.AMALGM_WORKSPACES_DIR, 'equal-trash-time');
933
+ const folderPath = path.join(root, 'folder');
934
+ const childPath = path.join(folderPath, 'child.txt');
935
+ fs.mkdirSync(folderPath, { recursive: true });
936
+ fs.writeFileSync(childPath, 'independent bytes\n');
937
+ const workspace = upsertWorkspace({
938
+ path: root,
939
+ name: 'Equal trash timestamp',
940
+ source: 'computer',
941
+ });
942
+ enrollWorkspace(workspace.id);
943
+ const folderId = nodeForPath(folderPath).node.id;
944
+ const childId = nodeForPath(childPath).node.id;
945
+ const changedAt = '2026-07-21T12:01:00.000Z';
946
+ const childTrashPath = trashPath(workspace.id, childId, workspace.path);
947
+ const folderTrashPath = trashPath(workspace.id, folderId, workspace.path);
948
+
949
+ commitTreeOperation({
950
+ kind: 'tree.trash',
951
+ workspaceId: workspace.id,
952
+ nodeId: childId,
953
+ trashedAt: changedAt,
954
+ });
955
+ commitTreeOperation({
956
+ kind: 'tree.trash',
957
+ workspaceId: workspace.id,
958
+ nodeId: folderId,
959
+ trashedAt: changedAt,
960
+ });
961
+ const tombstones = listWorkspaceNodes(workspace.id, { includeTrashed: true });
962
+ assert.equal(tombstones.find((node) => node.id === childId).trashComponentId, childId);
963
+ assert.equal(tombstones.find((node) => node.id === folderId).trashComponentId, folderId);
964
+
965
+ commitTreeOperation({
966
+ kind: 'tree.purge',
967
+ workspaceId: workspace.id,
968
+ nodeId: folderId,
969
+ }, { role: 'owner' });
970
+ assert.equal(fs.existsSync(folderTrashPath), false);
971
+ assert.equal(fs.existsSync(childTrashPath), false);
972
+ assert.deepEqual(reconcileWorkspaceTree(workspace.id).operations, []);
973
+ });
974
+
975
+ test('purging a writer-held cascaded child gives it a live recovery UUID', () => {
976
+ const root = path.join(process.env.AMALGM_WORKSPACES_DIR, 'cascaded-writer-purge');
977
+ const folderPath = path.join(root, 'folder');
978
+ const childPath = path.join(folderPath, 'child.txt');
979
+ fs.mkdirSync(folderPath, { recursive: true });
980
+ fs.writeFileSync(childPath, 'writer survives\n');
981
+ const workspace = upsertWorkspace({
982
+ path: root,
983
+ name: 'Cascaded writer purge',
984
+ source: 'computer',
985
+ });
986
+ enrollWorkspace(workspace.id);
987
+ const folderId = nodeForPath(folderPath).node.id;
988
+ const childId = nodeForPath(childPath).node.id;
989
+ const writer = fs.openSync(childPath, 'r+');
990
+ try {
991
+ commitTreeOperation({
992
+ kind: 'tree.trash',
993
+ workspaceId: workspace.id,
994
+ nodeId: folderId,
995
+ });
996
+ commitTreeOperation({
997
+ kind: 'tree.purge',
998
+ workspaceId: workspace.id,
999
+ nodeId: childId,
1000
+ }, { role: 'owner' });
1001
+
1002
+ const successor = listWorkspaceNodes(workspace.id)
1003
+ .find((node) => node.id !== workspace.id && node.id !== folderId);
1004
+ assert.ok(successor);
1005
+ assert.notEqual(successor.id, childId);
1006
+ assert.equal(successor.parentId, workspace.id);
1007
+ assert.match(successor.name, /^child\.amalgm-retained-[0-9a-f]{12}\.txt$/);
1008
+ assert.equal(
1009
+ fs.readFileSync(path.join(root, successor.name), 'utf8'),
1010
+ 'writer survives\n',
1011
+ );
1012
+ const oldChild = listWorkspaceNodes(workspace.id, {
1013
+ includeTrashed: true,
1014
+ includePurged: true,
1015
+ }).find((node) => node.id === childId);
1016
+ assert.ok(oldChild.purgedAt);
1017
+ assert.equal(
1018
+ openLocalDb().prepare(
1019
+ 'SELECT count(*) AS count FROM workspace_tree_operation_materializations',
1020
+ ).get().count,
1021
+ 0,
1022
+ );
1023
+ } finally {
1024
+ fs.closeSync(writer);
1025
+ }
1026
+ });
1027
+
1028
+ test('an occupied purge recovery name fails closed until both byte sets have durable births', () => {
1029
+ const root = path.join(process.env.AMALGM_WORKSPACES_DIR, 'purge-recovery-collision');
1030
+ const file = path.join(root, 'draft.txt');
1031
+ fs.mkdirSync(root);
1032
+ fs.writeFileSync(file, 'writer bytes\n');
1033
+ const workspace = upsertWorkspace({
1034
+ path: root,
1035
+ name: 'Purge recovery collision',
1036
+ source: 'computer',
1037
+ });
1038
+ enrollWorkspace(workspace.id);
1039
+ const originalId = nodeForPath(file).node.id;
1040
+ const writer = fs.openSync(file, 'r+');
1041
+ let recoveryTarget = null;
1042
+ try {
1043
+ commitTreeOperation({
1044
+ kind: 'tree.trash',
1045
+ workspaceId: workspace.id,
1046
+ nodeId: originalId,
1047
+ });
1048
+ assert.throws(() => commitTreeOperation({
1049
+ kind: 'tree.purge',
1050
+ workspaceId: workspace.id,
1051
+ nodeId: originalId,
1052
+ }, {
1053
+ role: 'owner',
1054
+ testHooks: {
1055
+ afterOperationPlan(plan) {
1056
+ recoveryTarget = plan.actions.find((action) => action.type === 'remove').retainTarget;
1057
+ fs.writeFileSync(recoveryTarget, 'independent collision\n');
1058
+ },
1059
+ },
1060
+ }), (error) => (
1061
+ error?.code === 'WORKSPACE_TREE_RETRY_PENDING'
1062
+ && error?.reason === 'retained-target-occupied'
1063
+ ));
1064
+
1065
+ assert.equal(fs.readFileSync(recoveryTarget, 'utf8'), 'independent collision\n');
1066
+ assert.ok(nodeForPath(recoveryTarget), 'the racing target receives its own birth');
1067
+ assert.equal(openLocalDb().prepare(`
1068
+ SELECT count(*) AS count
1069
+ FROM workspace_tree_operation_materializations
1070
+ WHERE resource_id = ? AND materialization_state = 'committed'
1071
+ `).get(workspace.id).count, 1);
1072
+
1073
+ fs.unlinkSync(recoveryTarget);
1074
+ recoverAllTreePlans({ database: openLocalDb() });
1075
+ const visible = fs.readdirSync(root).map((name) => path.join(root, name));
1076
+ const retainedPath = visible.find(
1077
+ (candidate) => fs.readFileSync(candidate, 'utf8') === 'writer bytes\n',
1078
+ );
1079
+ assert.ok(retainedPath);
1080
+ const retainedNode = nodeForPath(retainedPath).node;
1081
+ assert.notEqual(retainedNode.id, originalId);
1082
+ assert.equal(openLocalDb().prepare(`
1083
+ SELECT count(*) AS count
1084
+ FROM workspace_tree_operation_materializations
1085
+ WHERE resource_id = ?
1086
+ `).get(workspace.id).count, 0);
1087
+ } finally {
1088
+ fs.closeSync(writer);
1089
+ }
1090
+ });
1091
+
1092
+ test('purge recovery follows a live parent UUID across restart rename', () => {
1093
+ const root = path.join(process.env.AMALGM_WORKSPACES_DIR, 'purge-recovery-parent');
1094
+ const parent = path.join(root, 'parent');
1095
+ const movedParent = path.join(root, 'moved-parent');
1096
+ const file = path.join(parent, 'draft.txt');
1097
+ fs.mkdirSync(parent, { recursive: true });
1098
+ fs.writeFileSync(file, 'writer follows parent\n');
1099
+ const workspace = upsertWorkspace({
1100
+ path: root,
1101
+ name: 'Purge recovery parent',
1102
+ source: 'computer',
1103
+ });
1104
+ enrollWorkspace(workspace.id);
1105
+ const parentId = nodeForPath(parent).node.id;
1106
+ const originalId = nodeForPath(file).node.id;
1107
+ const writer = fs.openSync(file, 'r+');
1108
+ try {
1109
+ commitTreeOperation({
1110
+ kind: 'tree.trash',
1111
+ workspaceId: workspace.id,
1112
+ nodeId: originalId,
1113
+ });
1114
+ assert.throws(() => commitTreeOperation({
1115
+ kind: 'tree.purge',
1116
+ workspaceId: workspace.id,
1117
+ nodeId: originalId,
1118
+ }, {
1119
+ role: 'owner',
1120
+ testHooks: { crashAfterOperationCommit: true },
1121
+ }), /simulated workspace tree crash after local operation commit/);
1122
+
1123
+ fs.renameSync(parent, movedParent);
1124
+ fs.mkdirSync(parent);
1125
+ fs.writeFileSync(path.join(parent, 'replacement.txt'), 'replacement parent\n');
1126
+ recoverAllTreePlans({ database: openLocalDb() });
1127
+
1128
+ assert.equal(nodeForPath(movedParent).node.id, parentId);
1129
+ assert.notEqual(nodeForPath(parent).node.id, parentId);
1130
+ const successor = listWorkspaceNodes(workspace.id)
1131
+ .find((node) => (
1132
+ node.parentId === parentId
1133
+ && /^draft\.amalgm-retained-[0-9a-f]{12}\.txt$/.test(node.name)
1134
+ ));
1135
+ assert.ok(successor);
1136
+ assert.equal(successor.parentId, parentId);
1137
+ assert.equal(
1138
+ fs.readFileSync(path.join(movedParent, successor.name), 'utf8'),
1139
+ 'writer follows parent\n',
1140
+ );
1141
+ assert.equal(openLocalDb().prepare(`
1142
+ SELECT count(*) AS count
1143
+ FROM workspace_tree_operation_materializations
1144
+ WHERE resource_id = ?
1145
+ `).get(workspace.id).count, 0);
1146
+ } finally {
1147
+ fs.closeSync(writer);
1148
+ }
1149
+ });
1150
+
1151
+ test('legacy trash migration waits for physical ownership and never guesses from time', () => {
1152
+ const root = path.join(process.env.AMALGM_WORKSPACES_DIR, 'legacy-trash-provenance');
1153
+ const folderPath = path.join(root, 'folder');
1154
+ const childPath = path.join(folderPath, 'child.txt');
1155
+ fs.mkdirSync(folderPath, { recursive: true });
1156
+ fs.writeFileSync(childPath, 'legacy bytes\n');
1157
+ const workspace = upsertWorkspace({
1158
+ path: root,
1159
+ name: 'Legacy trash provenance',
1160
+ source: 'computer',
1161
+ });
1162
+ enrollWorkspace(workspace.id);
1163
+ const folderId = nodeForPath(folderPath).node.id;
1164
+ const childId = nodeForPath(childPath).node.id;
1165
+ const changedAt = '2026-07-21T12:01:00.000Z';
1166
+ commitTreeOperation({
1167
+ kind: 'tree.trash',
1168
+ workspaceId: workspace.id,
1169
+ nodeId: childId,
1170
+ trashedAt: changedAt,
1171
+ });
1172
+ commitTreeOperation({
1173
+ kind: 'tree.trash',
1174
+ workspaceId: workspace.id,
1175
+ nodeId: folderId,
1176
+ trashedAt: changedAt,
1177
+ });
1178
+
1179
+ const childTrashPath = trashPath(workspace.id, childId, workspace.path);
1180
+ const trashRoot = path.dirname(childTrashPath);
1181
+ const unavailableTrashRoot = `${trashRoot}-unavailable`;
1182
+ fs.renameSync(trashRoot, unavailableTrashRoot);
1183
+ openLocalDb().prepare(`
1184
+ UPDATE workspace_nodes
1185
+ SET trash_component_id = NULL
1186
+ WHERE id IN (?, ?)
1187
+ `).run(folderId, childId);
1188
+ backfillWorkspaceTrashComponents(openLocalDb());
1189
+ let rows = openLocalDb().prepare(`
1190
+ SELECT id, trash_component_id
1191
+ FROM workspace_nodes
1192
+ WHERE id IN (?, ?)
1193
+ ORDER BY id
1194
+ `).all(folderId, childId);
1195
+ assert.equal(rows.every((row) => row.trash_component_id === null), true);
1196
+
1197
+ fs.renameSync(unavailableTrashRoot, trashRoot);
1198
+ backfillWorkspaceTrashComponents(openLocalDb());
1199
+ rows = openLocalDb().prepare(`
1200
+ SELECT id, trash_component_id
1201
+ FROM workspace_nodes
1202
+ WHERE id IN (?, ?)
1203
+ `).all(folderId, childId);
1204
+ assert.equal(rows.find((row) => row.id === folderId).trash_component_id, folderId);
1205
+ assert.equal(rows.find((row) => row.id === childId).trash_component_id, childId);
1206
+
1207
+ openLocalDb().prepare(`
1208
+ UPDATE workspace_nodes
1209
+ SET purged_at = ?, trash_component_id = ?
1210
+ WHERE id = ?
1211
+ `).run(changedAt, childId, childId);
1212
+ backfillWorkspaceTrashComponents(openLocalDb());
1213
+ assert.equal(
1214
+ openLocalDb().prepare(
1215
+ 'SELECT trash_component_id FROM workspace_nodes WHERE id = ?',
1216
+ ).get(childId).trash_component_id,
1217
+ null,
1218
+ );
1219
+ });
1220
+
351
1221
  test('parent restore leaves an independently trashed child in its own trash slot', () => {
352
1222
  const root = path.join(process.env.AMALGM_WORKSPACES_DIR, 'trash-cascades');
353
1223
  const folderPath = path.join(root, 'folder');
@@ -358,8 +1228,8 @@ test('parent restore leaves an independently trashed child in its own trash slot
358
1228
  enrollWorkspace(workspace.id);
359
1229
  const folderId = nodeForPath(folderPath).node.id;
360
1230
  const childId = nodeForPath(childPath).node.id;
361
- const childTrashPath = path.join(process.env.AMALGM_DIR, 'trash', 'workspaces', workspace.id, childId);
362
- const folderTrashPath = path.join(process.env.AMALGM_DIR, 'trash', 'workspaces', workspace.id, folderId);
1231
+ const childTrashPath = trashPath(workspace.id, childId, workspace.path);
1232
+ const folderTrashPath = trashPath(workspace.id, folderId, workspace.path);
363
1233
 
364
1234
  commitTreeOperation({
365
1235
  kind: 'tree.trash',
@@ -368,6 +1238,11 @@ test('parent restore leaves an independently trashed child in its own trash slot
368
1238
  trashedAt: '2026-07-21T12:01:00.000Z',
369
1239
  });
370
1240
  assert.equal(fs.readFileSync(childTrashPath, 'utf8'), 'child bytes\n');
1241
+ assert.equal(
1242
+ fs.lstatSync(path.dirname(root), { bigint: true }).dev,
1243
+ fs.lstatSync(path.dirname(childTrashPath), { bigint: true }).dev,
1244
+ );
1245
+ assert.equal(childTrashPath.startsWith(`${process.env.AMALGM_DIR}${path.sep}`), false);
371
1246
  commitTreeOperation({
372
1247
  kind: 'tree.trash',
373
1248
  workspaceId: workspace.id,
@@ -386,3 +1261,2251 @@ test('parent restore leaves an independently trashed child in its own trash slot
386
1261
  assert.equal(fs.existsSync(folderTrashPath), false);
387
1262
  assert.equal(fs.existsSync(childTrashPath), false);
388
1263
  });
1264
+
1265
+ test('purging a cascaded child removes it from the parent trash carrier permanently', () => {
1266
+ const root = path.join(process.env.AMALGM_WORKSPACES_DIR, 'cascaded-child-purge');
1267
+ const folderPath = path.join(root, 'folder');
1268
+ const childPath = path.join(folderPath, 'child.txt');
1269
+ fs.mkdirSync(folderPath, { recursive: true });
1270
+ fs.writeFileSync(childPath, 'must stay deleted\n');
1271
+ const workspace = upsertWorkspace({
1272
+ path: root,
1273
+ name: 'Cascaded child purge',
1274
+ source: 'computer',
1275
+ });
1276
+ enrollWorkspace(workspace.id);
1277
+ const folderId = nodeForPath(folderPath).node.id;
1278
+ const childId = nodeForPath(childPath).node.id;
1279
+
1280
+ commitTreeOperation({
1281
+ kind: 'tree.trash',
1282
+ workspaceId: workspace.id,
1283
+ nodeId: folderId,
1284
+ });
1285
+ commitTreeOperation({
1286
+ kind: 'tree.purge',
1287
+ workspaceId: workspace.id,
1288
+ nodeId: childId,
1289
+ }, { role: 'owner' });
1290
+ commitTreeOperation({
1291
+ kind: 'tree.restore',
1292
+ workspaceId: workspace.id,
1293
+ nodeId: folderId,
1294
+ });
1295
+
1296
+ assert.equal(fs.existsSync(folderPath), true);
1297
+ assert.equal(fs.existsSync(childPath), false);
1298
+ const child = listWorkspaceNodes(workspace.id, {
1299
+ includeTrashed: true,
1300
+ includePurged: true,
1301
+ }).find((node) => node.id === childId);
1302
+ assert.ok(child.purgedAt);
1303
+ assert.deepEqual(reconcileWorkspaceTree(workspace.id).operations, []);
1304
+ });
1305
+
1306
+ test('renaming a parent ignores duplicate logical paths held only by tombstones', () => {
1307
+ const root = path.join(process.env.AMALGM_WORKSPACES_DIR, 'tombstone-paths');
1308
+ const folderPath = path.join(root, 'before');
1309
+ const firstPath = path.join(folderPath, 'same.txt');
1310
+ fs.mkdirSync(folderPath, { recursive: true });
1311
+ fs.writeFileSync(firstPath, 'first\n');
1312
+ const workspace = upsertWorkspace({
1313
+ path: root,
1314
+ name: 'Tombstone paths',
1315
+ source: 'computer',
1316
+ });
1317
+ enrollWorkspace(workspace.id);
1318
+ const folderId = nodeForPath(folderPath).node.id;
1319
+ const firstId = nodeForPath(firstPath).node.id;
1320
+ commitTreeOperation({
1321
+ kind: 'tree.trash',
1322
+ workspaceId: workspace.id,
1323
+ nodeId: firstId,
1324
+ });
1325
+
1326
+ fs.writeFileSync(firstPath, 'second\n');
1327
+ commitPathCreate(firstPath, 'file');
1328
+ const secondId = nodeForPath(firstPath).node.id;
1329
+ assert.notEqual(secondId, firstId);
1330
+ commitTreeOperation({
1331
+ kind: 'tree.trash',
1332
+ workspaceId: workspace.id,
1333
+ nodeId: secondId,
1334
+ });
1335
+
1336
+ commitTreeOperation({
1337
+ kind: 'tree.rename',
1338
+ workspaceId: workspace.id,
1339
+ nodeId: folderId,
1340
+ name: 'after',
1341
+ });
1342
+ assert.equal(fs.existsSync(path.join(root, 'after')), true);
1343
+ const tombstones = listWorkspaceNodes(workspace.id, {
1344
+ includeTrashed: true,
1345
+ }).filter((node) => node.name === 'same.txt');
1346
+ assert.equal(tombstones.length, 2);
1347
+ assert.equal(new Set(tombstones.map((node) => node.relativePath)).size, 1);
1348
+ });
1349
+
1350
+ test('a parent rename after source staging is captured and causally replanned', () => {
1351
+ const root = path.join(process.env.AMALGM_WORKSPACES_DIR, 'staged-parent-rename');
1352
+ const folder = path.join(root, 'A');
1353
+ const movedFolder = path.join(root, 'C');
1354
+ const source = path.join(folder, 'x.txt');
1355
+ fs.mkdirSync(folder, { recursive: true });
1356
+ fs.writeFileSync(source, 'staged parent bytes\n');
1357
+ const workspace = upsertWorkspace({
1358
+ path: root,
1359
+ name: 'Staged parent rename',
1360
+ source: 'computer',
1361
+ });
1362
+ enrollWorkspace(workspace.id);
1363
+ const fileId = nodeForPath(source).node.id;
1364
+ let raced = false;
1365
+ commitTreeOperation({
1366
+ kind: 'tree.rename',
1367
+ workspaceId: workspace.id,
1368
+ nodeId: fileId,
1369
+ name: 'y.txt',
1370
+ }, {
1371
+ role: 'owner',
1372
+ testHooks: {
1373
+ afterOperationStaging() {
1374
+ if (raced) return;
1375
+ raced = true;
1376
+ fs.renameSync(folder, movedFolder);
1377
+ },
1378
+ },
1379
+ });
1380
+ const finalPath = path.join(movedFolder, 'y.txt');
1381
+ assert.equal(fs.readFileSync(finalPath, 'utf8'), 'staged parent bytes\n');
1382
+ assert.equal(nodeForPath(finalPath).node.id, fileId);
1383
+ assert.deepEqual(reconcileWorkspaceTree(workspace.id).operations, []);
1384
+ assert.equal(openLocalDb().prepare(`
1385
+ SELECT COUNT(*) AS count
1386
+ FROM workspace_tree_operation_materializations
1387
+ WHERE resource_id = ?
1388
+ `).get(workspace.id).count, 0);
1389
+ });
1390
+
1391
+ test('cancellation births a source occupant before its restore sidecar plan retires', () => {
1392
+ const root = path.join(
1393
+ process.env.AMALGM_WORKSPACES_DIR,
1394
+ 'cancelled-source-occupant',
1395
+ );
1396
+ const folder = path.join(root, 'A');
1397
+ const movedFolder = path.join(root, 'C');
1398
+ const source = path.join(folder, 'source.txt');
1399
+ const movedSource = path.join(movedFolder, 'source.txt');
1400
+ fs.mkdirSync(folder, { recursive: true });
1401
+ fs.writeFileSync(source, 'official source bytes\n');
1402
+ const workspace = upsertWorkspace({
1403
+ path: root,
1404
+ name: 'Cancelled source occupant',
1405
+ source: 'computer',
1406
+ });
1407
+ enrollWorkspace(workspace.id);
1408
+ const sourceId = nodeForPath(source).node.id;
1409
+ assert.throws(
1410
+ () => commitTreeOperation({
1411
+ kind: 'tree.rename',
1412
+ workspaceId: workspace.id,
1413
+ nodeId: sourceId,
1414
+ name: 'official-name.txt',
1415
+ }, {
1416
+ role: 'owner',
1417
+ testHooks: {
1418
+ afterOperationStaging() {
1419
+ fs.renameSync(folder, movedFolder);
1420
+ fs.writeFileSync(movedSource, 'independent source occupant\n');
1421
+ },
1422
+ afterOperationCancellationOutputReceipt(outputs) {
1423
+ assert.ok(outputs.some((output) => (
1424
+ output.provenance === 'independent-occupant'
1425
+ && output.path.includes('.amalgm-retained-')
1426
+ )));
1427
+ throw new Error('simulated crash after source occupant receipt');
1428
+ },
1429
+ },
1430
+ }),
1431
+ /simulated crash after source occupant receipt/,
1432
+ );
1433
+ const held = openLocalDb().prepare(`
1434
+ SELECT plan_json AS planJson
1435
+ FROM workspace_tree_operation_materializations
1436
+ WHERE resource_id = ?
1437
+ `).get(workspace.id);
1438
+ assert.ok(JSON.parse(held.planJson).retainedOutputs.some((output) => (
1439
+ output.provenance === 'independent-occupant'
1440
+ )));
1441
+
1442
+ recoverAllTreePlans();
1443
+
1444
+ const official = path.join(movedFolder, 'official-name.txt');
1445
+ assert.equal(fs.readFileSync(official, 'utf8'), 'official source bytes\n');
1446
+ assert.equal(nodeForPath(official).node.id, sourceId);
1447
+ const sidecar = fs.readdirSync(movedFolder).find((name) => (
1448
+ name.startsWith('source.amalgm-retained-')
1449
+ && name.endsWith('.txt')
1450
+ ));
1451
+ assert.ok(sidecar);
1452
+ const sidecarPath = path.join(movedFolder, sidecar);
1453
+ assert.equal(
1454
+ fs.readFileSync(sidecarPath, 'utf8'),
1455
+ 'independent source occupant\n',
1456
+ );
1457
+ assert.notEqual(nodeForPath(sidecarPath).node.id, sourceId);
1458
+ assert.equal(openLocalDb().prepare(`
1459
+ SELECT COUNT(*) AS count
1460
+ FROM workspace_tree_operation_materializations
1461
+ WHERE resource_id = ?
1462
+ `).get(workspace.id).count, 0);
1463
+ });
1464
+
1465
+ test('prepared retained hardlinks hold visibly until one physical name wins', () => {
1466
+ const root = path.join(
1467
+ process.env.AMALGM_WORKSPACES_DIR,
1468
+ 'cancelled-source-hardlink-hold',
1469
+ );
1470
+ const folder = path.join(root, 'A');
1471
+ const movedFolder = path.join(root, 'C');
1472
+ const source = path.join(folder, 'source.txt');
1473
+ const movedSource = path.join(movedFolder, 'source.txt');
1474
+ const alias = path.join(movedFolder, 'alias.txt');
1475
+ fs.mkdirSync(folder, { recursive: true });
1476
+ fs.writeFileSync(source, 'official source bytes\n');
1477
+ const workspace = upsertWorkspace({
1478
+ path: root,
1479
+ name: 'Cancelled source hardlink hold',
1480
+ source: 'computer',
1481
+ });
1482
+ enrollWorkspace(workspace.id);
1483
+ const sourceId = nodeForPath(source).node.id;
1484
+ let linked = false;
1485
+ assert.throws(
1486
+ () => commitTreeOperation({
1487
+ kind: 'tree.rename',
1488
+ workspaceId: workspace.id,
1489
+ nodeId: sourceId,
1490
+ name: 'official-name.txt',
1491
+ }, {
1492
+ role: 'owner',
1493
+ testHooks: {
1494
+ afterOperationStaging() {
1495
+ fs.renameSync(folder, movedFolder);
1496
+ fs.writeFileSync(movedSource, 'independent source occupant\n');
1497
+ },
1498
+ afterOperationCancellationOutputMove(outputs) {
1499
+ if (linked) return;
1500
+ linked = true;
1501
+ const retained = outputs.find(
1502
+ (output) => output.provenance === 'independent-occupant',
1503
+ );
1504
+ fs.linkSync(retained.path, alias);
1505
+ throw new Error('simulated crash after retained hardlink appeared');
1506
+ },
1507
+ },
1508
+ }),
1509
+ /simulated crash after retained hardlink appeared/,
1510
+ );
1511
+
1512
+ assert.throws(
1513
+ () => recoverAllTreePlans(),
1514
+ /ambiguous-physical-alias/,
1515
+ );
1516
+ const heldWorkspace = listWorkspaces().find(
1517
+ (candidate) => candidate.id === workspace.id,
1518
+ );
1519
+ assert.equal(heldWorkspace.treeObservation.state, 'held');
1520
+ assert.ok(heldWorkspace.treeObservation.holds.some((hold) => (
1521
+ hold.reason === 'ambiguous-physical-alias'
1522
+ && hold.paths.some((heldPath) => heldPath.endsWith('alias.txt'))
1523
+ )));
1524
+ assert.equal(openLocalDb().prepare(`
1525
+ SELECT COUNT(*) AS count
1526
+ FROM workspace_tree_operation_materializations
1527
+ WHERE resource_id = ?
1528
+ `).get(workspace.id).count, 1);
1529
+ assert.equal(nodeForPath(alias), null);
1530
+
1531
+ fs.unlinkSync(alias);
1532
+ recoverAllTreePlans();
1533
+
1534
+ const sidecar = fs.readdirSync(movedFolder).find((name) => (
1535
+ name.startsWith('source.amalgm-retained-')
1536
+ && name.endsWith('.txt')
1537
+ ));
1538
+ assert.ok(sidecar);
1539
+ assert.equal(
1540
+ fs.readFileSync(path.join(movedFolder, sidecar), 'utf8'),
1541
+ 'independent source occupant\n',
1542
+ );
1543
+ assert.equal(
1544
+ listWorkspaces().find((candidate) => candidate.id === workspace.id)
1545
+ .treeObservation,
1546
+ null,
1547
+ );
1548
+ assert.equal(openLocalDb().prepare(`
1549
+ SELECT COUNT(*) AS count
1550
+ FROM workspace_tree_operation_materializations
1551
+ WHERE resource_id = ?
1552
+ `).get(workspace.id).count, 0);
1553
+ });
1554
+
1555
+ test('prepared creates cancel cleanly when their parent moves', () => {
1556
+ for (const [index, kind] of ['file', 'folder', 'symlink'].entries()) {
1557
+ const root = path.join(
1558
+ process.env.AMALGM_WORKSPACES_DIR,
1559
+ `prepared-create-parent-${kind}`,
1560
+ );
1561
+ const folder = path.join(root, 'A');
1562
+ const movedFolder = path.join(root, 'C');
1563
+ fs.mkdirSync(folder, { recursive: true });
1564
+ const workspace = upsertWorkspace({
1565
+ path: root,
1566
+ name: `Prepared ${kind}`,
1567
+ source: 'computer',
1568
+ });
1569
+ enrollWorkspace(workspace.id);
1570
+ const parentId = nodeForPath(folder).node.id;
1571
+ const nodeId = uuidV7();
1572
+ let raced = false;
1573
+ commitTreeOperation({
1574
+ kind: 'tree.create',
1575
+ workspaceId: workspace.id,
1576
+ nodeId,
1577
+ parentId,
1578
+ nodeKind: kind,
1579
+ name: `${kind}-${index}`,
1580
+ ...(kind === 'symlink' ? { symlinkTarget: 'target.txt' } : {}),
1581
+ }, {
1582
+ role: 'owner',
1583
+ testHooks: {
1584
+ afterOperationCreatePrepared() {
1585
+ if (raced) return;
1586
+ raced = true;
1587
+ fs.renameSync(folder, movedFolder);
1588
+ },
1589
+ },
1590
+ });
1591
+ const finalPath = path.join(movedFolder, `${kind}-${index}`);
1592
+ assert.equal(fs.lstatSync(finalPath).isSymbolicLink(), kind === 'symlink');
1593
+ assert.equal(nodeForPath(finalPath).node.id, nodeId);
1594
+ assert.equal(openLocalDb().prepare(`
1595
+ SELECT COUNT(*) AS count
1596
+ FROM workspace_tree_operation_materializations
1597
+ WHERE resource_id = ?
1598
+ `).get(workspace.id).count, 0);
1599
+ }
1600
+ });
1601
+
1602
+ test('same-path folder replacement retires the old UUID and births a new one', () => {
1603
+ const root = path.join(process.env.AMALGM_WORKSPACES_DIR, 'folder-replacement');
1604
+ const folder = path.join(root, 'A');
1605
+ fs.mkdirSync(folder, { recursive: true });
1606
+ const workspace = upsertWorkspace({
1607
+ path: root,
1608
+ name: 'Folder replacement',
1609
+ source: 'computer',
1610
+ });
1611
+ enrollWorkspace(workspace.id);
1612
+ const oldId = nodeForPath(folder).node.id;
1613
+ fs.rmdirSync(folder);
1614
+ fs.mkdirSync(folder);
1615
+ const observed = reconcileWorkspaceTree(workspace.id);
1616
+ assert.deepEqual(
1617
+ observed.operations.map((operation) => operation.kind),
1618
+ ['tree.trash', 'tree.create'],
1619
+ );
1620
+ const replacement = nodeForPath(folder).node;
1621
+ assert.notEqual(replacement.id, oldId);
1622
+ const old = listWorkspaceNodes(workspace.id, {
1623
+ includeTrashed: true,
1624
+ }).find((node) => node.id === oldId);
1625
+ assert.ok(old.trashedAt);
1626
+ });
1627
+
1628
+ test('an atomic same-path file save refreshes its binding before a later rename', () => {
1629
+ const root = path.join(
1630
+ process.env.AMALGM_WORKSPACES_DIR,
1631
+ 'atomic-file-binding-refresh',
1632
+ );
1633
+ const originalPath = path.join(root, 'a.txt');
1634
+ const replacementPath = path.join(root, '.replacement.txt');
1635
+ const renamedPath = path.join(root, 'b.txt');
1636
+ fs.mkdirSync(root, { recursive: true });
1637
+ fs.writeFileSync(originalPath, 'original bytes\n');
1638
+ const workspace = upsertWorkspace({
1639
+ path: root,
1640
+ name: 'Atomic file binding refresh',
1641
+ source: 'computer',
1642
+ });
1643
+ enrollWorkspace(workspace.id);
1644
+ const original = nodeForPath(originalPath).node;
1645
+
1646
+ fs.writeFileSync(replacementPath, 'replacement bytes\n');
1647
+ fs.renameSync(replacementPath, originalPath);
1648
+ const replacementIdentity = fs.lstatSync(originalPath);
1649
+ const replacement = reconcileWorkspaceTree(workspace.id);
1650
+ assert.deepEqual(replacement.operations, []);
1651
+ const rebound = nodeForPath(originalPath).node;
1652
+ assert.equal(rebound.id, original.id);
1653
+ assert.equal(rebound.deviceId, String(replacementIdentity.dev));
1654
+ assert.equal(rebound.inode, String(replacementIdentity.ino));
1655
+
1656
+ fs.renameSync(originalPath, renamedPath);
1657
+ const renamed = reconcileWorkspaceTree(workspace.id);
1658
+ assert.deepEqual(
1659
+ renamed.operations.map((operation) => operation.kind),
1660
+ ['tree.rename'],
1661
+ );
1662
+ assert.equal(nodeForPath(renamedPath).node.id, original.id);
1663
+ assert.equal(fs.readFileSync(renamedPath, 'utf8'), 'replacement bytes\n');
1664
+ const old = listWorkspaceNodes(workspace.id, {
1665
+ includeTrashed: true,
1666
+ }).find((node) => node.id === original.id);
1667
+ assert.equal(old.trashedAt, null);
1668
+ });
1669
+
1670
+ test('enrollment holds hardlink aliases before two UUIDs can claim one inode', () => {
1671
+ const root = path.join(
1672
+ process.env.AMALGM_WORKSPACES_DIR,
1673
+ 'initial-hardlink-alias',
1674
+ );
1675
+ const originalPath = path.join(root, 'a.txt');
1676
+ const aliasPath = path.join(root, 'b.txt');
1677
+ fs.mkdirSync(root, { recursive: true });
1678
+ fs.writeFileSync(originalPath, 'one inode\n');
1679
+ fs.linkSync(originalPath, aliasPath);
1680
+ const workspace = upsertWorkspace({
1681
+ path: root,
1682
+ name: 'Initial hardlink alias',
1683
+ source: 'computer',
1684
+ });
1685
+
1686
+ const held = enrollWorkspace(workspace.id);
1687
+ assert.equal(held.skipped, 'ambiguous-physical-alias');
1688
+ assert.deepEqual(held.held, [{
1689
+ reason: 'ambiguous-physical-alias',
1690
+ paths: ['a.txt', 'b.txt'],
1691
+ }]);
1692
+ assert.equal(listWorkspaceNodes(workspace.id).length, 1);
1693
+ assert.equal(nodeForPath(originalPath), null);
1694
+ assert.equal(nodeForPath(aliasPath), null);
1695
+
1696
+ fs.unlinkSync(aliasPath);
1697
+ const enrolled = enrollWorkspace(workspace.id);
1698
+ assert.equal(enrolled.skipped, undefined);
1699
+ assert.equal(nodeForPath(originalPath).node.id, enrolled.nodes.find(
1700
+ (node) => node.relativePath === 'a.txt',
1701
+ ).id);
1702
+ assert.equal(nodeForPath(aliasPath), null);
1703
+ });
1704
+
1705
+ test('registration returns the durable observation hold for initial hardlinks', async () => {
1706
+ const root = path.join(
1707
+ process.env.AMALGM_WORKSPACES_DIR,
1708
+ 'registered-hardlink-alias',
1709
+ );
1710
+ const originalPath = path.join(root, 'a.txt');
1711
+ const aliasPath = path.join(root, 'b.txt');
1712
+ fs.mkdirSync(root, { recursive: true });
1713
+ fs.writeFileSync(originalPath, 'one registered inode\n');
1714
+ fs.linkSync(originalPath, aliasPath);
1715
+ let response = null;
1716
+
1717
+ await handleProjectRegister({ path: root }, (status, body) => {
1718
+ response = { status, body };
1719
+ });
1720
+
1721
+ assert.equal(response.status, 200);
1722
+ assert.equal(response.body.workspace.treeObservation.state, 'held');
1723
+ assert.deepEqual(
1724
+ response.body.tree.observation.holds.map((hold) => ({
1725
+ reason: hold.reason,
1726
+ paths: hold.paths,
1727
+ })),
1728
+ [{
1729
+ reason: 'ambiguous-physical-alias',
1730
+ paths: ['a.txt', 'b.txt'],
1731
+ }],
1732
+ );
1733
+ });
1734
+
1735
+ test('observation holds new hardlink aliases while unrelated files still get births', () => {
1736
+ const root = path.join(
1737
+ process.env.AMALGM_WORKSPACES_DIR,
1738
+ 'observed-hardlink-alias',
1739
+ );
1740
+ const originalPath = path.join(root, 'a.txt');
1741
+ const aliasPath = path.join(root, 'b.txt');
1742
+ const unrelatedPath = path.join(root, 'c.txt');
1743
+ fs.mkdirSync(root, { recursive: true });
1744
+ const workspace = upsertWorkspace({
1745
+ path: root,
1746
+ name: 'Observed hardlink alias',
1747
+ source: 'computer',
1748
+ });
1749
+ enrollWorkspace(workspace.id);
1750
+
1751
+ fs.writeFileSync(originalPath, 'one unknown inode\n');
1752
+ fs.linkSync(originalPath, aliasPath);
1753
+ fs.writeFileSync(unrelatedPath, 'independent bytes\n');
1754
+ const observed = reconcileWorkspaceTree(workspace.id);
1755
+
1756
+ assert.deepEqual(observed.held, [{
1757
+ nodeId: null,
1758
+ paths: ['a.txt', 'b.txt'],
1759
+ reason: 'ambiguous-physical-alias',
1760
+ }]);
1761
+ assert.equal(nodeForPath(originalPath), null);
1762
+ assert.equal(nodeForPath(aliasPath), null);
1763
+ const unrelatedId = nodeForPath(unrelatedPath)?.node.id;
1764
+ assert.ok(unrelatedId);
1765
+ assert.deepEqual(
1766
+ observed.operations.map((operation) => operation.kind),
1767
+ ['tree.create'],
1768
+ );
1769
+
1770
+ fs.unlinkSync(aliasPath);
1771
+ const resolved = reconcileWorkspaceTree(workspace.id);
1772
+ assert.deepEqual(resolved.held, []);
1773
+ assert.ok(nodeForPath(originalPath)?.node.id);
1774
+ assert.equal(nodeForPath(aliasPath), null);
1775
+ assert.equal(nodeForPath(unrelatedPath)?.node.id, unrelatedId);
1776
+ });
1777
+
1778
+ test('the recurring observer durably surfaces and clears physical alias holds', () => {
1779
+ const root = path.join(
1780
+ process.env.AMALGM_WORKSPACES_DIR,
1781
+ 'recurring-hardlink-alias',
1782
+ );
1783
+ const originalPath = path.join(root, 'a.txt');
1784
+ const aliasPath = path.join(root, 'b.txt');
1785
+ const unrelatedPath = path.join(root, 'c.txt');
1786
+ fs.mkdirSync(root, { recursive: true });
1787
+ const workspace = upsertWorkspace({
1788
+ path: root,
1789
+ name: 'Recurring hardlink alias',
1790
+ source: 'computer',
1791
+ });
1792
+ enrollWorkspace(workspace.id);
1793
+
1794
+ fs.writeFileSync(originalPath, 'one unresolved inode\n');
1795
+ fs.linkSync(originalPath, aliasPath);
1796
+ fs.writeFileSync(unrelatedPath, 'ordinary local work\n');
1797
+ reconcileRegisteredWorkspaceTrees();
1798
+
1799
+ const held = listWorkspaces().find((entry) => entry.id === workspace.id);
1800
+ assert.equal(held.treeObservation.state, 'held');
1801
+ assert.deepEqual(
1802
+ held.treeObservation.holds.map((entry) => ({
1803
+ reason: entry.reason,
1804
+ paths: entry.paths,
1805
+ })),
1806
+ [{
1807
+ reason: 'ambiguous-physical-alias',
1808
+ paths: ['a.txt', 'b.txt'],
1809
+ }],
1810
+ );
1811
+ assert.ok(nodeForPath(unrelatedPath)?.node.id);
1812
+ assert.equal(nodeForPath(originalPath), null);
1813
+ assert.equal(nodeForPath(aliasPath), null);
1814
+ const event = openLocalDb().prepare(`
1815
+ SELECT value_json
1816
+ FROM event_log
1817
+ WHERE resource = 'workspaces' AND id = ?
1818
+ AND source = 'workspace-tree:observation-hold'
1819
+ ORDER BY seq DESC
1820
+ LIMIT 1
1821
+ `).get(workspace.id);
1822
+ assert.equal(JSON.parse(event.value_json).treeObservation.state, 'held');
1823
+ const holdEventCount = openLocalDb().prepare(`
1824
+ SELECT COUNT(*) AS count
1825
+ FROM event_log
1826
+ WHERE id = ? AND source = 'workspace-tree:observation-hold'
1827
+ `).get(workspace.id).count;
1828
+ reconcileRegisteredWorkspaceTrees();
1829
+ assert.equal(openLocalDb().prepare(`
1830
+ SELECT COUNT(*) AS count
1831
+ FROM event_log
1832
+ WHERE id = ? AND source = 'workspace-tree:observation-hold'
1833
+ `).get(workspace.id).count, holdEventCount);
1834
+ assert.equal(
1835
+ reconcileWorkspaceTree(workspace.id, { maxNodes: 0 }).skipped,
1836
+ 'node_limit',
1837
+ );
1838
+ assert.equal(
1839
+ listWorkspaces().find((entry) => entry.id === workspace.id).treeObservation.state,
1840
+ 'held',
1841
+ );
1842
+
1843
+ closeLocalDb();
1844
+ assert.equal(
1845
+ listWorkspaces().find((entry) => entry.id === workspace.id).treeObservation.state,
1846
+ 'held',
1847
+ );
1848
+
1849
+ fs.unlinkSync(aliasPath);
1850
+ reconcileRegisteredWorkspaceTrees();
1851
+ const resolved = listWorkspaces().find((entry) => entry.id === workspace.id);
1852
+ assert.equal(resolved.treeObservation, null);
1853
+ assert.ok(nodeForPath(originalPath)?.node.id);
1854
+ assert.equal(nodeForPath(aliasPath), null);
1855
+ assert.equal(openLocalDb().prepare(`
1856
+ SELECT COUNT(*) AS count
1857
+ FROM workspace_tree_observation_holds
1858
+ WHERE workspace_id = ?
1859
+ `).get(workspace.id).count, 0);
1860
+ });
1861
+
1862
+ test('repeat enrollment cannot absorb or clear a permission-held disk birth', () => {
1863
+ const root = path.join(
1864
+ process.env.AMALGM_WORKSPACES_DIR,
1865
+ 'permission-held-repeat-enrollment',
1866
+ );
1867
+ const target = path.join(root, 'local.txt');
1868
+ fs.mkdirSync(root, { recursive: true });
1869
+ const workspace = upsertWorkspace({
1870
+ path: root,
1871
+ name: 'Permission-held repeat enrollment',
1872
+ source: 'computer',
1873
+ });
1874
+ enrollWorkspace(workspace.id);
1875
+
1876
+ const identity = require('../workspace/identity');
1877
+ const previousUserId = identity.userId;
1878
+ const memberId = 'member-permission-held-repeat-enrollment';
1879
+ openLocalDb().prepare(`
1880
+ INSERT INTO workspace_node_permissions(workspace_id, node_id, user_id, role)
1881
+ VALUES (?, ?, ?, 'viewer')
1882
+ `).run(workspace.id, workspace.id, memberId);
1883
+ identity.userId = () => memberId;
1884
+ try {
1885
+ fs.writeFileSync(target, 'held local bytes\n');
1886
+ const first = reconcileWorkspaceTree(workspace.id);
1887
+ assert.deepEqual(first.operations, []);
1888
+ assert.equal(first.held[0].reason, 'forbidden');
1889
+ assert.deepEqual(first.held[0].paths, ['local.txt']);
1890
+ assert.equal(nodeForPath(target), null);
1891
+
1892
+ const repeated = enrollWorkspace(workspace.id);
1893
+ assert.deepEqual(repeated.operations, []);
1894
+ assert.equal(repeated.held[0].reason, 'forbidden');
1895
+ assert.equal(nodeForPath(target), null);
1896
+ assert.equal(
1897
+ listWorkspaces().find((entry) => entry.id === workspace.id)
1898
+ .treeObservation.holds[0].reason,
1899
+ 'forbidden',
1900
+ );
1901
+ assert.equal(openLocalDb().prepare(`
1902
+ SELECT COUNT(*) AS count
1903
+ FROM mutation_journal
1904
+ WHERE local_resource = ? AND operation_kind = 'tree.create'
1905
+ `).get(`workspace-tree:${workspace.id}`).count, 0);
1906
+
1907
+ openLocalDb().prepare(`
1908
+ UPDATE workspace_node_permissions SET role = 'editor'
1909
+ WHERE workspace_id = ? AND node_id = ? AND user_id = ?
1910
+ `).run(workspace.id, workspace.id, memberId);
1911
+ const resolved = enrollWorkspace(workspace.id);
1912
+ assert.deepEqual(
1913
+ resolved.operations.map((operation) => operation.kind),
1914
+ ['tree.create'],
1915
+ );
1916
+ assert.ok(nodeForPath(target)?.node.id);
1917
+ assert.equal(
1918
+ listWorkspaces().find((entry) => entry.id === workspace.id)
1919
+ .treeObservation,
1920
+ null,
1921
+ );
1922
+ } finally {
1923
+ identity.userId = previousUserId;
1924
+ }
1925
+ });
1926
+
1927
+ test('legacy duplicate inode owners remain one durable contradictory hold', () => {
1928
+ const root = path.join(
1929
+ process.env.AMALGM_WORKSPACES_DIR,
1930
+ 'legacy-duplicate-inode-owners',
1931
+ );
1932
+ const survivingPath = path.join(root, 'a.txt');
1933
+ const removedAliasPath = path.join(root, 'b.txt');
1934
+ fs.mkdirSync(root, { recursive: true });
1935
+ fs.writeFileSync(survivingPath, 'legacy shared inode\n');
1936
+ const workspace = upsertWorkspace({
1937
+ path: root,
1938
+ name: 'Legacy duplicate inode owners',
1939
+ source: 'computer',
1940
+ });
1941
+ enrollWorkspace(workspace.id);
1942
+ const first = nodeForPath(survivingPath).node;
1943
+ const secondId = uuidV7();
1944
+ const now = new Date().toISOString();
1945
+ fs.linkSync(survivingPath, removedAliasPath);
1946
+ openLocalDb().prepare(`
1947
+ INSERT INTO workspace_nodes (
1948
+ id, workspace_id, parent_id, kind, name, relative_path,
1949
+ symlink_target, device_id, inode, created_at, updated_at
1950
+ ) VALUES (?, ?, ?, 'file', 'b.txt', 'b.txt', NULL, ?, ?, ?, ?)
1951
+ `).run(
1952
+ secondId,
1953
+ workspace.id,
1954
+ workspace.id,
1955
+ first.deviceId,
1956
+ first.inode,
1957
+ now,
1958
+ now,
1959
+ );
1960
+ fs.unlinkSync(removedAliasPath);
1961
+
1962
+ const observed = reconcileWorkspaceTree(workspace.id);
1963
+ assert.deepEqual(observed.operations, []);
1964
+ assert.deepEqual(
1965
+ observed.held.map((hold) => ({
1966
+ nodeId: hold.nodeId,
1967
+ reason: hold.reason,
1968
+ paths: hold.paths,
1969
+ })).sort((left, right) => left.nodeId.localeCompare(right.nodeId)),
1970
+ [first.id, secondId].sort().map((nodeId) => ({
1971
+ nodeId,
1972
+ reason: 'ambiguous-physical-ownership',
1973
+ paths: ['a.txt'],
1974
+ })),
1975
+ );
1976
+ assert.equal(
1977
+ listWorkspaces().find((entry) => entry.id === workspace.id)
1978
+ .treeObservation.holds.length,
1979
+ 2,
1980
+ );
1981
+ assert.equal(
1982
+ listWorkspaceNodes(workspace.id).filter(
1983
+ (node) => node.id === first.id || node.id === secondId,
1984
+ ).length,
1985
+ 2,
1986
+ 'contradictory legacy ownership is held instead of guessed from a name',
1987
+ );
1988
+ });
1989
+
1990
+ test('a replaced destination parent applies the original move before its local trash', () => {
1991
+ const root = path.join(process.env.AMALGM_WORKSPACES_DIR, 'causal-parent-replacement');
1992
+ const folder = path.join(root, 'D');
1993
+ const source = path.join(root, 'x.txt');
1994
+ fs.mkdirSync(folder, { recursive: true });
1995
+ fs.writeFileSync(source, 'causal parent bytes\n');
1996
+ const workspace = upsertWorkspace({
1997
+ path: root,
1998
+ name: 'Causal parent replacement',
1999
+ source: 'computer',
2000
+ });
2001
+ enrollWorkspace(workspace.id);
2002
+ const oldFolderId = nodeForPath(folder).node.id;
2003
+ const fileId = nodeForPath(source).node.id;
2004
+ commitTreeOperation({
2005
+ kind: 'tree.move',
2006
+ workspaceId: workspace.id,
2007
+ nodeId: fileId,
2008
+ parentId: oldFolderId,
2009
+ name: 'x.txt',
2010
+ }, {
2011
+ role: 'owner',
2012
+ testHooks: {
2013
+ afterOperationPlan() {
2014
+ fs.rmdirSync(folder);
2015
+ fs.mkdirSync(folder);
2016
+ },
2017
+ },
2018
+ });
2019
+ assert.equal(fs.existsSync(source), false);
2020
+ assert.deepEqual(fs.readdirSync(folder), []);
2021
+ assert.notEqual(nodeForPath(folder).node.id, oldFolderId);
2022
+ const oldFolderTrash = trashPath(workspace.id, oldFolderId, workspace.path);
2023
+ assert.equal(
2024
+ fs.readFileSync(path.join(oldFolderTrash, 'x.txt'), 'utf8'),
2025
+ 'causal parent bytes\n',
2026
+ );
2027
+ const rows = listWorkspaceNodes(workspace.id, { includeTrashed: true });
2028
+ assert.ok(rows.find((node) => node.id === oldFolderId).trashedAt);
2029
+ assert.ok(rows.find((node) => node.id === fileId).trashedAt);
2030
+ assert.equal(openLocalDb().prepare(`
2031
+ SELECT COUNT(*) AS count
2032
+ FROM workspace_tree_operation_materializations
2033
+ WHERE resource_id = ?
2034
+ `).get(workspace.id).count, 0);
2035
+ });
2036
+
2037
+ test('a child keeps its UUID when only its same-path parent is replaced', () => {
2038
+ const root = path.join(
2039
+ process.env.AMALGM_WORKSPACES_DIR,
2040
+ 'same-path-parent-child-survivor',
2041
+ );
2042
+ const folder = path.join(root, 'A');
2043
+ const heldFolder = path.join(root, 'held-A');
2044
+ const file = path.join(folder, 'x.txt');
2045
+ fs.mkdirSync(folder, { recursive: true });
2046
+ fs.writeFileSync(file, 'surviving child bytes\n');
2047
+ const workspace = upsertWorkspace({
2048
+ path: root,
2049
+ name: 'Same-path parent child survivor',
2050
+ source: 'computer',
2051
+ });
2052
+ enrollWorkspace(workspace.id);
2053
+ const oldFolderId = nodeForPath(folder).node.id;
2054
+ const fileId = nodeForPath(file).node.id;
2055
+
2056
+ fs.renameSync(folder, heldFolder);
2057
+ fs.mkdirSync(folder);
2058
+ fs.renameSync(path.join(heldFolder, 'x.txt'), file);
2059
+ fs.rmdirSync(heldFolder);
2060
+
2061
+ reconcileWorkspaceTree(workspace.id);
2062
+ const replacementFolder = nodeForPath(folder).node;
2063
+ const survivingFile = nodeForPath(file).node;
2064
+ assert.notEqual(replacementFolder.id, oldFolderId);
2065
+ assert.equal(survivingFile.id, fileId);
2066
+ assert.equal(survivingFile.parentId, replacementFolder.id);
2067
+ assert.equal(fs.readFileSync(file, 'utf8'), 'surviving child bytes\n');
2068
+ const rows = listWorkspaceNodes(workspace.id, { includeTrashed: true });
2069
+ assert.ok(rows.find((node) => node.id === oldFolderId).trashedAt);
2070
+ assert.equal(rows.find((node) => node.id === fileId).trashedAt, null);
2071
+ });
2072
+
2073
+ test('a surviving folder preserves kept children while classifying its changed members', () => {
2074
+ const root = path.join(
2075
+ process.env.AMALGM_WORKSPACES_DIR,
2076
+ 'same-path-parent-folder-survivor',
2077
+ );
2078
+ const folder = path.join(root, 'A');
2079
+ const heldFolder = path.join(root, 'held-A');
2080
+ const nested = path.join(folder, 'B');
2081
+ fs.mkdirSync(nested, { recursive: true });
2082
+ fs.writeFileSync(path.join(nested, 'keep.txt'), 'keep bytes\n');
2083
+ fs.writeFileSync(path.join(nested, 'gone.txt'), 'gone bytes\n');
2084
+ const workspace = upsertWorkspace({
2085
+ path: root,
2086
+ name: 'Same-path parent folder survivor',
2087
+ source: 'computer',
2088
+ });
2089
+ enrollWorkspace(workspace.id);
2090
+ const oldFolderId = nodeForPath(folder).node.id;
2091
+ const nestedId = nodeForPath(nested).node.id;
2092
+ const keepId = nodeForPath(path.join(nested, 'keep.txt')).node.id;
2093
+ const goneId = nodeForPath(path.join(nested, 'gone.txt')).node.id;
2094
+
2095
+ fs.renameSync(folder, heldFolder);
2096
+ fs.unlinkSync(path.join(heldFolder, 'B', 'gone.txt'));
2097
+ fs.writeFileSync(path.join(heldFolder, 'B', 'new.txt'), 'new bytes\n');
2098
+ fs.mkdirSync(folder);
2099
+ fs.renameSync(path.join(heldFolder, 'B'), nested);
2100
+ fs.rmdirSync(heldFolder);
2101
+
2102
+ reconcileWorkspaceTree(workspace.id);
2103
+ const replacementFolder = nodeForPath(folder).node;
2104
+ assert.notEqual(replacementFolder.id, oldFolderId);
2105
+ assert.equal(nodeForPath(nested).node.id, nestedId);
2106
+ assert.equal(nodeForPath(nested).node.parentId, replacementFolder.id);
2107
+ assert.equal(nodeForPath(path.join(nested, 'keep.txt')).node.id, keepId);
2108
+ const newNode = nodeForPath(path.join(nested, 'new.txt')).node;
2109
+ assert.notEqual(newNode.id, goneId);
2110
+ assert.equal(fs.readFileSync(path.join(nested, 'new.txt'), 'utf8'), 'new bytes\n');
2111
+ const rows = listWorkspaceNodes(workspace.id, { includeTrashed: true });
2112
+ assert.ok(rows.find((node) => node.id === goneId).trashedAt);
2113
+ assert.equal(rows.find((node) => node.id === nestedId).trashedAt, null);
2114
+ assert.equal(rows.find((node) => node.id === keepId).trashedAt, null);
2115
+ });
2116
+
2117
+ test('ambiguous hardlink names hold a parent replacement until one path wins', () => {
2118
+ const root = path.join(
2119
+ process.env.AMALGM_WORKSPACES_DIR,
2120
+ 'same-path-parent-hardlink-survivor',
2121
+ );
2122
+ const folder = path.join(root, 'A');
2123
+ const heldFolder = path.join(root, 'held-A');
2124
+ const file = path.join(folder, 'x.txt');
2125
+ const alias = path.join(folder, 'y.txt');
2126
+ const sibling = path.join(folder, 'z.txt');
2127
+ fs.mkdirSync(folder, { recursive: true });
2128
+ fs.writeFileSync(file, 'ambiguous child bytes\n');
2129
+ const workspace = upsertWorkspace({
2130
+ path: root,
2131
+ name: 'Same-path parent hardlink survivor',
2132
+ source: 'computer',
2133
+ });
2134
+ enrollWorkspace(workspace.id);
2135
+ const oldFolderId = nodeForPath(folder).node.id;
2136
+ const fileId = nodeForPath(file).node.id;
2137
+
2138
+ fs.renameSync(folder, heldFolder);
2139
+ fs.mkdirSync(folder);
2140
+ fs.renameSync(path.join(heldFolder, 'x.txt'), file);
2141
+ fs.linkSync(file, alias);
2142
+ fs.writeFileSync(sibling, 'new sibling bytes\n');
2143
+ fs.rmdirSync(heldFolder);
2144
+
2145
+ const ambiguous = reconcileWorkspaceTree(workspace.id);
2146
+ assert.deepEqual(ambiguous.operations, []);
2147
+ assert.ok(ambiguous.held.some((entry) => (
2148
+ entry.nodeId === fileId
2149
+ && entry.reason === 'ambiguous-physical-alias'
2150
+ )));
2151
+ assert.equal(nodeForPath(folder).node.id, oldFolderId);
2152
+ assert.equal(nodeForPath(file).node.id, fileId);
2153
+ assert.equal(nodeForPath(alias), null);
2154
+ assert.equal(nodeForPath(sibling), null);
2155
+
2156
+ fs.unlinkSync(alias);
2157
+ reconcileWorkspaceTree(workspace.id);
2158
+ const replacementFolderId = nodeForPath(folder).node.id;
2159
+ assert.notEqual(replacementFolderId, oldFolderId);
2160
+ assert.equal(nodeForPath(file).node.id, fileId);
2161
+ assert.equal(nodeForPath(alias), null);
2162
+ assert.equal(nodeForPath(sibling).node.parentId, replacementFolderId);
2163
+ assert.equal(fs.readFileSync(file, 'utf8'), 'ambiguous child bytes\n');
2164
+ assert.equal(fs.readFileSync(sibling, 'utf8'), 'new sibling bytes\n');
2165
+ });
2166
+
2167
+ test('restart finishes a prepared create cancellation before replanning', () => {
2168
+ const root = path.join(
2169
+ process.env.AMALGM_WORKSPACES_DIR,
2170
+ 'prepared-create-cancellation-restart',
2171
+ );
2172
+ const folder = path.join(root, 'A');
2173
+ const movedFolder = path.join(root, 'C');
2174
+ fs.mkdirSync(folder, { recursive: true });
2175
+ const workspace = upsertWorkspace({
2176
+ path: root,
2177
+ name: 'Prepared create cancellation restart',
2178
+ source: 'computer',
2179
+ });
2180
+ enrollWorkspace(workspace.id);
2181
+ const parentId = nodeForPath(folder).node.id;
2182
+ const nodeId = uuidV7();
2183
+ let parentMoved = false;
2184
+ assert.throws(
2185
+ () => commitTreeOperation({
2186
+ kind: 'tree.create',
2187
+ workspaceId: workspace.id,
2188
+ nodeId,
2189
+ parentId,
2190
+ nodeKind: 'file',
2191
+ name: 'recovered.txt',
2192
+ }, {
2193
+ role: 'owner',
2194
+ testHooks: {
2195
+ afterOperationCreatePrepared() {
2196
+ if (parentMoved) return;
2197
+ parentMoved = true;
2198
+ fs.renameSync(folder, movedFolder);
2199
+ },
2200
+ afterOperationCreateCancellationStage() {
2201
+ throw new Error('simulated crash during create cancellation');
2202
+ },
2203
+ },
2204
+ }),
2205
+ /simulated crash during create cancellation/,
2206
+ );
2207
+ fs.renameSync(movedFolder, folder);
2208
+ recoverAllTreePlans();
2209
+ const recovered = path.join(folder, 'recovered.txt');
2210
+ assert.equal(fs.existsSync(recovered), true);
2211
+ assert.equal(nodeForPath(recovered).node.id, nodeId);
2212
+ assert.equal(openLocalDb().prepare(`
2213
+ SELECT COUNT(*) AS count
2214
+ FROM workspace_tree_operation_materializations
2215
+ WHERE resource_id = ?
2216
+ `).get(workspace.id).count, 0);
2217
+ });
2218
+
2219
+ test('restart births a retained cancellation occupant before replacing its plan', () => {
2220
+ const root = path.join(
2221
+ process.env.AMALGM_WORKSPACES_DIR,
2222
+ 'prepared-create-retained-occupant',
2223
+ );
2224
+ const folder = path.join(root, 'A');
2225
+ const movedFolder = path.join(root, 'C');
2226
+ const target = path.join(movedFolder, 'recovered.txt');
2227
+ fs.mkdirSync(folder, { recursive: true });
2228
+ const workspace = upsertWorkspace({
2229
+ path: root,
2230
+ name: 'Prepared create retained occupant',
2231
+ source: 'computer',
2232
+ });
2233
+ enrollWorkspace(workspace.id);
2234
+ const parentId = nodeForPath(folder).node.id;
2235
+ const nodeId = uuidV7();
2236
+ let parentMoved = false;
2237
+ assert.throws(
2238
+ () => commitTreeOperation({
2239
+ kind: 'tree.create',
2240
+ workspaceId: workspace.id,
2241
+ nodeId,
2242
+ parentId,
2243
+ nodeKind: 'file',
2244
+ name: 'recovered.txt',
2245
+ }, {
2246
+ role: 'owner',
2247
+ testHooks: {
2248
+ afterOperationCreatePrepared() {
2249
+ if (parentMoved) return;
2250
+ parentMoved = true;
2251
+ fs.renameSync(folder, movedFolder);
2252
+ fs.mkdirSync(target);
2253
+ fs.writeFileSync(path.join(target, 'writer.txt'), 'retained writer bytes\n');
2254
+ },
2255
+ afterOperationCancellationOutputReceipt(outputs) {
2256
+ assert.ok(outputs.some((output) => (
2257
+ output.path === target
2258
+ && output.provenance === 'independent-occupant'
2259
+ )));
2260
+ throw new Error('simulated crash after cancellation output receipt');
2261
+ },
2262
+ },
2263
+ }),
2264
+ /simulated crash after cancellation output receipt/,
2265
+ );
2266
+ const held = treeMaterialization.getOperation(
2267
+ workspace.id,
2268
+ openLocalDb().prepare(`
2269
+ SELECT mutation_id AS mutationId
2270
+ FROM workspace_tree_operation_materializations
2271
+ WHERE resource_id = ?
2272
+ `).get(workspace.id).mutationId,
2273
+ );
2274
+ assert.ok(held.plan.retainedOutputs.some((output) => output.path === target));
2275
+ assert.equal(nodeForPath(target), null);
2276
+
2277
+ recoverAllTreePlans();
2278
+
2279
+ const writerPaths = fs.readdirSync(root, { recursive: true })
2280
+ .map((entry) => path.join(root, String(entry)))
2281
+ .filter((entry) => path.basename(entry) === 'writer.txt');
2282
+ assert.equal(writerPaths.length, 1);
2283
+ assert.equal(fs.readFileSync(writerPaths[0], 'utf8'), 'retained writer bytes\n');
2284
+ const occupant = nodeForPath(path.dirname(writerPaths[0])).node;
2285
+ assert.equal(occupant.kind, 'folder');
2286
+ assert.notEqual(occupant.id, nodeId);
2287
+ const superseded = listWorkspaceNodes(
2288
+ workspace.id,
2289
+ { includeTrashed: true },
2290
+ ).find((node) => node.id === nodeId);
2291
+ assert.equal(superseded.kind, 'file');
2292
+ assert.ok(superseded.trashedAt);
2293
+ assert.equal(openLocalDb().prepare(`
2294
+ SELECT COUNT(*) AS count
2295
+ FROM workspace_tree_operation_materializations
2296
+ WHERE resource_id = ?
2297
+ `).get(workspace.id).count, 0);
2298
+ });
2299
+
2300
+ test('cancelled create receipts keep their UUID with the surviving carrier', () => {
2301
+ for (const occupied of [false, true]) {
2302
+ const root = path.join(
2303
+ process.env.AMALGM_WORKSPACES_DIR,
2304
+ `prepared-create-carrier-${occupied ? 'occupied' : 'open'}`,
2305
+ );
2306
+ const folder = path.join(root, 'A');
2307
+ const movedFolder = path.join(root, 'C');
2308
+ const target = path.join(movedFolder, 'recovered.txt');
2309
+ fs.mkdirSync(folder, { recursive: true });
2310
+ const workspace = upsertWorkspace({
2311
+ path: root,
2312
+ name: `Prepared create carrier ${occupied ? 'occupied' : 'open'}`,
2313
+ source: 'computer',
2314
+ });
2315
+ enrollWorkspace(workspace.id);
2316
+ const parentId = nodeForPath(folder).node.id;
2317
+ const nodeId = uuidV7();
2318
+ assert.throws(
2319
+ () => commitTreeOperation({
2320
+ kind: 'tree.create',
2321
+ workspaceId: workspace.id,
2322
+ nodeId,
2323
+ parentId,
2324
+ nodeKind: 'file',
2325
+ name: 'recovered.txt',
2326
+ }, {
2327
+ role: 'owner',
2328
+ testHooks: {
2329
+ afterOperationCreatePrepared(_action, prepared) {
2330
+ fs.writeFileSync(prepared, 'receipt-owned writer bytes\n');
2331
+ fs.renameSync(folder, movedFolder);
2332
+ if (occupied) {
2333
+ fs.mkdirSync(target);
2334
+ fs.writeFileSync(
2335
+ path.join(target, 'independent.txt'),
2336
+ 'independent bytes\n',
2337
+ );
2338
+ }
2339
+ },
2340
+ afterOperationCancellationOutputReceipt() {
2341
+ throw new Error('simulated crash after carried cancellation receipt');
2342
+ },
2343
+ },
2344
+ }),
2345
+ /simulated crash after carried cancellation receipt/,
2346
+ );
2347
+ const row = openLocalDb().prepare(`
2348
+ SELECT mutation_id AS mutationId, plan_json AS planJson
2349
+ FROM workspace_tree_operation_materializations
2350
+ WHERE resource_id = ?
2351
+ `).get(workspace.id);
2352
+ const durablePlan = JSON.parse(row.planJson);
2353
+ assert.equal(durablePlan.carriedCreates[0].nodeId, nodeId);
2354
+ assert.equal(
2355
+ durablePlan.carriedCreateDeletions?.some(
2356
+ (binding) => binding.nodeId === nodeId,
2357
+ ) || false,
2358
+ false,
2359
+ );
2360
+
2361
+ recoverAllTreePlans();
2362
+
2363
+ if (!occupied) {
2364
+ assert.equal(fs.readFileSync(target, 'utf8'), 'receipt-owned writer bytes\n');
2365
+ assert.equal(nodeForPath(target).node.id, nodeId);
2366
+ } else {
2367
+ const occupant = nodeForPath(target).node;
2368
+ assert.equal(occupant.kind, 'folder');
2369
+ assert.notEqual(occupant.id, nodeId);
2370
+ const sidecar = fs.readdirSync(movedFolder).find((name) => (
2371
+ name.startsWith('recovered.amalgm-retained-')
2372
+ && name.endsWith('.txt')
2373
+ ));
2374
+ assert.ok(sidecar);
2375
+ const sidecarPath = path.join(movedFolder, sidecar);
2376
+ assert.equal(
2377
+ fs.readFileSync(sidecarPath, 'utf8'),
2378
+ 'receipt-owned writer bytes\n',
2379
+ );
2380
+ assert.equal(nodeForPath(sidecarPath).node.id, nodeId);
2381
+ }
2382
+ assert.equal(treeMaterialization.getOperation(
2383
+ workspace.id,
2384
+ row.mutationId,
2385
+ ), null);
2386
+ }
2387
+ });
2388
+
2389
+ test('prepared cleanup captures a same-path save after retained births', () => {
2390
+ const root = path.join(
2391
+ process.env.AMALGM_WORKSPACES_DIR,
2392
+ 'prepared-post-birth-save',
2393
+ );
2394
+ const folder = path.join(root, 'A');
2395
+ const movedFolder = path.join(root, 'C');
2396
+ const target = path.join(movedFolder, 'recovered.txt');
2397
+ fs.mkdirSync(folder, { recursive: true });
2398
+ const workspace = upsertWorkspace({
2399
+ path: root,
2400
+ name: 'Prepared post-birth save',
2401
+ source: 'computer',
2402
+ });
2403
+ enrollWorkspace(workspace.id);
2404
+ const parentId = nodeForPath(folder).node.id;
2405
+ const nodeId = uuidV7();
2406
+ assert.throws(
2407
+ () => commitTreeOperation({
2408
+ kind: 'tree.create',
2409
+ workspaceId: workspace.id,
2410
+ nodeId,
2411
+ parentId,
2412
+ nodeKind: 'file',
2413
+ name: 'recovered.txt',
2414
+ }, {
2415
+ role: 'owner',
2416
+ testHooks: {
2417
+ afterOperationCreatePrepared(_action, prepared) {
2418
+ fs.writeFileSync(prepared, 'first retained bytes\n');
2419
+ fs.renameSync(folder, movedFolder);
2420
+ },
2421
+ afterOperationCancellationOutputReceipt() {
2422
+ throw new Error('simulated crash before retained birth');
2423
+ },
2424
+ },
2425
+ }),
2426
+ /simulated crash before retained birth/,
2427
+ );
2428
+
2429
+ let replaced = false;
2430
+ let crashedAfterSettle = false;
2431
+ assert.throws(
2432
+ () => recoverAllTreePlans({
2433
+ testHooks: {
2434
+ afterRetainedBindingReconcile(outputs) {
2435
+ if (replaced) return;
2436
+ const carried = outputs.find((output) => (
2437
+ output.provenance === 'carried-source'
2438
+ && output.ownerNodeId === nodeId
2439
+ ));
2440
+ assert.ok(carried);
2441
+ const replacement = `${carried.path}.replacement`;
2442
+ fs.writeFileSync(replacement, 'later same-path save\n');
2443
+ fs.renameSync(replacement, carried.path);
2444
+ replaced = true;
2445
+ },
2446
+ afterRetainedBindingSettle() {
2447
+ if (crashedAfterSettle) return;
2448
+ crashedAfterSettle = true;
2449
+ throw new Error('simulated crash after retained binding settlement');
2450
+ },
2451
+ },
2452
+ }),
2453
+ /simulated crash after retained binding settlement/,
2454
+ );
2455
+ const retainedPlan = openLocalDb().prepare(`
2456
+ SELECT plan_json AS planJson
2457
+ FROM workspace_tree_operation_materializations
2458
+ WHERE resource_id = ?
2459
+ `).get(workspace.id);
2460
+ assert.equal(JSON.parse(retainedPlan.planJson).retainedOutputsAdopted, true);
2461
+
2462
+ recoverAllTreePlans();
2463
+
2464
+ assert.equal(replaced, true);
2465
+ assert.equal(fs.readFileSync(target, 'utf8'), 'later same-path save\n');
2466
+ assert.equal(nodeForPath(target).node.id, nodeId);
2467
+ assert.equal(openLocalDb().prepare(`
2468
+ SELECT COUNT(*) AS count
2469
+ FROM workspace_tree_operation_materializations
2470
+ WHERE resource_id = ?
2471
+ `).get(workspace.id).count, 0);
2472
+ });
2473
+
2474
+ test('prepared cleanup durably replays a folder successor after settlement crash', () => {
2475
+ const root = path.join(
2476
+ process.env.AMALGM_WORKSPACES_DIR,
2477
+ 'prepared-folder-successor-crash',
2478
+ );
2479
+ const folder = path.join(root, 'A');
2480
+ const movedFolder = path.join(root, 'C');
2481
+ fs.mkdirSync(folder, { recursive: true });
2482
+ const workspace = upsertWorkspace({
2483
+ path: root,
2484
+ name: 'Prepared folder successor crash',
2485
+ source: 'computer',
2486
+ });
2487
+ enrollWorkspace(workspace.id);
2488
+ const parentId = nodeForPath(folder).node.id;
2489
+ const nodeId = uuidV7();
2490
+ assert.throws(
2491
+ () => commitTreeOperation({
2492
+ kind: 'tree.create',
2493
+ workspaceId: workspace.id,
2494
+ nodeId,
2495
+ parentId,
2496
+ nodeKind: 'folder',
2497
+ name: 'recovered',
2498
+ }, {
2499
+ role: 'owner',
2500
+ testHooks: {
2501
+ afterOperationCreatePrepared(_action, prepared) {
2502
+ fs.writeFileSync(path.join(prepared, 'original.txt'), 'original\n');
2503
+ fs.renameSync(folder, movedFolder);
2504
+ },
2505
+ afterOperationCancellationOutputReceipt() {
2506
+ throw new Error('simulated crash before retained folder birth');
2507
+ },
2508
+ },
2509
+ }),
2510
+ /simulated crash before retained folder birth/,
2511
+ );
2512
+
2513
+ let originalPath = null;
2514
+ let successorPath = null;
2515
+ assert.throws(
2516
+ () => recoverAllTreePlans({
2517
+ testHooks: {
2518
+ afterRetainedBindingReconcile(outputs) {
2519
+ if (successorPath) return;
2520
+ const carried = outputs.find((output) => (
2521
+ output.provenance === 'carried-source'
2522
+ && output.ownerNodeId === nodeId
2523
+ && output.sourceRelativePath === ''
2524
+ && output.identity.kind === 'folder'
2525
+ ));
2526
+ assert.ok(carried);
2527
+ successorPath = carried.path;
2528
+ originalPath = `${carried.path}-moved`;
2529
+ fs.renameSync(carried.path, originalPath);
2530
+ fs.mkdirSync(carried.path);
2531
+ fs.writeFileSync(
2532
+ path.join(carried.path, 'replacement.txt'),
2533
+ 'replacement\n',
2534
+ );
2535
+ },
2536
+ afterRetainedBindingSettle() {
2537
+ throw new Error('simulated crash after retained folder settlement');
2538
+ },
2539
+ },
2540
+ }),
2541
+ /simulated crash after retained folder settlement/,
2542
+ );
2543
+
2544
+ assert.ok(openLocalDb().prepare(`
2545
+ SELECT 1 AS present
2546
+ FROM workspace_tree_operation_successors
2547
+ WHERE resource_id = ?
2548
+ LIMIT 1
2549
+ `).get(workspace.id));
2550
+ recoverAllTreePlans();
2551
+
2552
+ assert.equal(
2553
+ fs.readFileSync(path.join(originalPath, 'original.txt'), 'utf8'),
2554
+ 'original\n',
2555
+ );
2556
+ assert.equal(
2557
+ fs.readFileSync(path.join(successorPath, 'replacement.txt'), 'utf8'),
2558
+ 'replacement\n',
2559
+ );
2560
+ assert.equal(nodeForPath(originalPath).node.id, nodeId);
2561
+ assert.notEqual(nodeForPath(successorPath).node.id, nodeId);
2562
+ assert.equal(openLocalDb().prepare(`
2563
+ SELECT COUNT(*) AS count
2564
+ FROM workspace_tree_operation_successors
2565
+ WHERE resource_id = ?
2566
+ `).get(workspace.id).count, 0);
2567
+ assert.equal(openLocalDb().prepare(`
2568
+ SELECT COUNT(*) AS count
2569
+ FROM workspace_tree_operation_materializations
2570
+ WHERE resource_id = ?
2571
+ `).get(workspace.id).count, 0);
2572
+ });
2573
+
2574
+ function relocatedCreatedParentFixture(label) {
2575
+ const root = path.join(
2576
+ process.env.AMALGM_WORKSPACES_DIR,
2577
+ label,
2578
+ );
2579
+ const originalParent = path.join(root, 'P');
2580
+ const relocatedParent = path.join(root, 'Q');
2581
+ fs.mkdirSync(root);
2582
+ const workspace = upsertWorkspace({
2583
+ path: root,
2584
+ name: label,
2585
+ source: 'computer',
2586
+ });
2587
+ enrollWorkspace(workspace.id);
2588
+ const beforeNodes = listWorkspaceNodes(workspace.id, {
2589
+ includeTrashed: true,
2590
+ });
2591
+ const parentId = uuidV7();
2592
+ const childId = uuidV7();
2593
+ const now = new Date().toISOString();
2594
+ const parentNode = {
2595
+ id: parentId,
2596
+ workspaceId: workspace.id,
2597
+ parentId: workspace.id,
2598
+ kind: 'folder',
2599
+ name: 'P',
2600
+ symlinkTarget: null,
2601
+ trashedAt: null,
2602
+ purgedAt: null,
2603
+ createdAt: now,
2604
+ updatedAt: now,
2605
+ };
2606
+ const childNode = {
2607
+ id: childId,
2608
+ workspaceId: workspace.id,
2609
+ parentId,
2610
+ kind: 'file',
2611
+ name: 'child.txt',
2612
+ symlinkTarget: null,
2613
+ trashedAt: null,
2614
+ purgedAt: null,
2615
+ createdAt: now,
2616
+ updatedAt: now,
2617
+ };
2618
+ const rootIdentity = treeMaterialization.identity(root, { deep: false });
2619
+ const mutationId = `mut_${label.replaceAll('-', '_')}`;
2620
+ const plan = {
2621
+ contract: 'workspace-tree-operation-materialization@1',
2622
+ resourceId: workspace.id,
2623
+ mutationId,
2624
+ channelId: `resource:${workspace.id}`,
2625
+ version: 1,
2626
+ authorityEpoch: 1,
2627
+ rootPath: root,
2628
+ rootIdentity,
2629
+ controlRoots: [],
2630
+ rollbackBindings: [],
2631
+ operation: {
2632
+ kind: 'tree.create',
2633
+ workspaceId: workspace.id,
2634
+ nodeId: parentId,
2635
+ parentId: workspace.id,
2636
+ nodeKind: 'folder',
2637
+ name: 'P',
2638
+ createdAt: now,
2639
+ },
2640
+ beforeNodes,
2641
+ afterNodes: [...beforeNodes, parentNode, childNode],
2642
+ officialAfterNodes: null,
2643
+ actions: [
2644
+ {
2645
+ type: 'create',
2646
+ nodeId: parentId,
2647
+ target: originalParent,
2648
+ targetParentNodeId: workspace.id,
2649
+ targetParentIdentity: rootIdentity,
2650
+ kind: 'folder',
2651
+ symlinkTarget: null,
2652
+ },
2653
+ {
2654
+ type: 'create',
2655
+ nodeId: childId,
2656
+ target: path.join(originalParent, 'child.txt'),
2657
+ targetParentNodeId: parentId,
2658
+ targetParentIdentity: null,
2659
+ kind: 'file',
2660
+ symlinkTarget: null,
2661
+ },
2662
+ ],
2663
+ event: {
2664
+ resource: `workspace-tree:${workspace.id}`,
2665
+ op: 'update',
2666
+ id: parentId,
2667
+ patch: {},
2668
+ sharedResourceId: workspace.id,
2669
+ source: 'workspace-tree:test',
2670
+ },
2671
+ sourceRaceCaptured: true,
2672
+ };
2673
+ return {
2674
+ childId,
2675
+ mutationId,
2676
+ originalParent,
2677
+ parentId,
2678
+ plan,
2679
+ relocatedParent,
2680
+ workspace,
2681
+ };
2682
+ }
2683
+
2684
+ function createdSiblingFilesFixture(label) {
2685
+ const root = path.join(process.env.AMALGM_WORKSPACES_DIR, label);
2686
+ const firstPath = path.join(root, 'first.txt');
2687
+ const triggerPath = path.join(root, 'trigger.txt');
2688
+ fs.mkdirSync(root);
2689
+ const workspace = upsertWorkspace({
2690
+ path: root,
2691
+ name: label,
2692
+ source: 'computer',
2693
+ });
2694
+ enrollWorkspace(workspace.id);
2695
+ const beforeNodes = listWorkspaceNodes(workspace.id, {
2696
+ includeTrashed: true,
2697
+ });
2698
+ const firstId = uuidV7();
2699
+ const triggerId = uuidV7();
2700
+ const now = new Date().toISOString();
2701
+ const node = (id, name) => ({
2702
+ id,
2703
+ workspaceId: workspace.id,
2704
+ parentId: workspace.id,
2705
+ kind: 'file',
2706
+ name,
2707
+ symlinkTarget: null,
2708
+ trashedAt: null,
2709
+ purgedAt: null,
2710
+ createdAt: now,
2711
+ updatedAt: now,
2712
+ });
2713
+ const rootIdentity = treeMaterialization.identity(root, { deep: false });
2714
+ const mutationId = `mut_${label.replaceAll('-', '_')}`;
2715
+ return {
2716
+ firstId,
2717
+ firstPath,
2718
+ mutationId,
2719
+ plan: {
2720
+ contract: 'workspace-tree-operation-materialization@1',
2721
+ resourceId: workspace.id,
2722
+ mutationId,
2723
+ channelId: `resource:${workspace.id}`,
2724
+ version: 1,
2725
+ authorityEpoch: 1,
2726
+ rootPath: root,
2727
+ rootIdentity,
2728
+ controlRoots: [],
2729
+ rollbackBindings: [],
2730
+ operation: {
2731
+ kind: 'tree.create',
2732
+ workspaceId: workspace.id,
2733
+ nodeId: firstId,
2734
+ parentId: workspace.id,
2735
+ nodeKind: 'file',
2736
+ name: 'first.txt',
2737
+ createdAt: now,
2738
+ },
2739
+ beforeNodes,
2740
+ afterNodes: [
2741
+ ...beforeNodes,
2742
+ node(firstId, 'first.txt'),
2743
+ node(triggerId, 'trigger.txt'),
2744
+ ],
2745
+ officialAfterNodes: null,
2746
+ actions: [
2747
+ {
2748
+ type: 'create',
2749
+ nodeId: firstId,
2750
+ target: firstPath,
2751
+ targetParentNodeId: workspace.id,
2752
+ targetParentIdentity: rootIdentity,
2753
+ kind: 'file',
2754
+ symlinkTarget: null,
2755
+ },
2756
+ {
2757
+ type: 'create',
2758
+ nodeId: triggerId,
2759
+ target: triggerPath,
2760
+ targetParentNodeId: workspace.id,
2761
+ targetParentIdentity: rootIdentity,
2762
+ kind: 'file',
2763
+ symlinkTarget: null,
2764
+ },
2765
+ ],
2766
+ event: {
2767
+ resource: `workspace-tree:${workspace.id}`,
2768
+ op: 'update',
2769
+ id: firstId,
2770
+ patch: {},
2771
+ sharedResourceId: workspace.id,
2772
+ source: 'workspace-tree:test',
2773
+ },
2774
+ sourceRaceCaptured: true,
2775
+ },
2776
+ root,
2777
+ triggerId,
2778
+ triggerPath,
2779
+ workspace,
2780
+ };
2781
+ }
2782
+
2783
+ function multipleRelocatedParentsFixture(label, parentCount = 2) {
2784
+ const root = path.join(process.env.AMALGM_WORKSPACES_DIR, label);
2785
+ fs.mkdirSync(root);
2786
+ const workspace = upsertWorkspace({
2787
+ path: root,
2788
+ name: label,
2789
+ source: 'computer',
2790
+ });
2791
+ enrollWorkspace(workspace.id);
2792
+ const beforeNodes = listWorkspaceNodes(workspace.id, {
2793
+ includeTrashed: true,
2794
+ });
2795
+ const parentIds = Array.from({ length: parentCount }, () => uuidV7());
2796
+ const [firstId, secondId, thirdId = null] = parentIds;
2797
+ const childId = uuidV7();
2798
+ const now = new Date().toISOString();
2799
+ const node = (id, parentId, kind, name) => ({
2800
+ id,
2801
+ workspaceId: workspace.id,
2802
+ parentId,
2803
+ kind,
2804
+ name,
2805
+ symlinkTarget: null,
2806
+ trashedAt: null,
2807
+ purgedAt: null,
2808
+ createdAt: now,
2809
+ updatedAt: now,
2810
+ });
2811
+ const rootIdentity = treeMaterialization.identity(root, { deep: false });
2812
+ const mutationId = `mut_${label.replaceAll('-', '_')}`;
2813
+ const target = (name) => path.join(root, name);
2814
+ return {
2815
+ childId,
2816
+ firstId,
2817
+ mutationId,
2818
+ plan: {
2819
+ contract: 'workspace-tree-operation-materialization@1',
2820
+ resourceId: workspace.id,
2821
+ mutationId,
2822
+ channelId: `resource:${workspace.id}`,
2823
+ version: 1,
2824
+ authorityEpoch: 1,
2825
+ rootPath: root,
2826
+ rootIdentity,
2827
+ controlRoots: [],
2828
+ rollbackBindings: [],
2829
+ operation: {
2830
+ kind: 'tree.create',
2831
+ workspaceId: workspace.id,
2832
+ nodeId: firstId,
2833
+ parentId: workspace.id,
2834
+ nodeKind: 'folder',
2835
+ name: 'P1',
2836
+ createdAt: now,
2837
+ },
2838
+ beforeNodes,
2839
+ afterNodes: [
2840
+ ...beforeNodes,
2841
+ ...parentIds.map((id, index) => (
2842
+ node(id, workspace.id, 'folder', `P${index + 1}`)
2843
+ )),
2844
+ node(childId, secondId, 'file', 'child.txt'),
2845
+ ],
2846
+ officialAfterNodes: null,
2847
+ actions: [
2848
+ ...parentIds.map((id, index) => ({
2849
+ type: 'create',
2850
+ nodeId: id,
2851
+ target: target(`P${index + 1}`),
2852
+ targetParentNodeId: workspace.id,
2853
+ targetParentIdentity: rootIdentity,
2854
+ kind: 'folder',
2855
+ symlinkTarget: null,
2856
+ })),
2857
+ {
2858
+ type: 'create',
2859
+ nodeId: childId,
2860
+ target: path.join(target('P2'), 'child.txt'),
2861
+ targetParentNodeId: secondId,
2862
+ targetParentIdentity: null,
2863
+ kind: 'file',
2864
+ symlinkTarget: null,
2865
+ },
2866
+ ],
2867
+ event: {
2868
+ resource: `workspace-tree:${workspace.id}`,
2869
+ op: 'update',
2870
+ id: firstId,
2871
+ patch: {},
2872
+ sharedResourceId: workspace.id,
2873
+ source: 'workspace-tree:test',
2874
+ },
2875
+ sourceRaceCaptured: true,
2876
+ },
2877
+ parentIds,
2878
+ root,
2879
+ secondId,
2880
+ thirdId,
2881
+ workspace,
2882
+ };
2883
+ }
2884
+
2885
+ test('restart finds a receipt-owned create that moved before parent validation', () => {
2886
+ const {
2887
+ childId,
2888
+ mutationId,
2889
+ originalParent,
2890
+ parentId,
2891
+ plan,
2892
+ relocatedParent,
2893
+ workspace,
2894
+ } = relocatedCreatedParentFixture('relocated-create-before-validation');
2895
+ let held = treeMaterialization.insertOperation(plan);
2896
+ assert.throws(
2897
+ () => crossTreeOperationWithRaceCapture(held, openLocalDb(), {
2898
+ afterOperationCreatePrepared(action) {
2899
+ if (
2900
+ action.nodeId === childId
2901
+ && fs.existsSync(originalParent)
2902
+ ) {
2903
+ fs.renameSync(originalParent, relocatedParent);
2904
+ throw new Error('simulated crash immediately after parent relocation');
2905
+ }
2906
+ },
2907
+ }),
2908
+ /simulated crash immediately after parent relocation/,
2909
+ );
2910
+ held = treeMaterialization.getOperation(workspace.id, mutationId);
2911
+ assert.equal(held.plan.carriedCreates, undefined);
2912
+ assert.throws(
2913
+ () => crossTreeOperationWithRaceCapture(held, openLocalDb(), {
2914
+ afterOperationCarriedCreateCapture() {
2915
+ throw new Error('recovery found relocated parent receipt');
2916
+ },
2917
+ }),
2918
+ /recovery found relocated parent receipt/,
2919
+ );
2920
+ held = treeMaterialization.getOperation(workspace.id, mutationId);
2921
+ assert.equal(held.plan.carriedCreates[0].nodeId, parentId);
2922
+ assert.equal(held.plan.carriedCreates[0].path, relocatedParent);
2923
+
2924
+ held = crossTreeOperationWithRaceCapture(held, openLocalDb());
2925
+ projectStoredTreeOperation(held, openLocalDb());
2926
+ held = treeMaterialization.markOperationCommitted(held);
2927
+ finishTreeOperationPlan(held, openLocalDb());
2928
+ assert.equal(nodeForPath(relocatedParent).node.id, parentId);
2929
+ assert.equal(
2930
+ nodeForPath(path.join(relocatedParent, 'child.txt')).node.id,
2931
+ childId,
2932
+ );
2933
+ });
2934
+
2935
+ test('restart preserves deletion of a plan-created parent before child validation', () => {
2936
+ const {
2937
+ childId,
2938
+ mutationId,
2939
+ originalParent,
2940
+ parentId,
2941
+ plan,
2942
+ workspace,
2943
+ } = relocatedCreatedParentFixture('deleted-create-before-validation');
2944
+ let held = treeMaterialization.insertOperation(plan);
2945
+ assert.throws(
2946
+ () => crossTreeOperationWithRaceCapture(held, openLocalDb(), {
2947
+ afterOperationCreatePrepared(action) {
2948
+ if (
2949
+ action.nodeId === childId
2950
+ && fs.existsSync(originalParent)
2951
+ ) {
2952
+ fs.rmSync(originalParent, { recursive: true });
2953
+ throw new Error('simulated crash immediately after parent deletion');
2954
+ }
2955
+ },
2956
+ }),
2957
+ /simulated crash immediately after parent deletion/,
2958
+ );
2959
+ held = treeMaterialization.getOperation(workspace.id, mutationId);
2960
+ assert.equal(held.plan.carriedCreateDeletions, undefined);
2961
+ assert.throws(
2962
+ () => crossTreeOperationWithRaceCapture(held, openLocalDb(), {
2963
+ afterOperationCarriedCreateDeletionCapture() {
2964
+ throw new Error('recovery captured deleted parent receipt');
2965
+ },
2966
+ }),
2967
+ /recovery captured deleted parent receipt/,
2968
+ );
2969
+ held = treeMaterialization.getOperation(workspace.id, mutationId);
2970
+ assert.equal(held.plan.carriedCreateDeletions[0].nodeId, parentId);
2971
+
2972
+ assert.throws(
2973
+ () => crossTreeOperationWithRaceCapture(held, openLocalDb(), {
2974
+ afterCarriedSuccessorBirth(operations) {
2975
+ assert.deepEqual(
2976
+ operations.map((operation) => operation.kind),
2977
+ ['tree.trash'],
2978
+ );
2979
+ throw new Error('simulated crash after carried trash birth');
2980
+ },
2981
+ }),
2982
+ /simulated crash after carried trash birth/,
2983
+ );
2984
+ const afterTrashBirth = listWorkspaceNodes(workspace.id, {
2985
+ includeTrashed: true,
2986
+ includeMissing: true,
2987
+ includePurged: true,
2988
+ });
2989
+ assert.ok(
2990
+ afterTrashBirth.find((node) => node.id === parentId)?.trashedAt,
2991
+ JSON.stringify(afterTrashBirth),
2992
+ );
2993
+ assert.equal(openLocalDb().prepare(`
2994
+ SELECT COUNT(*) AS count
2995
+ FROM mutation_journal
2996
+ WHERE local_resource = ?
2997
+ AND operation_kind = 'tree.trash'
2998
+ AND json_extract(operation_json, '$.nodeId') = ?
2999
+ `).get(`workspace-tree:${workspace.id}`, parentId).count, 1);
3000
+ held = treeMaterialization.getOperation(workspace.id, mutationId);
3001
+ held = crossTreeOperationWithRaceCapture(held, openLocalDb());
3002
+ projectStoredTreeOperation(held, openLocalDb());
3003
+ assert.equal(fs.existsSync(originalParent), false);
3004
+ const nodes = listWorkspaceNodes(workspace.id, {
3005
+ includeTrashed: true,
3006
+ });
3007
+ assert.ok(nodes.find((node) => node.id === parentId)?.trashedAt);
3008
+ assert.ok(nodes.find((node) => node.id === childId)?.trashedAt);
3009
+ assert.equal(openLocalDb().prepare(`
3010
+ SELECT COUNT(*) AS count
3011
+ FROM mutation_journal
3012
+ WHERE local_resource = ?
3013
+ AND operation_kind = 'tree.trash'
3014
+ AND json_extract(operation_json, '$.nodeId') = ?
3015
+ `).get(`workspace-tree:${workspace.id}`, parentId).count, 1);
3016
+ held = treeMaterialization.markOperationCommitted(held);
3017
+ finishTreeOperationPlan(held, openLocalDb());
3018
+ });
3019
+
3020
+ test('restart gives a replacement of a plan-created folder a fresh UUID', () => {
3021
+ const {
3022
+ childId,
3023
+ mutationId,
3024
+ originalParent,
3025
+ parentId,
3026
+ plan,
3027
+ workspace,
3028
+ } = relocatedCreatedParentFixture('replaced-create-before-validation');
3029
+ const replacementFile = path.join(originalParent, 'local.txt');
3030
+ let held = treeMaterialization.insertOperation(plan);
3031
+ assert.throws(
3032
+ () => crossTreeOperationWithRaceCapture(held, openLocalDb(), {
3033
+ afterOperationCreatePrepared(action) {
3034
+ if (
3035
+ action.nodeId === childId
3036
+ && fs.existsSync(originalParent)
3037
+ ) {
3038
+ fs.rmSync(originalParent, { recursive: true });
3039
+ fs.mkdirSync(originalParent);
3040
+ fs.writeFileSync(replacementFile, 'local replacement\n');
3041
+ throw new Error('simulated crash after created-folder replacement');
3042
+ }
3043
+ },
3044
+ }),
3045
+ /simulated crash after created-folder replacement/,
3046
+ );
3047
+ held = treeMaterialization.getOperation(workspace.id, mutationId);
3048
+ assert.throws(
3049
+ () => crossTreeOperationWithRaceCapture(held, openLocalDb(), {
3050
+ afterOperationCarriedCreateDeletionCapture() {
3051
+ throw new Error('recovery classified replaced created folder');
3052
+ },
3053
+ }),
3054
+ /recovery classified replaced created folder/,
3055
+ );
3056
+ held = treeMaterialization.getOperation(workspace.id, mutationId);
3057
+ held = crossTreeOperationWithRaceCapture(held, openLocalDb());
3058
+ projectStoredTreeOperation(held, openLocalDb());
3059
+
3060
+ const replacement = nodeForPath(originalParent).node;
3061
+ assert.notEqual(replacement.id, parentId);
3062
+ assert.equal(fs.readFileSync(replacementFile, 'utf8'), 'local replacement\n');
3063
+ assert.equal(nodeForPath(replacementFile).node.parentId, replacement.id);
3064
+ const nodes = listWorkspaceNodes(workspace.id, { includeTrashed: true });
3065
+ assert.ok(nodes.find((node) => node.id === parentId)?.trashedAt);
3066
+ assert.ok(nodes.find((node) => node.id === childId)?.trashedAt);
3067
+ held = treeMaterialization.markOperationCommitted(held);
3068
+ finishTreeOperationPlan(held, openLocalDb());
3069
+ });
3070
+
3071
+ test('restart keeps a same-path file successor on its plan-created UUID', () => {
3072
+ const {
3073
+ firstId,
3074
+ firstPath,
3075
+ mutationId,
3076
+ plan,
3077
+ root,
3078
+ triggerId,
3079
+ triggerPath,
3080
+ workspace,
3081
+ } = createdSiblingFilesFixture('replaced-created-file');
3082
+ let held = treeMaterialization.insertOperation(plan);
3083
+ assert.throws(
3084
+ () => crossTreeOperationWithRaceCapture(held, openLocalDb(), {
3085
+ afterOperationCreatePrepared(action) {
3086
+ if (action.nodeId === triggerId && fs.existsSync(firstPath)) {
3087
+ const successor = path.join(root, 'successor.tmp');
3088
+ fs.writeFileSync(successor, 'newer local bytes\n');
3089
+ fs.renameSync(successor, firstPath);
3090
+ throw new Error('simulated crash after created-file replacement');
3091
+ }
3092
+ },
3093
+ }),
3094
+ /simulated crash after created-file replacement/,
3095
+ );
3096
+ held = treeMaterialization.getOperation(workspace.id, mutationId);
3097
+ assert.throws(
3098
+ () => crossTreeOperationWithRaceCapture(held, openLocalDb(), {
3099
+ afterOperationCarriedCreateCapture(bindings) {
3100
+ assert.equal(bindings.find((binding) => binding.nodeId === firstId)?.path, firstPath);
3101
+ throw new Error('recovery captured same-path file successor');
3102
+ },
3103
+ }),
3104
+ /recovery captured same-path file successor/,
3105
+ );
3106
+ held = treeMaterialization.getOperation(workspace.id, mutationId);
3107
+ const latestSuccessor = path.join(root, 'latest-successor.tmp');
3108
+ fs.writeFileSync(latestSuccessor, 'latest local bytes\n');
3109
+ fs.renameSync(latestSuccessor, firstPath);
3110
+ assert.throws(
3111
+ () => crossTreeOperationWithRaceCapture(held, openLocalDb(), {
3112
+ afterCarriedCreateRefresh(bindings) {
3113
+ assert.equal(
3114
+ bindings.find((binding) => binding.nodeId === firstId)?.identity.ino,
3115
+ treeMaterialization.identity(firstPath, { deep: false }).ino,
3116
+ );
3117
+ throw new Error('simulated crash after carried file refresh');
3118
+ },
3119
+ }),
3120
+ /simulated crash after carried file refresh/,
3121
+ );
3122
+ held = treeMaterialization.getOperation(workspace.id, mutationId);
3123
+ held = crossTreeOperationWithRaceCapture(held, openLocalDb());
3124
+ projectStoredTreeOperation(held, openLocalDb());
3125
+
3126
+ assert.equal(nodeForPath(firstPath).node.id, firstId);
3127
+ assert.equal(nodeForPath(triggerPath).node.id, triggerId);
3128
+ assert.equal(fs.readFileSync(firstPath, 'utf8'), 'latest local bytes\n');
3129
+ assert.equal(docs.openDoc(firstPath).resource, `doc:${firstId}`);
3130
+ assert.equal(openLocalDb().prepare(`
3131
+ SELECT COUNT(*) AS count
3132
+ FROM mutation_journal
3133
+ WHERE local_resource = ?
3134
+ AND json_extract(operation_json, '$.nodeId') = ?
3135
+ AND operation_kind IN ('tree.create', 'tree.trash')
3136
+ `).get(`workspace-tree:${workspace.id}`, firstId).count, 0);
3137
+ held = treeMaterialization.markOperationCommitted(held);
3138
+ finishTreeOperationPlan(held, openLocalDb());
3139
+ });
3140
+
3141
+ test('multiple carried successors keep durable sequence IDs across path reordering', () => {
3142
+ const {
3143
+ childId,
3144
+ firstId,
3145
+ mutationId,
3146
+ plan,
3147
+ root,
3148
+ secondId,
3149
+ workspace,
3150
+ } = multipleRelocatedParentsFixture('multiple-carried-successors');
3151
+ const pathFor = (name) => path.join(root, name);
3152
+ let held = treeMaterialization.insertOperation(plan);
3153
+ assert.throws(
3154
+ () => crossTreeOperationWithRaceCapture(held, openLocalDb(), {
3155
+ afterOperationCreatePrepared(action) {
3156
+ if (
3157
+ action.nodeId === childId
3158
+ && fs.existsSync(pathFor('P1'))
3159
+ && fs.existsSync(pathFor('P2'))
3160
+ ) {
3161
+ fs.renameSync(pathFor('P1'), pathFor('Q1'));
3162
+ fs.renameSync(pathFor('P2'), pathFor('Q2'));
3163
+ }
3164
+ },
3165
+ afterOperationCarriedCreateCapture(bindings) {
3166
+ assert.equal(bindings.length, 2);
3167
+ throw new Error('simulated crash after two carried creates');
3168
+ },
3169
+ }),
3170
+ /simulated crash after two carried creates/,
3171
+ );
3172
+ held = treeMaterialization.getOperation(workspace.id, mutationId);
3173
+ assert.throws(
3174
+ () => crossTreeOperationWithRaceCapture(held, openLocalDb(), {
3175
+ afterCarriedSuccessorBirth(operations) {
3176
+ assert.deepEqual(
3177
+ operations.map((operation) => operation.kind),
3178
+ ['tree.rename', 'tree.rename'],
3179
+ );
3180
+ throw new Error('simulated crash after two successor births');
3181
+ },
3182
+ }),
3183
+ /simulated crash after two successor births/,
3184
+ );
3185
+ assert.equal(openLocalDb().prepare(`
3186
+ SELECT COUNT(*) AS count
3187
+ FROM mutation_journal
3188
+ WHERE local_resource = ? AND operation_kind = 'tree.rename'
3189
+ `).get(`workspace-tree:${workspace.id}`).count, 2);
3190
+
3191
+ held = treeMaterialization.getOperation(workspace.id, mutationId);
3192
+ fs.renameSync(pathFor('Q1'), pathFor('Z'));
3193
+ assert.throws(
3194
+ () => crossTreeOperationWithRaceCapture(held, openLocalDb(), {
3195
+ afterCarriedSuccessorBirth(operations) {
3196
+ assert.deepEqual(
3197
+ operations.map((operation) => operation.name),
3198
+ ['Q1', 'Q2', 'Z'],
3199
+ );
3200
+ throw new Error('simulated crash after reordered successor birth');
3201
+ },
3202
+ }),
3203
+ /simulated crash after reordered successor birth/,
3204
+ );
3205
+ assert.equal(openLocalDb().prepare(`
3206
+ SELECT COUNT(*) AS count
3207
+ FROM mutation_journal
3208
+ WHERE local_resource = ? AND operation_kind = 'tree.rename'
3209
+ `).get(`workspace-tree:${workspace.id}`).count, 3);
3210
+ assert.equal(openLocalDb().prepare(`
3211
+ SELECT COUNT(*) AS count
3212
+ FROM mutation_journal
3213
+ WHERE local_resource = ?
3214
+ AND operation_kind = 'tree.rename'
3215
+ AND json_extract(operation_json, '$.nodeId') = ?
3216
+ `).get(`workspace-tree:${workspace.id}`, secondId).count, 1);
3217
+
3218
+ held = treeMaterialization.getOperation(workspace.id, mutationId);
3219
+ held = crossTreeOperationWithRaceCapture(held, openLocalDb());
3220
+ projectStoredTreeOperation(held, openLocalDb());
3221
+ assert.equal(nodeForPath(pathFor('Z')).node.id, firstId);
3222
+ assert.equal(nodeForPath(pathFor('Q2')).node.id, secondId);
3223
+ assert.equal(
3224
+ nodeForPath(path.join(pathFor('Q2'), 'child.txt')).node.id,
3225
+ childId,
3226
+ );
3227
+ held = treeMaterialization.markOperationCommitted(held);
3228
+ finishTreeOperationPlan(held, openLocalDb());
3229
+ });
3230
+
3231
+ test('a two-node carried path swap is planned durably before its births', () => {
3232
+ const {
3233
+ childId,
3234
+ firstId,
3235
+ mutationId,
3236
+ plan,
3237
+ root,
3238
+ secondId,
3239
+ workspace,
3240
+ } = multipleRelocatedParentsFixture('two-node-carried-swap');
3241
+ const pathFor = (name) => path.join(root, name);
3242
+ let held = treeMaterialization.insertOperation(plan);
3243
+ assert.throws(
3244
+ () => crossTreeOperationWithRaceCapture(held, openLocalDb(), {
3245
+ afterOperationCreatePrepared(action) {
3246
+ if (
3247
+ action.nodeId === childId
3248
+ && fs.existsSync(pathFor('P1'))
3249
+ && fs.existsSync(pathFor('P2'))
3250
+ ) {
3251
+ fs.renameSync(pathFor('P1'), pathFor('swap.tmp'));
3252
+ fs.renameSync(pathFor('P2'), pathFor('P1'));
3253
+ fs.renameSync(pathFor('swap.tmp'), pathFor('P2'));
3254
+ }
3255
+ },
3256
+ afterOperationCarriedCreateCapture(bindings) {
3257
+ assert.equal(bindings.length, 2);
3258
+ throw new Error('simulated crash after swapped creates were captured');
3259
+ },
3260
+ }),
3261
+ /simulated crash after swapped creates were captured/,
3262
+ );
3263
+
3264
+ held = treeMaterialization.getOperation(workspace.id, mutationId);
3265
+ assert.throws(
3266
+ () => crossTreeOperationWithRaceCapture(held, openLocalDb(), {
3267
+ afterCarriedSuccessorPlan(operations) {
3268
+ assert.equal(operations.length, 4);
3269
+ assert.equal(
3270
+ operations.filter((operation) => (
3271
+ operation.name?.startsWith('amalgm-relocation-')
3272
+ )).length,
3273
+ 2,
3274
+ );
3275
+ assert.deepEqual(
3276
+ operations.slice(-2).map((operation) => operation.name),
3277
+ ['P1', 'P2'],
3278
+ );
3279
+ throw new Error('simulated crash after durable swap plan');
3280
+ },
3281
+ }),
3282
+ /simulated crash after durable swap plan/,
3283
+ );
3284
+ assert.equal(openLocalDb().prepare(`
3285
+ SELECT COUNT(*) AS count
3286
+ FROM mutation_journal
3287
+ WHERE local_resource = ? AND operation_kind = 'tree.rename'
3288
+ `).get(`workspace-tree:${workspace.id}`).count, 0);
3289
+
3290
+ held = treeMaterialization.getOperation(workspace.id, mutationId);
3291
+ held = crossTreeOperationWithRaceCapture(held, openLocalDb());
3292
+ projectStoredTreeOperation(held, openLocalDb());
3293
+ assert.equal(nodeForPath(pathFor('P1')).node.id, secondId);
3294
+ assert.equal(nodeForPath(pathFor('P2')).node.id, firstId);
3295
+ assert.equal(
3296
+ nodeForPath(path.join(pathFor('P1'), 'child.txt')).node.id,
3297
+ childId,
3298
+ );
3299
+ assert.equal(openLocalDb().prepare(`
3300
+ SELECT COUNT(*) AS count
3301
+ FROM mutation_journal
3302
+ WHERE local_resource = ? AND operation_kind = 'tree.rename'
3303
+ `).get(`workspace-tree:${workspace.id}`).count, 4);
3304
+ held = treeMaterialization.markOperationCommitted(held);
3305
+ finishTreeOperationPlan(held, openLocalDb());
3306
+ });
3307
+
3308
+ test('a three-node carried path cycle converges as one durable permutation', () => {
3309
+ const {
3310
+ childId,
3311
+ firstId,
3312
+ mutationId,
3313
+ plan,
3314
+ root,
3315
+ secondId,
3316
+ thirdId,
3317
+ workspace,
3318
+ } = multipleRelocatedParentsFixture('three-node-carried-cycle', 3);
3319
+ const pathFor = (name) => path.join(root, name);
3320
+ let held = treeMaterialization.insertOperation(plan);
3321
+ assert.throws(
3322
+ () => crossTreeOperationWithRaceCapture(held, openLocalDb(), {
3323
+ afterOperationCreatePrepared(action) {
3324
+ if (
3325
+ action.nodeId === childId
3326
+ && ['P1', 'P2', 'P3'].every((name) => fs.existsSync(pathFor(name)))
3327
+ ) {
3328
+ fs.renameSync(pathFor('P1'), pathFor('cycle.tmp'));
3329
+ fs.renameSync(pathFor('P3'), pathFor('P1'));
3330
+ fs.renameSync(pathFor('P2'), pathFor('P3'));
3331
+ fs.renameSync(pathFor('cycle.tmp'), pathFor('P2'));
3332
+ }
3333
+ },
3334
+ afterOperationCarriedCreateCapture(bindings) {
3335
+ assert.equal(bindings.length, 3);
3336
+ throw new Error('simulated crash after cyclic creates were captured');
3337
+ },
3338
+ }),
3339
+ /simulated crash after cyclic creates were captured/,
3340
+ );
3341
+
3342
+ held = treeMaterialization.getOperation(workspace.id, mutationId);
3343
+ assert.throws(
3344
+ () => crossTreeOperationWithRaceCapture(held, openLocalDb(), {
3345
+ afterCarriedSuccessorBirth(operations) {
3346
+ assert.equal(operations.length, 6);
3347
+ assert.equal(
3348
+ operations.filter((operation) => (
3349
+ operation.name?.startsWith('amalgm-relocation-')
3350
+ )).length,
3351
+ 3,
3352
+ );
3353
+ throw new Error('simulated crash after cyclic successor births');
3354
+ },
3355
+ }),
3356
+ /simulated crash after cyclic successor births/,
3357
+ );
3358
+
3359
+ held = treeMaterialization.getOperation(workspace.id, mutationId);
3360
+ held = crossTreeOperationWithRaceCapture(held, openLocalDb());
3361
+ projectStoredTreeOperation(held, openLocalDb());
3362
+ assert.equal(nodeForPath(pathFor('P1')).node.id, thirdId);
3363
+ assert.equal(nodeForPath(pathFor('P2')).node.id, firstId);
3364
+ assert.equal(nodeForPath(pathFor('P3')).node.id, secondId);
3365
+ assert.equal(
3366
+ nodeForPath(path.join(pathFor('P3'), 'child.txt')).node.id,
3367
+ childId,
3368
+ );
3369
+ assert.equal(openLocalDb().prepare(`
3370
+ SELECT COUNT(*) AS count
3371
+ FROM mutation_journal
3372
+ WHERE local_resource = ? AND operation_kind = 'tree.rename'
3373
+ `).get(`workspace-tree:${workspace.id}`).count, 6);
3374
+ held = treeMaterialization.markOperationCommitted(held);
3375
+ finishTreeOperationPlan(held, openLocalDb());
3376
+ });
3377
+
3378
+ test('a relocated plan-created parent preserves its rename-then-delete successor chain', () => {
3379
+ const {
3380
+ childId,
3381
+ mutationId,
3382
+ originalParent,
3383
+ parentId,
3384
+ plan,
3385
+ relocatedParent,
3386
+ workspace,
3387
+ } = relocatedCreatedParentFixture('relocated-created-parent');
3388
+ let held = treeMaterialization.insertOperation(plan);
3389
+ assert.throws(
3390
+ () => crossTreeOperationWithRaceCapture(held, openLocalDb(), {
3391
+ afterOperationCreatePrepared(action) {
3392
+ if (
3393
+ action.nodeId === childId
3394
+ && fs.existsSync(originalParent)
3395
+ ) {
3396
+ fs.renameSync(originalParent, relocatedParent);
3397
+ }
3398
+ },
3399
+ afterOperationCarriedCreateCapture() {
3400
+ throw new Error('simulated crash after carried create capture');
3401
+ },
3402
+ }),
3403
+ /simulated crash after carried create capture/,
3404
+ );
3405
+ held = treeMaterialization.getOperation(workspace.id, mutationId);
3406
+ assert.equal(held.plan.carriedCreates[0].nodeId, parentId);
3407
+ assert.equal(fs.existsSync(relocatedParent), true);
3408
+
3409
+ const identity = require('../workspace/identity');
3410
+ const previousUserId = identity.userId;
3411
+ const memberId = 'member-relocated-created-parent';
3412
+ openLocalDb().prepare(`
3413
+ INSERT INTO workspace_node_permissions(workspace_id, node_id, user_id, role)
3414
+ VALUES (?, ?, ?, 'viewer')
3415
+ `).run(workspace.id, workspace.id, memberId);
3416
+ identity.userId = () => memberId;
3417
+ try {
3418
+ const assertPermissionHeld = () => assert.throws(
3419
+ () => crossTreeOperationWithRaceCapture(held, openLocalDb()),
3420
+ (error) => (
3421
+ error.code === 'WORKSPACE_TREE_RETRY_PENDING'
3422
+ && error.reason === 'forbidden'
3423
+ && error.statusCode === 409
3424
+ ),
3425
+ );
3426
+ assertPermissionHeld();
3427
+ held = treeMaterialization.getOperation(workspace.id, mutationId);
3428
+ assert.equal(held.plan.permissionHold.reason, 'forbidden');
3429
+ assert.equal(held.plan.permissionHold.operations.length, 1);
3430
+ assert.equal(held.plan.permissionHold.operations[0].kind, 'tree.rename');
3431
+ assert.equal(nodeForPath(relocatedParent).node.id, parentId);
3432
+ assert.equal(openLocalDb().prepare(`
3433
+ SELECT COUNT(*) AS count
3434
+ FROM mutation_journal
3435
+ WHERE local_resource = ?
3436
+ AND operation_kind = 'tree.rename'
3437
+ AND json_extract(operation_json, '$.nodeId') = ?
3438
+ `).get(`workspace-tree:${workspace.id}`, parentId).count, 0);
3439
+ assertPermissionHeld();
3440
+ assert.equal(
3441
+ treeMaterialization.listOperations(openLocalDb(), workspace.id).length,
3442
+ 1,
3443
+ 'permission denial keeps one durable recovery owner',
3444
+ );
3445
+
3446
+ openLocalDb().prepare(`
3447
+ UPDATE workspace_node_permissions SET role = 'editor'
3448
+ WHERE workspace_id = ? AND node_id = ? AND user_id = ?
3449
+ `).run(workspace.id, workspace.id, memberId);
3450
+ assert.throws(
3451
+ () => crossTreeOperationWithRaceCapture(held, openLocalDb(), {
3452
+ afterCarriedSuccessorBirth() {
3453
+ throw new Error('simulated crash after carried successor birth');
3454
+ },
3455
+ }),
3456
+ /simulated crash after carried successor birth/,
3457
+ );
3458
+ assert.equal(openLocalDb().prepare(`
3459
+ SELECT COUNT(*) AS count
3460
+ FROM mutation_journal
3461
+ WHERE local_resource = ?
3462
+ AND operation_kind = 'tree.rename'
3463
+ AND json_extract(operation_json, '$.nodeId') = ?
3464
+ `).get(`workspace-tree:${workspace.id}`, parentId).count, 1);
3465
+
3466
+ held = treeMaterialization.getOperation(workspace.id, mutationId);
3467
+ fs.rmSync(relocatedParent, { recursive: true });
3468
+ assert.throws(
3469
+ () => crossTreeOperationWithRaceCapture(held, openLocalDb(), {
3470
+ afterCarriedSuccessorBirth(operations) {
3471
+ assert.deepEqual(
3472
+ operations.map((operation) => operation.kind),
3473
+ ['tree.rename', 'tree.trash'],
3474
+ );
3475
+ throw new Error('simulated crash after carried delete successor birth');
3476
+ },
3477
+ }),
3478
+ /simulated crash after carried delete successor birth/,
3479
+ );
3480
+ held = treeMaterialization.getOperation(workspace.id, mutationId);
3481
+ held = crossTreeOperationWithRaceCapture(held, openLocalDb());
3482
+ projectStoredTreeOperation(held, openLocalDb());
3483
+
3484
+ assert.equal(fs.existsSync(originalParent), false);
3485
+ assert.equal(fs.existsSync(relocatedParent), false);
3486
+ const nodes = listWorkspaceNodes(workspace.id, {
3487
+ includeTrashed: true,
3488
+ });
3489
+ assert.ok(nodes.find((node) => node.id === parentId)?.trashedAt);
3490
+ assert.ok(nodes.find((node) => node.id === childId)?.trashedAt);
3491
+ assert.equal(openLocalDb().prepare(`
3492
+ SELECT COUNT(*) AS count
3493
+ FROM mutation_journal
3494
+ WHERE local_resource = ?
3495
+ AND operation_kind = 'tree.rename'
3496
+ AND json_extract(operation_json, '$.nodeId') = ?
3497
+ `).get(`workspace-tree:${workspace.id}`, parentId).count, 1);
3498
+ assert.equal(openLocalDb().prepare(`
3499
+ SELECT COUNT(*) AS count
3500
+ FROM mutation_journal
3501
+ WHERE local_resource = ?
3502
+ AND operation_kind = 'tree.trash'
3503
+ AND json_extract(operation_json, '$.nodeId') = ?
3504
+ `).get(`workspace-tree:${workspace.id}`, parentId).count, 1);
3505
+
3506
+ held = treeMaterialization.markOperationCommitted(held);
3507
+ finishTreeOperationPlan(held, openLocalDb());
3508
+ } finally {
3509
+ identity.userId = previousUserId;
3510
+ }
3511
+ });