amalgm 0.1.245 → 0.1.246

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 (157) hide show
  1. package/README.md +12 -0
  2. package/lib/cli.js +146 -8
  3. package/lib/layout.js +89 -4
  4. package/lib/shared-realtime-tunnel.js +384 -43
  5. package/lib/supervisor.js +0 -3
  6. package/package.json +2 -2
  7. package/runtime/scripts/amalgm-mcp/adapters/contract.js +197 -0
  8. package/runtime/scripts/amalgm-mcp/adapters/filesystem.js +135 -0
  9. package/runtime/scripts/amalgm-mcp/adapters/git.js +84 -0
  10. package/runtime/scripts/amalgm-mcp/adapters/index.js +43 -0
  11. package/runtime/scripts/amalgm-mcp/adapters/reference.js +31 -0
  12. package/runtime/scripts/amalgm-mcp/adapters/truth.js +19 -0
  13. package/runtime/scripts/amalgm-mcp/browser/cookie-jar.js +20 -68
  14. package/runtime/scripts/amalgm-mcp/index.js +4 -0
  15. package/runtime/scripts/amalgm-mcp/lib/layout.js +89 -4
  16. package/runtime/scripts/amalgm-mcp/observer/README.md +259 -0
  17. package/runtime/scripts/amalgm-mcp/observer/apply.js +150 -0
  18. package/runtime/scripts/amalgm-mcp/observer/continuity.js +111 -0
  19. package/runtime/scripts/amalgm-mcp/observer/edges.js +111 -0
  20. package/runtime/scripts/amalgm-mcp/observer/index.js +1146 -0
  21. package/runtime/scripts/amalgm-mcp/observer/scan.js +387 -0
  22. package/runtime/scripts/amalgm-mcp/observer/store.js +262 -0
  23. package/runtime/scripts/amalgm-mcp/observer/verify.js +200 -0
  24. package/runtime/scripts/amalgm-mcp/observer/watch.js +62 -0
  25. package/runtime/scripts/amalgm-mcp/project-context/store.js +19 -87
  26. package/runtime/scripts/amalgm-mcp/registration/classify.js +33 -0
  27. package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +456 -0
  28. package/runtime/scripts/amalgm-mcp/registration/entity-content.js +345 -0
  29. package/runtime/scripts/amalgm-mcp/registration/index.js +752 -0
  30. package/runtime/scripts/amalgm-mcp/registration/refusal.js +42 -0
  31. package/runtime/scripts/amalgm-mcp/registration/repo-followers.js +127 -0
  32. package/runtime/scripts/amalgm-mcp/registration/repo-states.js +109 -0
  33. package/runtime/scripts/amalgm-mcp/registration/service.js +310 -0
  34. package/runtime/scripts/amalgm-mcp/registration/tree.js +178 -0
  35. package/runtime/scripts/amalgm-mcp/registry/evidence.js +826 -0
  36. package/runtime/scripts/amalgm-mcp/registry/index.js +666 -0
  37. package/runtime/scripts/amalgm-mcp/registry/store.js +290 -0
  38. package/runtime/scripts/amalgm-mcp/repocard/README.md +103 -0
  39. package/runtime/scripts/amalgm-mcp/repocard/apply.js +111 -0
  40. package/runtime/scripts/amalgm-mcp/repocard/capture-worker.js +94 -0
  41. package/runtime/scripts/amalgm-mcp/repocard/capture.js +149 -0
  42. package/runtime/scripts/amalgm-mcp/repocard/follow.js +310 -0
  43. package/runtime/scripts/amalgm-mcp/repocard/git.js +48 -0
  44. package/runtime/scripts/amalgm-mcp/repocard/index.js +48 -0
  45. package/runtime/scripts/amalgm-mcp/server/local-service-router.js +2 -0
  46. package/runtime/scripts/amalgm-mcp/server/routes/entities.js +287 -0
  47. package/runtime/scripts/amalgm-mcp/server/routes/state.js +0 -13
  48. package/runtime/scripts/amalgm-mcp/state/attachments.js +11 -45
  49. package/runtime/scripts/amalgm-mcp/state/db.js +46 -215
  50. package/runtime/scripts/amalgm-mcp/state/docs.js +301 -1101
  51. package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +0 -99
  52. package/runtime/scripts/amalgm-mcp/state/mutations.js +130 -507
  53. package/runtime/scripts/amalgm-mcp/state/promotions.js +1 -21
  54. package/runtime/scripts/amalgm-mcp/state/replicas.js +13 -134
  55. package/runtime/scripts/amalgm-mcp/state/shared-rest.js +3 -46
  56. package/runtime/scripts/amalgm-mcp/tests/adapters.test.js +379 -0
  57. package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +0 -193
  58. package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.life.test.js +449 -0
  59. package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.rig.js +107 -0
  60. package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.watch.test.js +213 -0
  61. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/adapter.js +72 -0
  62. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/assert.js +497 -0
  63. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/bind.js +91 -0
  64. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/graph.js +1031 -0
  65. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/ledger.js +234 -0
  66. package/runtime/scripts/amalgm-mcp/tests/entity-oracle/referee.js +118 -0
  67. package/runtime/scripts/amalgm-mcp/tests/entity.oracle.test.js +1365 -0
  68. package/runtime/scripts/amalgm-mcp/tests/entity.registry.test.js +967 -0
  69. package/runtime/scripts/amalgm-mcp/tests/entity.rig.js +239 -0
  70. package/runtime/scripts/amalgm-mcp/tests/entity.storm.test.js +486 -0
  71. package/runtime/scripts/amalgm-mcp/tests/fake-watch.js +35 -0
  72. package/runtime/scripts/amalgm-mcp/tests/observer.links.test.js +289 -0
  73. package/runtime/scripts/amalgm-mcp/tests/observer.rig.js +103 -0
  74. package/runtime/scripts/amalgm-mcp/tests/observer.roots.test.js +505 -0
  75. package/runtime/scripts/amalgm-mcp/tests/observer.storm.test.js +389 -0
  76. package/runtime/scripts/amalgm-mcp/tests/observer.territory.test.js +845 -0
  77. package/runtime/scripts/amalgm-mcp/tests/observer.test.js +657 -0
  78. package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +17 -32
  79. package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +256 -0
  80. package/runtime/scripts/amalgm-mcp/tests/registration.test.js +931 -0
  81. package/runtime/scripts/amalgm-mcp/tests/repocard.storm.test.js +284 -0
  82. package/runtime/scripts/amalgm-mcp/tests/repocard.test.js +371 -0
  83. package/runtime/scripts/amalgm-mcp/tests/repofollow.storm.test.js +435 -0
  84. package/runtime/scripts/amalgm-mcp/tests/repofollow.test.js +675 -0
  85. package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +0 -84
  86. package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +13 -376
  87. package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +0 -309
  88. package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +30 -758
  89. package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +3 -72
  90. package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +1 -89
  91. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +24 -55
  92. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +141 -424
  93. package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +3 -171
  94. package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +7 -220
  95. package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +236 -179
  96. package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +326 -2228
  97. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +3 -5319
  98. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +1 -191
  99. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +17 -3140
  100. package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +740 -137
  101. package/runtime/scripts/amalgm-mcp/workspace/access-store.js +1 -5
  102. package/runtime/scripts/amalgm-mcp/workspace/card.js +11 -35
  103. package/runtime/scripts/amalgm-mcp/workspace/cards.js +14 -39
  104. package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +147 -587
  105. package/runtime/scripts/amalgm-mcp/workspace/git.js +34 -406
  106. package/runtime/scripts/amalgm-mcp/workspace/merge.js +181 -102
  107. package/runtime/scripts/amalgm-mcp/workspace/objects.js +56 -221
  108. package/runtime/scripts/amalgm-mcp/workspace/reducer.js +538 -1964
  109. package/runtime/scripts/amalgm-mcp/workspace/rest.js +28 -49
  110. package/runtime/scripts/amalgm-mcp/workspace/store.js +4 -77
  111. package/runtime/scripts/amalgm-mcp/workspace/transition.js +192 -1734
  112. package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +1 -7
  113. package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +61 -73
  114. package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +6 -51
  115. package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +8 -79
  116. package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +146 -3461
  117. package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +71 -899
  118. package/runtime/scripts/amalgm-mcp/workspace/wall.js +538 -370
  119. package/runtime/scripts/local-gateway.js +1 -0
  120. package/runtime/scripts/amalgm-mcp/state/doc-disk.js +0 -550
  121. package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +0 -399
  122. package/runtime/scripts/amalgm-mcp/tests/fixtures/code-project-stream-v2.json +0 -1
  123. package/runtime/scripts/amalgm-mcp/tests/workspace-durable.test.js +0 -24
  124. package/runtime/scripts/amalgm-mcp/tests/workspace-local-intent.test.js +0 -1999
  125. package/runtime/scripts/amalgm-mcp/tests/workspace-native-file-transaction.test.js +0 -364
  126. package/runtime/scripts/amalgm-mcp/tests/workspace-provenance.test.js +0 -185
  127. package/runtime/scripts/amalgm-mcp/tests/workspace-reducer-liveness.test.js +0 -26
  128. package/runtime/scripts/amalgm-mcp/tests/workspace-sync-stats.test.js +0 -119
  129. package/runtime/scripts/amalgm-mcp/tests/workspace-transition.test.js +0 -1446
  130. package/runtime/scripts/amalgm-mcp/workspace/content.js +0 -32
  131. package/runtime/scripts/amalgm-mcp/workspace/durable.js +0 -45
  132. package/runtime/scripts/amalgm-mcp/workspace/intent.js +0 -535
  133. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/BUILD.md +0 -31
  134. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/darwin-arm64/amalgm-file-transaction +0 -0
  135. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/linux-x64/amalgm-file-transaction +0 -0
  136. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/build.mjs +0 -72
  137. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/client.js +0 -165
  138. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.mod +0 -5
  139. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.sum +0 -2
  140. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/main.go +0 -2201
  141. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/manifest.json +0 -19
  142. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_darwin.go +0 -18
  143. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_linux.go +0 -18
  144. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_darwin.go +0 -33
  145. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_linux.go +0 -47
  146. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/verify.mjs +0 -43
  147. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_darwin.go +0 -114
  148. package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_linux.go +0 -84
  149. package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +0 -771
  150. package/runtime/scripts/amalgm-mcp/workspace/provenance.js +0 -240
  151. package/runtime/scripts/amalgm-mcp/workspace/sync-stats.js +0 -240
  152. package/runtime/scripts/amalgm-mcp/workspace/tree/floor.js +0 -100
  153. package/runtime/scripts/amalgm-mcp/workspace/tree/observation-holds.js +0 -127
  154. package/runtime/scripts/amalgm-mcp/workspace/tree/path-state.js +0 -12
  155. package/runtime/scripts/amalgm-mcp/workspace/tree/root-binding.js +0 -125
  156. package/runtime/scripts/amalgm-mcp/workspace/tree/symlinks.js +0 -76
  157. package/runtime/scripts/amalgm-mcp/workspace/tree-materialization.js +0 -3766
@@ -5,7 +5,6 @@ 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');
9
8
 
10
9
  const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-workspace-tree-test-'));
11
10
  process.env.AMALGM_DIR = path.join(tempRoot, '.amalgm');
@@ -13,14 +12,10 @@ process.env.AMALGM_WORKSPACES_DIR = path.join(tempRoot, 'workspaces');
13
12
  process.env.AMALGM_COMPUTER_ID = 'computer-tree-test';
14
13
  fs.mkdirSync(process.env.AMALGM_WORKSPACES_DIR, { recursive: true });
15
14
 
16
- const {
17
- backfillWorkspaceTrashComponents,
18
- closeLocalDb,
19
- openLocalDb,
20
- } = require('../state/db');
15
+ const { closeLocalDb, openLocalDb } = require('../state/db');
21
16
  const docs = require('../state/docs');
22
17
  const { handleProjectRegister, handleTreeList } = require('../workspace/rest');
23
- const { listWorkspaces, upsertWorkspace } = require('../workspace/store');
18
+ const { upsertWorkspace } = require('../workspace/store');
24
19
  const {
25
20
  commitPathCreate,
26
21
  commitPathRename,
@@ -29,18 +24,9 @@ const {
29
24
  enrollRegisteredWorkspaces,
30
25
  listWorkspaceNodes,
31
26
  nodeForPath,
32
- reconcileRegisteredWorkspaceTrees,
33
27
  reconcileWorkspaceTree,
34
28
  } = require('../workspace/tree-store');
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');
29
+ const { isUuidV7 } = require('../workspace/tree/identity');
44
30
 
45
31
  test.after(() => {
46
32
  docs.closeAllDocs();
@@ -84,7 +70,7 @@ test('enrollment gives every node a UUID and open docs keep one resource through
84
70
  assert.equal(state.node_id, beforeNode.id);
85
71
  });
86
72
 
87
- test('bounded discovery may skip a large tree but recurring reconciliation repairs it uncapped', () => {
73
+ test('bounded automatic tree passes skip large workspaces without blocking explicit enrollment', () => {
88
74
  const root = path.join(process.env.AMALGM_WORKSPACES_DIR, 'bounded-observer');
89
75
  fs.mkdirSync(root, { recursive: true });
90
76
  fs.writeFileSync(path.join(root, 'one.txt'), 'one\n');
@@ -92,15 +78,7 @@ test('bounded discovery may skip a large tree but recurring reconciliation repai
92
78
  const workspace = upsertWorkspace({ path: root, name: 'Bounded observer', source: 'computer' });
93
79
 
94
80
  const boundedEnrollment = enrollWorkspace(workspace.id, { maxNodes: 1 });
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
- )));
81
+ assert.equal(boundedEnrollment.skipped, 'node_limit');
104
82
  assert.equal(listWorkspaceNodes(workspace.id).length, 1, 'bounded scan must not persist a partial tree');
105
83
 
106
84
  const explicitEnrollment = enrollWorkspace(workspace.id);
@@ -109,156 +87,7 @@ test('bounded discovery may skip a large tree but recurring reconciliation repai
109
87
  fs.writeFileSync(path.join(root, 'three.txt'), 'three\n');
110
88
  const boundedReconcile = reconcileWorkspaceTree(workspace.id, { maxNodes: 1 });
111
89
  assert.equal(boundedReconcile.skipped, 'node_limit');
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
- );
90
+ assert.equal(nodeForPath(path.join(root, 'three.txt')), null, 'automatic reconciliation skips the oversized tree');
262
91
  });
263
92
 
264
93
  test('first-update enrollment assigns UUIDs to existing files and ignores nested noise', async () => {
@@ -333,13 +162,6 @@ test('a git checkout never becomes per-file workspace operations', () => {
333
162
  const enrolled = enrollWorkspace(workspace.id);
334
163
  assert.equal(enrolled.skipped, 'git-owned');
335
164
  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);
343
165
 
344
166
  fs.writeFileSync(path.join(root, 'more.txt'), 'more\n');
345
167
  const reconciled = reconcileWorkspaceTree(workspace.id);
@@ -350,25 +172,6 @@ test('a git checkout never becomes per-file workspace operations', () => {
350
172
  .get(`workspace-tree:${workspace.id}`).count,
351
173
  0,
352
174
  );
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
- );
372
175
  });
373
176
 
374
177
  test('trash is editor-class while permanent removal is owner-only', () => {
@@ -415,285 +218,7 @@ test('direct shell creates and renames enter the same UUID tree journal', () =>
415
218
  assert.deepEqual(rows.map((row) => row.operation_kind), ['tree.create', 'tree.rename']);
416
219
  });
417
220
 
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', () => {
221
+ test('a missing node never lends its UUID to a recreated path with a recycled inode', () => {
697
222
  const root = path.join(process.env.AMALGM_WORKSPACES_DIR, 'inode-reuse');
698
223
  const target = path.join(root, 'note.md');
699
224
  fs.mkdirSync(root);
@@ -704,9 +229,9 @@ test('a deleted node never lends its UUID to a recreated path with a recycled in
704
229
 
705
230
  fs.unlinkSync(target);
706
231
  enrollWorkspace(workspace.id);
707
- const deleted = listWorkspaceNodes(workspace.id, { includeTrashed: true })
232
+ const missing = listWorkspaceNodes(workspace.id, { includeMissing: true })
708
233
  .find((node) => node.id === original.id);
709
- assert.ok(deleted.trashedAt);
234
+ assert.ok(missing.missingAt);
710
235
 
711
236
  fs.writeFileSync(target, 'replacement identity\n');
712
237
  const replacementStats = fs.lstatSync(target);
@@ -719,11 +244,11 @@ test('a deleted node never lends its UUID to a recreated path with a recycled in
719
244
  const replacement = nodeForPath(target).node;
720
245
  assert.notEqual(replacement.id, original.id);
721
246
  assert.equal(
722
- listWorkspaceNodes(workspace.id, { includeTrashed: true }).some(
723
- (node) => node.id === original.id && node.trashedAt,
247
+ listWorkspaceNodes(workspace.id, { includeMissing: true }).some(
248
+ (node) => node.id === original.id && node.missingAt,
724
249
  ),
725
250
  true,
726
- 'the old identity remains durably trashed',
251
+ 'the old identity remains tombstoned',
727
252
  );
728
253
  });
729
254
 
@@ -751,7 +276,7 @@ test('tree lookup enrolls an existing path on demand before sharing', () => {
751
276
  assert.equal(response.body.node.id, firstId, 'repeated sharing preserves identity');
752
277
  });
753
278
 
754
- test('moving or restoring a node requires write access at both source and destination', () => {
279
+ test('moving a node requires write access at both source and destination', () => {
755
280
  const root = path.join(process.env.AMALGM_WORKSPACES_DIR, 'move-authority');
756
281
  const sourcePath = path.join(root, 'source');
757
282
  const destinationPath = path.join(root, 'destination');
@@ -784,76 +309,25 @@ test('moving or restoring a node requires write access at both source and destin
784
309
  workspaceId: workspace.id,
785
310
  nodeId: file.id,
786
311
  parentId: destination.id,
787
- }, {
788
- role: 'editor',
789
312
  }), /tree destination is forbidden/);
790
313
  assert.equal(nodeForPath(filePath).node.id, file.id);
791
314
 
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
-
806
315
  database.prepare(`
807
316
  UPDATE workspace_node_permissions SET role = 'editor'
808
317
  WHERE node_id = ? AND user_id = ?
809
318
  `).run(destination.id, memberId);
810
319
  commitTreeOperation({
811
- kind: 'tree.restore',
320
+ kind: 'tree.move',
812
321
  workspaceId: workspace.id,
813
322
  nodeId: file.id,
814
323
  parentId: destination.id,
815
- }, { role: 'editor' });
324
+ });
816
325
  assert.equal(nodeForPath(path.join(destinationPath, 'note.md')).node.id, file.id);
817
326
  } finally {
818
327
  identity.userId = currentUserId;
819
328
  }
820
329
  });
821
330
 
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
-
857
331
  test('recursive create enrolls missing parents without overwriting existing disk entries', () => {
858
332
  const root = path.join(process.env.AMALGM_WORKSPACES_DIR, 'recursive');
859
333
  fs.mkdirSync(root);
@@ -874,350 +348,6 @@ test('recursive create enrolls missing parents without overwriting existing disk
874
348
  assert.ok(nodeForPath(existing)?.node.id);
875
349
  });
876
350
 
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
-
1221
351
  test('parent restore leaves an independently trashed child in its own trash slot', () => {
1222
352
  const root = path.join(process.env.AMALGM_WORKSPACES_DIR, 'trash-cascades');
1223
353
  const folderPath = path.join(root, 'folder');
@@ -1228,8 +358,8 @@ test('parent restore leaves an independently trashed child in its own trash slot
1228
358
  enrollWorkspace(workspace.id);
1229
359
  const folderId = nodeForPath(folderPath).node.id;
1230
360
  const childId = nodeForPath(childPath).node.id;
1231
- const childTrashPath = trashPath(workspace.id, childId, workspace.path);
1232
- const folderTrashPath = trashPath(workspace.id, folderId, workspace.path);
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);
1233
363
 
1234
364
  commitTreeOperation({
1235
365
  kind: 'tree.trash',
@@ -1238,11 +368,6 @@ test('parent restore leaves an independently trashed child in its own trash slot
1238
368
  trashedAt: '2026-07-21T12:01:00.000Z',
1239
369
  });
1240
370
  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);
1246
371
  commitTreeOperation({
1247
372
  kind: 'tree.trash',
1248
373
  workspaceId: workspace.id,
@@ -1261,2251 +386,3 @@ test('parent restore leaves an independently trashed child in its own trash slot
1261
386
  assert.equal(fs.existsSync(folderTrashPath), false);
1262
387
  assert.equal(fs.existsSync(childTrashPath), false);
1263
388
  });
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
- });