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