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