amalgm 0.1.239 → 0.1.241
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/shared-realtime-tunnel.js +18 -4
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/browser/cookie-jar.js +68 -20
- package/runtime/scripts/amalgm-mcp/project-context/store.js +87 -19
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +13 -0
- package/runtime/scripts/amalgm-mcp/state/attachments.js +40 -12
- package/runtime/scripts/amalgm-mcp/state/db.js +216 -17
- package/runtime/scripts/amalgm-mcp/state/doc-disk.js +550 -0
- package/runtime/scripts/amalgm-mcp/state/docs.js +1101 -301
- package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +99 -0
- package/runtime/scripts/amalgm-mcp/state/mutations.js +507 -130
- package/runtime/scripts/amalgm-mcp/state/promotions.js +21 -1
- package/runtime/scripts/amalgm-mcp/state/replicas.js +134 -13
- package/runtime/scripts/amalgm-mcp/state/shared-rest.js +14 -8
- package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +193 -0
- package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +313 -94
- package/runtime/scripts/amalgm-mcp/tests/fixtures/code-project-stream-v2.json +1 -0
- package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +32 -17
- package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +84 -0
- package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +376 -13
- package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +309 -0
- package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +758 -30
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +72 -3
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +33 -6
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +27 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +334 -191
- package/runtime/scripts/amalgm-mcp/tests/workspace-durable.test.js +24 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-local-intent.test.js +1999 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-native-file-transaction.test.js +364 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +98 -4
- package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +156 -8
- package/runtime/scripts/amalgm-mcp/tests/workspace-provenance.test.js +185 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +174 -269
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer-liveness.test.js +26 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +2060 -407
- package/runtime/scripts/amalgm-mcp/tests/workspace-sync-stats.test.js +119 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-transition.test.js +1446 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +5319 -3
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +191 -1
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +3140 -17
- package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +135 -637
- package/runtime/scripts/amalgm-mcp/workspace/access-store.js +5 -1
- package/runtime/scripts/amalgm-mcp/workspace/card.js +7 -7
- package/runtime/scripts/amalgm-mcp/workspace/cards.js +36 -12
- package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +436 -173
- package/runtime/scripts/amalgm-mcp/workspace/content.js +32 -0
- package/runtime/scripts/amalgm-mcp/workspace/durable.js +45 -0
- package/runtime/scripts/amalgm-mcp/workspace/git.js +391 -37
- package/runtime/scripts/amalgm-mcp/workspace/intent.js +535 -0
- package/runtime/scripts/amalgm-mcp/workspace/merge.js +89 -6
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/BUILD.md +31 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/darwin-arm64/amalgm-file-transaction +0 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/linux-x64/amalgm-file-transaction +0 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/build.mjs +72 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/client.js +165 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.mod +5 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.sum +2 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/main.go +2201 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/manifest.json +19 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_darwin.go +18 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_linux.go +18 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_darwin.go +33 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_linux.go +47 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/verify.mjs +43 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_darwin.go +114 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_linux.go +84 -0
- package/runtime/scripts/amalgm-mcp/workspace/objects.js +185 -55
- package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +616 -168
- package/runtime/scripts/amalgm-mcp/workspace/provenance.js +240 -0
- package/runtime/scripts/amalgm-mcp/workspace/reducer.js +1979 -570
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +49 -28
- package/runtime/scripts/amalgm-mcp/workspace/store.js +77 -4
- package/runtime/scripts/amalgm-mcp/workspace/sync-stats.js +240 -0
- package/runtime/scripts/amalgm-mcp/workspace/transition.js +1729 -192
- package/runtime/scripts/amalgm-mcp/workspace/tree/floor.js +100 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +7 -1
- package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +73 -61
- package/runtime/scripts/amalgm-mcp/workspace/tree/observation-holds.js +127 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/path-state.js +12 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +51 -6
- package/runtime/scripts/amalgm-mcp/workspace/tree/root-binding.js +125 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +79 -8
- package/runtime/scripts/amalgm-mcp/workspace/tree/symlinks.js +76 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +3480 -165
- package/runtime/scripts/amalgm-mcp/workspace/tree-materialization.js +3766 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +899 -71
- package/runtime/scripts/amalgm-mcp/workspace/wall.js +352 -497
|
@@ -3,17 +3,19 @@
|
|
|
3
3
|
const crypto = require('crypto');
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const path = require('path');
|
|
6
|
-
const {
|
|
6
|
+
const { isPathAbsentError } = require('./tree/path-state');
|
|
7
7
|
const { openLocalDb } = require('../state/db');
|
|
8
|
-
const { applyTreeOperation } = require('./tree/reducer');
|
|
8
|
+
const { applyTreeOperation, descendantsInclude } = require('./tree/reducer');
|
|
9
9
|
const { relativeWorkspacePath, shouldSync } = require('./tree/inclusion');
|
|
10
10
|
const { normalizeTreeOperation } = require('./tree/operations');
|
|
11
11
|
const { normalizeTreeSnapshot } = require('./tree/snapshot');
|
|
12
|
+
const { validateLiveSymlinkTargets } = require('./tree/symlinks');
|
|
12
13
|
const {
|
|
13
14
|
hasAncestorInSet,
|
|
14
|
-
materializeOperation,
|
|
15
15
|
persistNodes,
|
|
16
|
+
relativePathForNode,
|
|
16
17
|
rowToNode,
|
|
18
|
+
trashPath,
|
|
17
19
|
workspaceRow,
|
|
18
20
|
} = require('./tree/local-state');
|
|
19
21
|
|
|
@@ -75,7 +77,7 @@ function safeSymlinkTarget(rootPath, linkPath, target) {
|
|
|
75
77
|
}
|
|
76
78
|
|
|
77
79
|
function activeNode(node) {
|
|
78
|
-
return Boolean(node && !node.trashedAt && !node.purgedAt);
|
|
80
|
+
return Boolean(node && !node.missingAt && !node.trashedAt && !node.purgedAt);
|
|
79
81
|
}
|
|
80
82
|
|
|
81
83
|
function normalizeSnapshotPermissions(input, snapshot) {
|
|
@@ -100,7 +102,15 @@ function normalizeSnapshotPermissions(input, snapshot) {
|
|
|
100
102
|
});
|
|
101
103
|
}
|
|
102
104
|
|
|
103
|
-
function replaceTreeProjection(
|
|
105
|
+
function replaceTreeProjection(
|
|
106
|
+
database,
|
|
107
|
+
workspaceId,
|
|
108
|
+
rootPath,
|
|
109
|
+
snapshot,
|
|
110
|
+
paths,
|
|
111
|
+
permissions,
|
|
112
|
+
authorityEpoch,
|
|
113
|
+
) {
|
|
104
114
|
const ordered = Array.from(snapshot.nodes.values()).sort((left, right) => {
|
|
105
115
|
const leftPath = paths.get(left.id);
|
|
106
116
|
const rightPath = paths.get(right.id);
|
|
@@ -112,7 +122,11 @@ function replaceTreeProjection(database, workspaceId, snapshot, paths, permissio
|
|
|
112
122
|
.filter((nodeId) => !snapshot.nodes.has(nodeId));
|
|
113
123
|
if (removedIds.length) {
|
|
114
124
|
const placeholders = removedIds.map(() => '?').join(',');
|
|
115
|
-
database.prepare(`
|
|
125
|
+
if (database.prepare(`
|
|
126
|
+
SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'doc_states'
|
|
127
|
+
`).get()) {
|
|
128
|
+
database.prepare(`DELETE FROM doc_states WHERE node_id IN (${placeholders})`).run(...removedIds);
|
|
129
|
+
}
|
|
116
130
|
database.prepare(`DELETE FROM shared_resource_replicas WHERE resource_id IN (${placeholders})`).run(...removedIds);
|
|
117
131
|
database.prepare(`DELETE FROM shared_resource_attachments WHERE resource_id IN (${placeholders})`).run(...removedIds);
|
|
118
132
|
}
|
|
@@ -121,14 +135,25 @@ function replaceTreeProjection(database, workspaceId, snapshot, paths, permissio
|
|
|
121
135
|
const insert = database.prepare(`
|
|
122
136
|
INSERT INTO workspace_nodes (
|
|
123
137
|
id, workspace_id, parent_id, kind, name, relative_path, symlink_target,
|
|
124
|
-
trashed_at,
|
|
125
|
-
|
|
138
|
+
device_id, inode, trashed_at, trash_component_id, purged_at,
|
|
139
|
+
created_at, updated_at
|
|
140
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
126
141
|
`);
|
|
127
142
|
for (const node of ordered) {
|
|
143
|
+
const relativePath = paths.get(node.id);
|
|
144
|
+
const physicalPath = relativePath
|
|
145
|
+
? path.join(rootPath, ...relativePath.split('/'))
|
|
146
|
+
: rootPath;
|
|
147
|
+
let physical = null;
|
|
148
|
+
if (activeNode(node) && pathExists(physicalPath)) {
|
|
149
|
+
const stats = fs.lstatSync(physicalPath);
|
|
150
|
+
physical = { deviceId: String(stats.dev), inode: String(stats.ino) };
|
|
151
|
+
}
|
|
128
152
|
insert.run(
|
|
129
153
|
node.id, node.workspaceId, node.parentId, node.kind, node.name,
|
|
130
|
-
|
|
131
|
-
node.
|
|
154
|
+
relativePath, node.symlinkTarget, physical?.deviceId || null,
|
|
155
|
+
physical?.inode || null, node.trashedAt, node.trashComponentId,
|
|
156
|
+
node.purgedAt, node.createdAt, node.updatedAt,
|
|
132
157
|
);
|
|
133
158
|
}
|
|
134
159
|
const insertPermission = database.prepare(`
|
|
@@ -145,80 +170,889 @@ function replaceTreeProjection(database, workspaceId, snapshot, paths, permissio
|
|
|
145
170
|
return ordered;
|
|
146
171
|
}
|
|
147
172
|
|
|
148
|
-
function
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
173
|
+
function projectedAuthorityEpoch(workspaceId, database) {
|
|
174
|
+
return Number(database.prepare(`
|
|
175
|
+
SELECT authority_epoch FROM workspace_access_state WHERE workspace_id = ?
|
|
176
|
+
`).get(workspaceId)?.authority_epoch || 0);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function snapshotRecord(snapshot) {
|
|
180
|
+
return {
|
|
181
|
+
schemaVersion: 1,
|
|
182
|
+
workspaceId: snapshot.workspaceId,
|
|
183
|
+
version: snapshot.version,
|
|
184
|
+
nodes: Array.from(snapshot.nodes.values()),
|
|
185
|
+
};
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function activeCopy(node) {
|
|
189
|
+
return {
|
|
190
|
+
...node,
|
|
191
|
+
missingAt: null,
|
|
192
|
+
trashedAt: null,
|
|
193
|
+
purgedAt: null,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function pendingTreeMutations(resourceId, throughVersion, database) {
|
|
198
|
+
return require('../state/mutations')
|
|
199
|
+
.listLocalOverlayMutations(resourceId, { database, throughVersion })
|
|
200
|
+
.filter((mutation) => (
|
|
201
|
+
mutation.localResource === `${TREE_RESOURCE_PREFIX}${resourceId}`
|
|
202
|
+
&& mutation.contract === 'workspace-tree@1'
|
|
203
|
+
));
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function pendingContentNodeIds(before, database) {
|
|
207
|
+
const nodeIds = new Set(before.keys());
|
|
208
|
+
const pending = new Set();
|
|
209
|
+
const rows = database.prepare(`
|
|
210
|
+
SELECT shared_resource_id, local_resource
|
|
211
|
+
FROM mutation_journal
|
|
212
|
+
WHERE mutation_origin = 'local'
|
|
213
|
+
AND (mutation_state != 'synced' OR official_projected_at IS NULL)
|
|
214
|
+
ORDER BY journal_id ASC
|
|
215
|
+
`).all();
|
|
216
|
+
for (const row of rows) {
|
|
217
|
+
if (row.shared_resource_id && nodeIds.has(row.shared_resource_id)) {
|
|
218
|
+
pending.add(row.shared_resource_id);
|
|
219
|
+
}
|
|
220
|
+
const localResource = String(row.local_resource || '');
|
|
221
|
+
if (localResource.startsWith('doc:')) {
|
|
222
|
+
const nodeId = localResource.slice('doc:'.length);
|
|
223
|
+
if (nodeIds.has(nodeId)) pending.add(nodeId);
|
|
224
|
+
}
|
|
157
225
|
}
|
|
226
|
+
return pending;
|
|
158
227
|
}
|
|
159
228
|
|
|
160
|
-
|
|
161
|
-
|
|
229
|
+
/**
|
|
230
|
+
* A snapshot moves only the official floor. Local tree operations remain an
|
|
231
|
+
* overlay. Other resource contracts can also own a tree node: a pending
|
|
232
|
+
* document mutation keeps its UUID and ancestor chain present even when the
|
|
233
|
+
* incoming tree prefix omits it.
|
|
234
|
+
*/
|
|
235
|
+
function snapshotWithLocalOverlay(official, before, resourceId, database) {
|
|
236
|
+
let nodes = new Map(official.nodes);
|
|
237
|
+
const importAncestors = (nodeId) => {
|
|
238
|
+
if (!nodeId || nodes.has(nodeId)) return;
|
|
239
|
+
const node = before.get(nodeId);
|
|
240
|
+
if (!node) throw invalid(`local tree intent lost ancestor ${nodeId}`, 409);
|
|
241
|
+
importAncestors(node.parentId);
|
|
242
|
+
nodes.set(node.id, activeCopy(node));
|
|
243
|
+
};
|
|
244
|
+
// Structural births own membership. Apply them before content ownership so
|
|
245
|
+
// one locally created document does not import its UUID and then collide
|
|
246
|
+
// with its own pending tree.create.
|
|
247
|
+
for (const mutation of pendingTreeMutations(resourceId, official.version, database)) {
|
|
248
|
+
const operation = normalizeTreeOperation(mutation.operation);
|
|
249
|
+
if (operation.kind === 'tree.create') {
|
|
250
|
+
importAncestors(operation.parentId);
|
|
251
|
+
} else {
|
|
252
|
+
// Import the UUID the local operation names, but not untouched
|
|
253
|
+
// descendants. Descendants come from the newer official projection, so
|
|
254
|
+
// a local folder rename cannot resurrect a child another writer deleted.
|
|
255
|
+
importAncestors(operation.nodeId);
|
|
256
|
+
if (operation.parentId) importAncestors(operation.parentId);
|
|
257
|
+
}
|
|
258
|
+
nodes = applyTreeOperation(nodes, operation);
|
|
259
|
+
}
|
|
260
|
+
for (const nodeId of pendingContentNodeIds(before, database)) importAncestors(nodeId);
|
|
261
|
+
return { ...official, nodes };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function pathExists(target) {
|
|
265
|
+
try {
|
|
266
|
+
fs.lstatSync(target);
|
|
267
|
+
return true;
|
|
268
|
+
} catch (error) {
|
|
269
|
+
if (isPathAbsentError(error)) return false;
|
|
270
|
+
throw error;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function physicalBindingAtPath(target, database) {
|
|
275
|
+
let stats;
|
|
276
|
+
try {
|
|
277
|
+
stats = fs.lstatSync(target);
|
|
278
|
+
} catch (error) {
|
|
279
|
+
if (isPathAbsentError(error)) return null;
|
|
280
|
+
throw error;
|
|
281
|
+
}
|
|
282
|
+
const kind = stats.isSymbolicLink()
|
|
283
|
+
? 'symlink'
|
|
284
|
+
: stats.isDirectory()
|
|
285
|
+
? 'folder'
|
|
286
|
+
: 'file';
|
|
287
|
+
const held = localTree().nodeForPath(target, { database });
|
|
288
|
+
if (
|
|
289
|
+
!held
|
|
290
|
+
|| held.node.kind !== kind
|
|
291
|
+
|| held.node.deviceId !== String(stats.dev)
|
|
292
|
+
|| held.node.inode !== String(stats.ino)
|
|
293
|
+
) {
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
return {
|
|
297
|
+
node: held.node,
|
|
298
|
+
kind,
|
|
299
|
+
deviceId: String(stats.dev),
|
|
300
|
+
inode: String(stats.ino),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function retainedBindingFailures(plan, database) {
|
|
305
|
+
const failures = [];
|
|
306
|
+
for (const output of plan.retainedOutputs || []) {
|
|
307
|
+
const atPath = localTree().nodeForPath(output.path, { database });
|
|
308
|
+
if (!pathExists(output.path)) {
|
|
309
|
+
if (atPath) failures.push(output.path);
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (!physicalBindingAtPath(output.path, database)) {
|
|
313
|
+
failures.push(output.path);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
return failures;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
function settleRetainedOutputBindings(plan, database, options = {}) {
|
|
320
|
+
const operations = [];
|
|
321
|
+
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
322
|
+
const observed = localTree().reconcileWorkspaceTree(plan.resourceId, {
|
|
323
|
+
database,
|
|
324
|
+
skipRecovery: true,
|
|
325
|
+
source: options.source || 'workspace-tree:retained-binding',
|
|
326
|
+
persistObservedOperation: options.persistObservedOperation,
|
|
327
|
+
});
|
|
328
|
+
operations.push(...(observed.operations || []));
|
|
329
|
+
if (observed.held?.length) {
|
|
330
|
+
options.hold(
|
|
331
|
+
observed.held[0]?.reason || 'retained-birth-pending',
|
|
332
|
+
observed.held.flatMap((item) => item.paths || []),
|
|
333
|
+
operations,
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
if (
|
|
337
|
+
attempt === 0
|
|
338
|
+
&& typeof options.testHooks?.afterRetainedBindingReconcile === 'function'
|
|
339
|
+
) {
|
|
340
|
+
options.testHooks.afterRetainedBindingReconcile(
|
|
341
|
+
plan.retainedOutputs || [],
|
|
342
|
+
plan,
|
|
343
|
+
);
|
|
344
|
+
}
|
|
345
|
+
const failures = retainedBindingFailures(plan, database);
|
|
346
|
+
if (!failures.length) {
|
|
347
|
+
if (typeof options.afterSettled === 'function') {
|
|
348
|
+
options.afterSettled(operations);
|
|
349
|
+
}
|
|
350
|
+
if (
|
|
351
|
+
typeof options.testHooks?.afterRetainedBindingSettle === 'function'
|
|
352
|
+
) {
|
|
353
|
+
options.testHooks.afterRetainedBindingSettle(
|
|
354
|
+
plan.retainedOutputs || [],
|
|
355
|
+
plan,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
return operations;
|
|
359
|
+
}
|
|
360
|
+
if (attempt === 1) {
|
|
361
|
+
options.hold(
|
|
362
|
+
'retained-physical-binding-changed',
|
|
363
|
+
failures.map((target) => path.relative(plan.rootPath, target)),
|
|
364
|
+
operations,
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
return operations;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function captureRemovalPreconditions(rootPath, before, after, beforePaths) {
|
|
162
372
|
const removedIds = new Set();
|
|
163
373
|
for (const node of before.values()) {
|
|
164
374
|
if (node.id === node.workspaceId || !activeNode(node)) continue;
|
|
165
|
-
if (!activeNode(after.get(node.id))) removedIds.add(node.id);
|
|
375
|
+
if (!activeNode(after.nodes.get(node.id))) removedIds.add(node.id);
|
|
376
|
+
}
|
|
377
|
+
const components = removedComponentIndex(removedIds, before);
|
|
378
|
+
const excludedPrefixesByRoot = new Map();
|
|
379
|
+
// A surviving node whose old parent is removed is the one physical boundary
|
|
380
|
+
// between two removed components. The outer precondition must stop there:
|
|
381
|
+
// the survivor is moved intact, and any removed descendants below it are
|
|
382
|
+
// captured by their own component. This partitions the forest so every
|
|
383
|
+
// byte is read by at most one destructive precondition.
|
|
384
|
+
for (const node of before.values()) {
|
|
385
|
+
if (
|
|
386
|
+
node.id === node.workspaceId
|
|
387
|
+
|| !activeNode(node)
|
|
388
|
+
|| !activeNode(after.nodes.get(node.id))
|
|
389
|
+
|| !removedIds.has(node.parentId)
|
|
390
|
+
) continue;
|
|
391
|
+
const rootId = components.rootById.get(node.parentId);
|
|
392
|
+
const rootPathBefore = beforePaths.get(rootId);
|
|
393
|
+
const nodePathBefore = beforePaths.get(node.id);
|
|
394
|
+
const prefix = descendantSuffix(nodePathBefore, rootPathBefore);
|
|
395
|
+
if (!rootId || !prefix) {
|
|
396
|
+
throw invalid(`workspace tree survivor ${node.id} has no removal boundary`, 409);
|
|
397
|
+
}
|
|
398
|
+
const prefixes = excludedPrefixesByRoot.get(rootId) || [];
|
|
399
|
+
prefixes.push(prefix);
|
|
400
|
+
excludedPrefixesByRoot.set(rootId, prefixes);
|
|
166
401
|
}
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
require('../state/docs').closeDocsByNodeIds(removedIds);
|
|
171
|
-
for (const node of removedRoots) {
|
|
402
|
+
const materialization = require('./tree-materialization');
|
|
403
|
+
const preconditions = new Map();
|
|
404
|
+
for (const node of components.roots) {
|
|
172
405
|
const relative = beforePaths.get(node.id);
|
|
173
|
-
|
|
406
|
+
const source = relative
|
|
407
|
+
? path.join(rootPath, ...relative.split('/'))
|
|
408
|
+
: null;
|
|
409
|
+
if (source && pathExists(source)) {
|
|
410
|
+
preconditions.set(node.id, materialization.removalPrecondition(source, {
|
|
411
|
+
excludedPrefixes: excludedPrefixesByRoot.get(node.id) || [],
|
|
412
|
+
}));
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
return { preconditions, removedIds };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function removedComponentIndex(removedIds, nodes) {
|
|
419
|
+
const rootById = new Map();
|
|
420
|
+
for (const id of removedIds) {
|
|
421
|
+
if (rootById.has(id)) continue;
|
|
422
|
+
const trail = [];
|
|
423
|
+
let currentId = id;
|
|
424
|
+
let rootId = null;
|
|
425
|
+
while (removedIds.has(currentId) && !rootById.has(currentId)) {
|
|
426
|
+
const node = nodes.get(currentId);
|
|
427
|
+
if (!node) break;
|
|
428
|
+
trail.push(currentId);
|
|
429
|
+
if (!removedIds.has(node.parentId)) {
|
|
430
|
+
rootId = currentId;
|
|
431
|
+
break;
|
|
432
|
+
}
|
|
433
|
+
currentId = node.parentId;
|
|
434
|
+
}
|
|
435
|
+
if (!rootId) rootId = rootById.get(currentId) || currentId;
|
|
436
|
+
for (const trailId of trail) rootById.set(trailId, rootId);
|
|
437
|
+
}
|
|
438
|
+
const rootIds = new Set(rootById.values());
|
|
439
|
+
return {
|
|
440
|
+
rootById,
|
|
441
|
+
roots: Array.from(rootIds)
|
|
442
|
+
.map((id) => nodes.get(id))
|
|
443
|
+
.filter(Boolean),
|
|
444
|
+
};
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
function matchesPathPrefix(prefixes) {
|
|
448
|
+
const root = { terminal: false, children: new Map() };
|
|
449
|
+
for (const relativePath of prefixes) {
|
|
450
|
+
let current = root;
|
|
451
|
+
for (const segment of relativePath.split('/').filter(Boolean)) {
|
|
452
|
+
if (current.terminal) break;
|
|
453
|
+
if (!current.children.has(segment)) {
|
|
454
|
+
current.children.set(segment, { terminal: false, children: new Map() });
|
|
455
|
+
}
|
|
456
|
+
current = current.children.get(segment);
|
|
457
|
+
}
|
|
458
|
+
current.terminal = true;
|
|
459
|
+
current.children.clear();
|
|
460
|
+
}
|
|
461
|
+
return (relativePath) => {
|
|
462
|
+
let current = root;
|
|
463
|
+
if (current.terminal) return true;
|
|
464
|
+
for (const segment of relativePath.split('/').filter(Boolean)) {
|
|
465
|
+
current = current.children.get(segment);
|
|
466
|
+
if (!current) return false;
|
|
467
|
+
if (current.terminal) return true;
|
|
468
|
+
}
|
|
469
|
+
return false;
|
|
470
|
+
};
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
function retirementAfterPlannedMoves(retirement, excludedPrefixes) {
|
|
474
|
+
if (!retirement) return null;
|
|
475
|
+
const excludedByMove = matchesPathPrefix(excludedPrefixes);
|
|
476
|
+
const remains = (relativePath) => !excludedByMove(relativePath);
|
|
477
|
+
return {
|
|
478
|
+
entries: retirement.entries.filter((entry) => remains(entry.relativePath)),
|
|
479
|
+
directories: retirement.directories.filter((entry) => remains(entry.relativePath)),
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function descendantSuffix(relativePath, ancestorPath) {
|
|
484
|
+
if (
|
|
485
|
+
!relativePath
|
|
486
|
+
|| !ancestorPath
|
|
487
|
+
|| relativePath === ancestorPath
|
|
488
|
+
|| !relativePath.startsWith(`${ancestorPath}/`)
|
|
489
|
+
) {
|
|
490
|
+
return null;
|
|
174
491
|
}
|
|
492
|
+
return relativePath.slice(ancestorPath.length + 1);
|
|
493
|
+
}
|
|
175
494
|
|
|
495
|
+
function physicalPlan({
|
|
496
|
+
rootPath,
|
|
497
|
+
rootIdentity = null,
|
|
498
|
+
before,
|
|
499
|
+
officialSnapshot,
|
|
500
|
+
afterSnapshot,
|
|
501
|
+
beforePaths,
|
|
502
|
+
afterPaths,
|
|
503
|
+
envelope,
|
|
504
|
+
checksum,
|
|
505
|
+
permissions,
|
|
506
|
+
contentResources,
|
|
507
|
+
initial,
|
|
508
|
+
removalPreconditions = null,
|
|
509
|
+
}) {
|
|
510
|
+
const after = afterSnapshot.nodes;
|
|
511
|
+
const removedIds = new Set();
|
|
512
|
+
for (const node of before.values()) {
|
|
513
|
+
if (node.id === node.workspaceId || !activeNode(node)) continue;
|
|
514
|
+
if (!activeNode(after.get(node.id))) removedIds.add(node.id);
|
|
515
|
+
}
|
|
516
|
+
const removedComponents = removedComponentIndex(removedIds, before);
|
|
517
|
+
const removedRoots = removedComponents.roots;
|
|
176
518
|
const changedIds = new Set();
|
|
177
519
|
for (const node of after.values()) {
|
|
178
|
-
if (
|
|
179
|
-
|
|
520
|
+
if (
|
|
521
|
+
node.id === afterSnapshot.workspaceId
|
|
522
|
+
|| !activeNode(node)
|
|
523
|
+
|| !activeNode(before.get(node.id))
|
|
524
|
+
) continue;
|
|
525
|
+
const previous = before.get(node.id);
|
|
526
|
+
if (
|
|
527
|
+
beforePaths.get(node.id) !== afterPaths.get(node.id)
|
|
528
|
+
|| previous.parentId !== node.parentId
|
|
529
|
+
) {
|
|
530
|
+
changedIds.add(node.id);
|
|
531
|
+
}
|
|
180
532
|
}
|
|
181
|
-
const
|
|
533
|
+
const treeMaterialization = require('./tree-materialization');
|
|
534
|
+
// Bind every surviving UUID to the physical object that currently owns it.
|
|
535
|
+
// Move roots alone are not a complete proof: a child can rename while its
|
|
536
|
+
// parent moves, in which case moving only the parent leaves the child at the
|
|
537
|
+
// wrong suffix while the database claims the snapshot is installed.
|
|
538
|
+
const rollbackBindings = Array.from(before.values())
|
|
539
|
+
.filter((node) => (
|
|
540
|
+
node.id !== afterSnapshot.workspaceId
|
|
541
|
+
&& activeNode(node)
|
|
542
|
+
))
|
|
543
|
+
.sort((left, right) => left.id.localeCompare(right.id))
|
|
544
|
+
.map((node) => {
|
|
545
|
+
const previous = node;
|
|
546
|
+
const projected = after.get(node.id);
|
|
547
|
+
if (
|
|
548
|
+
activeNode(projected)
|
|
549
|
+
&& (
|
|
550
|
+
previous.kind !== projected.kind
|
|
551
|
+
|| previous.symlinkTarget !== projected.symlinkTarget
|
|
552
|
+
)
|
|
553
|
+
) {
|
|
554
|
+
throw invalid(
|
|
555
|
+
`workspace tree node ${node.id} changed its immutable physical identity`,
|
|
556
|
+
409,
|
|
557
|
+
);
|
|
558
|
+
}
|
|
559
|
+
const from = beforePaths.get(node.id);
|
|
560
|
+
const source = path.join(rootPath, ...from.split('/'));
|
|
561
|
+
if (!pathExists(source)) {
|
|
562
|
+
throw invalid(`workspace tree node ${node.id} has no physical source`, 409);
|
|
563
|
+
}
|
|
564
|
+
return {
|
|
565
|
+
nodeId: node.id,
|
|
566
|
+
from,
|
|
567
|
+
to: activeNode(projected) ? afterPaths.get(node.id) : null,
|
|
568
|
+
kind: previous.kind,
|
|
569
|
+
symlinkTarget: previous.symlinkTarget,
|
|
570
|
+
identity: treeMaterialization.identity(source, { deep: false }),
|
|
571
|
+
};
|
|
572
|
+
});
|
|
573
|
+
const rollbackBindingByNodeId = new Map(
|
|
574
|
+
rollbackBindings.map((binding) => [binding.nodeId, binding]),
|
|
575
|
+
);
|
|
576
|
+
const projectedBindings = Array.from(after.values())
|
|
577
|
+
.filter((node) => (
|
|
578
|
+
node.id !== afterSnapshot.workspaceId
|
|
579
|
+
&& activeNode(node)
|
|
580
|
+
&& activeNode(before.get(node.id))
|
|
581
|
+
))
|
|
582
|
+
.sort((left, right) => left.id.localeCompare(right.id))
|
|
583
|
+
.map((node) => {
|
|
584
|
+
const binding = rollbackBindingByNodeId.get(node.id);
|
|
585
|
+
if (!binding) {
|
|
586
|
+
throw invalid(`workspace tree node ${node.id} has no rollback binding`, 409);
|
|
587
|
+
}
|
|
588
|
+
return {
|
|
589
|
+
...binding,
|
|
590
|
+
to: afterPaths.get(node.id),
|
|
591
|
+
};
|
|
592
|
+
});
|
|
593
|
+
const bindingByNodeId = new Map(
|
|
594
|
+
projectedBindings.map((binding) => [binding.nodeId, binding]),
|
|
595
|
+
);
|
|
596
|
+
// A changed descendant may ride inside an already planned ancestor only
|
|
597
|
+
// when that ancestor carries the descendant to its exact final suffix.
|
|
598
|
+
// Otherwise stage both nodes independently: deepest sources extract first,
|
|
599
|
+
// then target-depth order installs the parent before the child.
|
|
600
|
+
const moves = [];
|
|
601
|
+
const carrierStack = [];
|
|
602
|
+
const changedNodes = Array.from(changedIds)
|
|
182
603
|
.map((id) => after.get(id))
|
|
183
|
-
.
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
604
|
+
.sort((left, right) => {
|
|
605
|
+
const leftPath = beforePaths.get(left.id);
|
|
606
|
+
const rightPath = beforePaths.get(right.id);
|
|
607
|
+
if (leftPath !== rightPath) return leftPath < rightPath ? -1 : 1;
|
|
608
|
+
return left.id.localeCompare(right.id);
|
|
609
|
+
});
|
|
610
|
+
for (const node of changedNodes) {
|
|
611
|
+
const from = beforePaths.get(node.id);
|
|
612
|
+
const to = afterPaths.get(node.id);
|
|
613
|
+
const parentChanged = before.get(node.id).parentId !== node.parentId;
|
|
614
|
+
while (
|
|
615
|
+
carrierStack.length
|
|
616
|
+
&& descendantSuffix(from, carrierStack[carrierStack.length - 1].from) === null
|
|
617
|
+
) {
|
|
618
|
+
carrierStack.pop();
|
|
619
|
+
}
|
|
620
|
+
const carrier = carrierStack[carrierStack.length - 1] || null;
|
|
621
|
+
const carriedSuffix = carrier ? descendantSuffix(from, carrier.from) : null;
|
|
622
|
+
if (
|
|
623
|
+
!parentChanged
|
|
624
|
+
&& carrier
|
|
625
|
+
&& `${carrier.to}/${carriedSuffix}` === to
|
|
626
|
+
) {
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
const binding = bindingByNodeId.get(node.id);
|
|
630
|
+
if (!binding) {
|
|
631
|
+
throw invalid(`workspace tree move ${node.id} has no physical binding`, 409);
|
|
632
|
+
}
|
|
633
|
+
const move = {
|
|
634
|
+
nodeId: node.id,
|
|
635
|
+
from,
|
|
636
|
+
to,
|
|
637
|
+
sourceParentNodeId: before.get(node.id)?.parentId || afterSnapshot.workspaceId,
|
|
638
|
+
targetParentNodeId: node.parentId || afterSnapshot.workspaceId,
|
|
639
|
+
kind: binding.kind,
|
|
640
|
+
symlinkTarget: binding.symlinkTarget,
|
|
641
|
+
identity: binding.identity,
|
|
642
|
+
};
|
|
643
|
+
moves.push(move);
|
|
644
|
+
carrierStack.push(move);
|
|
645
|
+
}
|
|
646
|
+
// Rollback must be able to reverse an independently staged descendant even
|
|
647
|
+
// when a local tool carried it inside any surviving parent, including one
|
|
648
|
+
// that the official snapshot did not move. Derive carriers from UUID
|
|
649
|
+
// ancestry and the complete projected binding set, never pathname sort.
|
|
650
|
+
const annotateCarrier = (move, node, nodes, paths, bindingPath, prefix) => {
|
|
651
|
+
let parentId = node?.parentId || null;
|
|
652
|
+
while (parentId) {
|
|
653
|
+
const carrier = bindingByNodeId.get(parentId);
|
|
654
|
+
if (carrier) {
|
|
655
|
+
const suffix = descendantSuffix(
|
|
656
|
+
paths.get(move.nodeId),
|
|
657
|
+
carrier[bindingPath],
|
|
658
|
+
);
|
|
659
|
+
if (!suffix) {
|
|
660
|
+
throw invalid(
|
|
661
|
+
`workspace tree move ${move.nodeId} has an invalid ${prefix} carrier`,
|
|
662
|
+
409,
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
move[`${prefix}CarrierNodeId`] = carrier.nodeId;
|
|
666
|
+
move[`${prefix}CarrierSuffix`] = suffix;
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
parentId = nodes.get(parentId)?.parentId || null;
|
|
670
|
+
}
|
|
671
|
+
};
|
|
672
|
+
for (const move of moves) {
|
|
673
|
+
annotateCarrier(
|
|
674
|
+
move,
|
|
675
|
+
before.get(move.nodeId),
|
|
676
|
+
before,
|
|
677
|
+
beforePaths,
|
|
678
|
+
'from',
|
|
679
|
+
'source',
|
|
680
|
+
);
|
|
681
|
+
annotateCarrier(
|
|
682
|
+
move,
|
|
683
|
+
after.get(move.nodeId),
|
|
684
|
+
after,
|
|
685
|
+
afterPaths,
|
|
686
|
+
'to',
|
|
687
|
+
'target',
|
|
688
|
+
);
|
|
689
|
+
}
|
|
690
|
+
// A surviving node can split exactly one removed component: the component
|
|
691
|
+
// containing its direct old parent. Assign that boundary once by UUID
|
|
692
|
+
// ancestry instead of rescanning every move for every removed root.
|
|
693
|
+
const movedPrefixesByRemovalRoot = new Map();
|
|
694
|
+
for (const move of moves) {
|
|
695
|
+
const oldParentId = before.get(move.nodeId)?.parentId;
|
|
696
|
+
if (!removedIds.has(oldParentId)) continue;
|
|
697
|
+
const rootId = removedComponents.rootById.get(oldParentId);
|
|
698
|
+
const removedFrom = beforePaths.get(rootId);
|
|
699
|
+
const suffix = descendantSuffix(move.from, removedFrom);
|
|
700
|
+
if (!rootId || !suffix) {
|
|
701
|
+
throw invalid(
|
|
702
|
+
`workspace tree move ${move.nodeId} has no removed-component boundary`,
|
|
703
|
+
409,
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
const prefixes = movedPrefixesByRemovalRoot.get(rootId) || [];
|
|
707
|
+
prefixes.push(suffix);
|
|
708
|
+
movedPrefixesByRemovalRoot.set(rootId, prefixes);
|
|
709
|
+
}
|
|
710
|
+
const removals = removedRoots.flatMap((node) => {
|
|
711
|
+
const from = beforePaths.get(node.id);
|
|
712
|
+
const source = path.join(rootPath, ...from.split('/'));
|
|
713
|
+
if (!from || !pathExists(source)) return [];
|
|
714
|
+
const precondition = removalPreconditions?.get(node.id) || null;
|
|
715
|
+
if (removalPreconditions && !precondition) {
|
|
716
|
+
throw invalid(`workspace tree removal ${node.id} has no capture precondition`, 409);
|
|
717
|
+
}
|
|
718
|
+
return [{
|
|
719
|
+
nodeId: node.id,
|
|
720
|
+
from,
|
|
721
|
+
to: null,
|
|
722
|
+
sourceParentNodeId: before.get(node.id)?.parentId || afterSnapshot.workspaceId,
|
|
723
|
+
// The deep preflight binds every byte before the first planned move.
|
|
724
|
+
// The later staging step uses only the root inode because moving a
|
|
725
|
+
// planned descendant legitimately changes its ancestor's digest.
|
|
726
|
+
identity: treeMaterialization.identity(source, { deep: false }),
|
|
727
|
+
preflightIdentity: precondition?.identity || null,
|
|
728
|
+
retirement: retirementAfterPlannedMoves(
|
|
729
|
+
precondition?.retirement || treeMaterialization.removalPrecondition(source).retirement,
|
|
730
|
+
movedPrefixesByRemovalRoot.get(node.id) || [],
|
|
731
|
+
),
|
|
732
|
+
}];
|
|
206
733
|
});
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
734
|
+
const creates = Array.from(after.values())
|
|
735
|
+
.filter((node) => (
|
|
736
|
+
node.id !== afterSnapshot.workspaceId
|
|
737
|
+
&& activeNode(node)
|
|
738
|
+
&& !activeNode(before.get(node.id))
|
|
739
|
+
))
|
|
740
|
+
.sort((left, right) => (
|
|
741
|
+
afterPaths.get(left.id).split('/').length - afterPaths.get(right.id).split('/').length
|
|
742
|
+
))
|
|
743
|
+
.map((node) => {
|
|
744
|
+
const to = afterPaths.get(node.id);
|
|
745
|
+
const target = path.join(rootPath, ...to.split('/'));
|
|
746
|
+
if (
|
|
747
|
+
node.kind === 'symlink'
|
|
748
|
+
&& !safeSymlinkTarget(rootPath, target, node.symlinkTarget)
|
|
749
|
+
) {
|
|
750
|
+
throw invalid(`workspace tree symlink escapes its root: ${to}`, 409);
|
|
751
|
+
}
|
|
752
|
+
return {
|
|
753
|
+
nodeId: node.id,
|
|
754
|
+
to,
|
|
755
|
+
targetParentNodeId: node.parentId || afterSnapshot.workspaceId,
|
|
756
|
+
kind: node.kind,
|
|
757
|
+
symlinkTarget: node.symlinkTarget,
|
|
758
|
+
};
|
|
759
|
+
});
|
|
760
|
+
const verifiedRootIdentity = rootIdentity
|
|
761
|
+
|| treeMaterialization.identity(rootPath, { deep: false });
|
|
762
|
+
return {
|
|
763
|
+
contract: 'workspace-tree-materialization@1',
|
|
764
|
+
resourceId: envelope.resourceId,
|
|
765
|
+
snapshotVersion: envelope.snapshotVersion,
|
|
766
|
+
snapshotChecksum: checksum,
|
|
767
|
+
rootPath,
|
|
768
|
+
rootIdentity: {
|
|
769
|
+
kind: 'folder',
|
|
770
|
+
dev: String(verifiedRootIdentity.dev ?? verifiedRootIdentity.deviceId),
|
|
771
|
+
ino: String(verifiedRootIdentity.ino ?? verifiedRootIdentity.inode),
|
|
772
|
+
},
|
|
773
|
+
envelope,
|
|
774
|
+
officialSnapshot: snapshotRecord(officialSnapshot || afterSnapshot),
|
|
775
|
+
snapshot: snapshotRecord(afterSnapshot),
|
|
776
|
+
permissions,
|
|
777
|
+
contentResources: Array.isArray(contentResources) ? contentResources : [],
|
|
778
|
+
initial,
|
|
779
|
+
workspaceName: after.get(afterSnapshot.workspaceId).name,
|
|
780
|
+
beforeNodes: Array.from(before.values()),
|
|
781
|
+
rollbackBindings,
|
|
782
|
+
projectedBindings,
|
|
783
|
+
moves,
|
|
784
|
+
removals,
|
|
785
|
+
creates,
|
|
786
|
+
removedNodeIds: Array.from(removedIds),
|
|
787
|
+
changedPaths: Array.from(changedIds).map((nodeId) => ({
|
|
788
|
+
nodeId,
|
|
789
|
+
oldPath: path.join(rootPath, ...beforePaths.get(nodeId).split('/')),
|
|
790
|
+
newPath: path.join(rootPath, ...afterPaths.get(nodeId).split('/')),
|
|
791
|
+
})),
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function applyStoredTreePlan(held, database, testHooks = {}) {
|
|
796
|
+
const treeMaterialization = require('./tree-materialization');
|
|
797
|
+
const plan = held.plan;
|
|
798
|
+
const replicas = require('../state/replicas');
|
|
799
|
+
const existingReplica = replicas.getReplicaByResourceId(plan.resourceId, { database });
|
|
800
|
+
const previousProjectedAuthority = projectedAuthorityEpoch(plan.resourceId, database);
|
|
801
|
+
if (held.state === 'committed') {
|
|
802
|
+
const workspace = workspaceRow(plan.resourceId, database);
|
|
803
|
+
const replica = require('../state/replicas')
|
|
804
|
+
.getReplicaByResourceId(plan.resourceId, { database });
|
|
805
|
+
if (!workspace || !replica || replica.appliedVersion < plan.snapshotVersion) {
|
|
806
|
+
throw invalid('committed workspace tree snapshot is missing its projection', 409);
|
|
807
|
+
}
|
|
808
|
+
finishTreeSnapshotPlan(held, database, testHooks);
|
|
809
|
+
return {
|
|
810
|
+
workspace,
|
|
811
|
+
replica,
|
|
812
|
+
contentAttachments: [],
|
|
813
|
+
duplicate: true,
|
|
814
|
+
recovered: true,
|
|
815
|
+
};
|
|
816
|
+
}
|
|
817
|
+
if (
|
|
818
|
+
existingReplica
|
|
819
|
+
&& Number(plan.envelope.authorityEpoch) < existingReplica.authorityEpoch
|
|
820
|
+
) {
|
|
821
|
+
throw invalid(
|
|
822
|
+
`workspace tree plan ${plan.resourceId}@${plan.snapshotVersion} has stale authority`,
|
|
823
|
+
409,
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
if (existingReplica) {
|
|
827
|
+
// A recovery record is not authority. Prove that its permission epoch is
|
|
828
|
+
// still the newest advertised epoch before closing documents or moving a
|
|
829
|
+
// single filesystem entry.
|
|
830
|
+
if (plan.projectionOnly !== true) {
|
|
831
|
+
const disposition = replicas.inspectReplicaSnapshot({
|
|
832
|
+
resourceId: plan.resourceId,
|
|
833
|
+
snapshotVersion: plan.snapshotVersion,
|
|
834
|
+
snapshotChecksum: plan.snapshotChecksum,
|
|
835
|
+
authorityEpoch: plan.envelope.authorityEpoch,
|
|
836
|
+
}, {
|
|
837
|
+
database,
|
|
838
|
+
allowAuthorityProjection: true,
|
|
839
|
+
projectedAuthorityEpoch: previousProjectedAuthority || existingReplica.authorityEpoch,
|
|
840
|
+
});
|
|
841
|
+
if (disposition.action !== 'install') {
|
|
842
|
+
throw invalid(
|
|
843
|
+
`workspace tree plan ${plan.resourceId}@${plan.snapshotVersion} is no longer installable`,
|
|
844
|
+
409,
|
|
845
|
+
);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
const snapshot = normalizeTreeSnapshot(plan.snapshot);
|
|
850
|
+
const officialSnapshot = normalizeTreeSnapshot(plan.officialSnapshot || plan.snapshot);
|
|
851
|
+
const paths = pathMapForSnapshot(snapshot);
|
|
852
|
+
require('../state/docs').closeDocsByNodeIds(new Set(plan.removedNodeIds));
|
|
853
|
+
if (typeof testHooks.beforeTreePlanCross === 'function') {
|
|
854
|
+
testHooks.beforeTreePlanCross(plan);
|
|
855
|
+
}
|
|
856
|
+
try {
|
|
857
|
+
held = treeMaterialization.cross(held, database, testHooks);
|
|
858
|
+
} catch (error) {
|
|
859
|
+
holdClassifiedPlanRetry(plan, error, database);
|
|
860
|
+
}
|
|
861
|
+
if (testHooks.crashAfterTreePlanCross === true) {
|
|
862
|
+
throw new Error('simulated workspace tree crash after snapshot crossing');
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
let workspace = workspaceRow(plan.resourceId, database);
|
|
866
|
+
if (!workspace) {
|
|
867
|
+
if (!plan.initial) {
|
|
868
|
+
throw invalid('workspace tree recovery has no local workspace', 409);
|
|
869
|
+
}
|
|
870
|
+
workspace = require('./store').upsertWorkspace({
|
|
871
|
+
id: plan.resourceId,
|
|
872
|
+
path: plan.rootPath,
|
|
873
|
+
name: plan.workspaceName,
|
|
874
|
+
source: 'cloud',
|
|
875
|
+
}, { source: 'workspace:cloud-attach', seedOwner: false });
|
|
876
|
+
}
|
|
877
|
+
const events = require('../state/events');
|
|
878
|
+
let registered;
|
|
879
|
+
let contentAttachments;
|
|
880
|
+
let event;
|
|
881
|
+
let finishDocRebind = null;
|
|
882
|
+
const attachmentEvents = [];
|
|
883
|
+
// The physical plan is already crossed. Publish every database fact that
|
|
884
|
+
// claims that result—including the cursor and the WAL commit—in one
|
|
885
|
+
// transaction. A crash may therefore leave the plan crossed and retryable,
|
|
886
|
+
// or committed and fully represented, but can never leave an advanced
|
|
887
|
+
// cursor pointing at an uncommitted plan.
|
|
888
|
+
database.transaction(() => {
|
|
889
|
+
replaceTreeProjection(
|
|
890
|
+
database,
|
|
891
|
+
workspace.id,
|
|
892
|
+
plan.rootPath,
|
|
893
|
+
snapshot,
|
|
894
|
+
paths,
|
|
895
|
+
plan.permissions,
|
|
896
|
+
plan.envelope.authorityEpoch,
|
|
897
|
+
);
|
|
898
|
+
if (plan.projectionOnly !== true) {
|
|
899
|
+
require('./tree/floor').write(
|
|
900
|
+
officialSnapshot,
|
|
901
|
+
plan.envelope.authorityEpoch,
|
|
902
|
+
{ database },
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
const existing = replicas.getReplicaByResourceId(plan.resourceId, { database });
|
|
907
|
+
registered = plan.projectionOnly === true
|
|
908
|
+
? { replica: existing }
|
|
909
|
+
: existing
|
|
910
|
+
? {
|
|
911
|
+
replica: replicas.advanceReplicaSnapshot({
|
|
912
|
+
resourceId: plan.resourceId,
|
|
913
|
+
snapshotVersion: plan.snapshotVersion,
|
|
914
|
+
snapshotChecksum: plan.snapshotChecksum,
|
|
915
|
+
authorityEpoch: plan.envelope.authorityEpoch,
|
|
916
|
+
}, {
|
|
917
|
+
database,
|
|
918
|
+
allowAuthorityProjection: true,
|
|
919
|
+
projectedAuthorityEpoch: previousProjectedAuthority || existing.authorityEpoch,
|
|
920
|
+
}),
|
|
921
|
+
}
|
|
922
|
+
: replicas.registerReplica({
|
|
923
|
+
resourceId: plan.resourceId,
|
|
924
|
+
localResource: `${TREE_RESOURCE_PREFIX}${workspace.id}`,
|
|
925
|
+
localPath: plan.rootPath,
|
|
926
|
+
contract: 'workspace-tree@1',
|
|
927
|
+
schemaVersion: 1,
|
|
928
|
+
authorityEpoch: plan.envelope.authorityEpoch,
|
|
929
|
+
appliedVersion: plan.snapshotVersion,
|
|
930
|
+
snapshotVersion: plan.snapshotVersion,
|
|
931
|
+
snapshotChecksum: plan.snapshotChecksum,
|
|
932
|
+
}, { database });
|
|
933
|
+
if (testHooks.crashAfterSnapshotReplicaAdvance === true) {
|
|
934
|
+
throw new Error('simulated workspace tree crash after snapshot replica advance');
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
if (plan.changedPaths.length) {
|
|
938
|
+
finishDocRebind = require('../state/docs').rebindNodePaths(
|
|
939
|
+
plan.changedPaths,
|
|
940
|
+
{ database, deferOpenDocs: true },
|
|
941
|
+
);
|
|
942
|
+
}
|
|
943
|
+
contentAttachments = enqueueProjectedContentAttachments(
|
|
944
|
+
plan.rootPath,
|
|
945
|
+
snapshot,
|
|
946
|
+
paths,
|
|
947
|
+
{
|
|
948
|
+
contentResources: plan.contentResources,
|
|
949
|
+
deferredEvents: attachmentEvents,
|
|
950
|
+
},
|
|
951
|
+
database,
|
|
952
|
+
);
|
|
953
|
+
if (testHooks.crashAfterSnapshotAttachmentEnqueue === true) {
|
|
954
|
+
throw new Error('simulated workspace tree crash after snapshot attachment enqueue');
|
|
955
|
+
}
|
|
956
|
+
if (plan.projectionOnly !== true) {
|
|
957
|
+
require('../state/mutations').completeOfficialSnapshotFold(
|
|
958
|
+
plan.resourceId,
|
|
959
|
+
plan.snapshotVersion,
|
|
960
|
+
{ database, contract: 'workspace-tree@1' },
|
|
961
|
+
);
|
|
962
|
+
}
|
|
963
|
+
event = events.insertStateEvent(database, {
|
|
964
|
+
resource: `${TREE_RESOURCE_PREFIX}${workspace.id}`,
|
|
965
|
+
op: 'update',
|
|
966
|
+
id: workspace.id,
|
|
967
|
+
value: workspaceTreeSnapshot(workspace.id, { database }),
|
|
968
|
+
sharedResourceId: workspace.id,
|
|
969
|
+
source: plan.projectionOnly === true
|
|
970
|
+
? 'workspace-tree:overlay-fold'
|
|
971
|
+
: plan.initial
|
|
972
|
+
? 'workspace-tree:cloud-snapshot'
|
|
973
|
+
: 'workspace-tree:cloud-resnapshot',
|
|
974
|
+
resourceVersion: plan.snapshotVersion,
|
|
975
|
+
});
|
|
976
|
+
held = treeMaterialization.markCommitted(held, database);
|
|
977
|
+
})();
|
|
978
|
+
if (finishDocRebind) finishDocRebind();
|
|
979
|
+
for (const attachmentEvent of attachmentEvents) {
|
|
980
|
+
events.publishStateEvent(attachmentEvent);
|
|
981
|
+
}
|
|
982
|
+
events.publishStateEvent(event);
|
|
983
|
+
if (testHooks.crashAfterCommit === true) {
|
|
984
|
+
throw new Error('simulated workspace tree crash after commit');
|
|
985
|
+
}
|
|
986
|
+
finishTreeSnapshotPlan(held, database, testHooks);
|
|
987
|
+
return {
|
|
988
|
+
workspace,
|
|
989
|
+
replica: registered.replica,
|
|
990
|
+
contentAttachments,
|
|
991
|
+
duplicate: false,
|
|
992
|
+
recovered: true,
|
|
993
|
+
};
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
function recoverTreePlans(resourceId, database, testHooks = {}, options = {}) {
|
|
997
|
+
let result = null;
|
|
998
|
+
const materialization = require('./tree-materialization');
|
|
999
|
+
for (const held of materialization.list(database, resourceId)) {
|
|
1000
|
+
if (
|
|
1001
|
+
options.minimumAuthorityEpoch != null
|
|
1002
|
+
&& Number(held.plan?.envelope?.authorityEpoch) < Number(options.minimumAuthorityEpoch)
|
|
1003
|
+
) {
|
|
1004
|
+
if (held.state === 'committed') {
|
|
1005
|
+
result = applyStoredTreePlan(held, database, testHooks);
|
|
1006
|
+
continue;
|
|
1007
|
+
}
|
|
1008
|
+
let superseded = false;
|
|
1009
|
+
try {
|
|
1010
|
+
superseded = materialization.supersedeUncommitted(held, database, {
|
|
1011
|
+
testHooks,
|
|
1012
|
+
captureRetainedOutputs(retainedOutputs, plan) {
|
|
1013
|
+
retainRemovalSuccessors(
|
|
1014
|
+
plan,
|
|
1015
|
+
[{ retainedOutputs }],
|
|
1016
|
+
database,
|
|
1017
|
+
{ reuseIds: true },
|
|
1018
|
+
);
|
|
1019
|
+
},
|
|
1020
|
+
});
|
|
1021
|
+
} catch (error) {
|
|
1022
|
+
holdClassifiedPlanRetry(held.plan, error, database);
|
|
1023
|
+
}
|
|
1024
|
+
if (!superseded) {
|
|
1025
|
+
throw invalid(
|
|
1026
|
+
`workspace tree stale plan ${held.resourceId}@${held.snapshotVersion} could not roll back`,
|
|
1027
|
+
409,
|
|
1028
|
+
);
|
|
1029
|
+
}
|
|
1030
|
+
continue;
|
|
1031
|
+
}
|
|
1032
|
+
result = applyStoredTreePlan(held, database, testHooks);
|
|
1033
|
+
}
|
|
1034
|
+
return result;
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
function recoverAllTreePlans(options = {}) {
|
|
1038
|
+
const database = options.database || openLocalDb();
|
|
1039
|
+
const results = [];
|
|
1040
|
+
for (const held of require('./tree-materialization').list(database)) {
|
|
1041
|
+
results.push(applyStoredTreePlan(held, database, options.testHooks));
|
|
1042
|
+
}
|
|
1043
|
+
for (const held of require('./tree-materialization').listOperations(database)) {
|
|
1044
|
+
results.push(applyStoredTreeOperationPlan(held, database, options.testHooks));
|
|
1045
|
+
}
|
|
1046
|
+
// A legacy carrier can be temporarily absent while one of the plans above
|
|
1047
|
+
// owns it in private staging. Only the recovered physical slot may assign
|
|
1048
|
+
// its component UUID; timestamps are never a fallback.
|
|
1049
|
+
require('../state/db').backfillWorkspaceTrashComponents(database);
|
|
1050
|
+
return results;
|
|
218
1051
|
}
|
|
219
1052
|
|
|
220
1053
|
function enqueueProjectedContentAttachments(rootPath, snapshot, paths, input, database) {
|
|
221
1054
|
const resources = Array.isArray(input?.contentResources) ? input.contentResources : [];
|
|
1055
|
+
const deferredEvents = Array.isArray(input?.deferredEvents) ? input.deferredEvents : null;
|
|
222
1056
|
const queued = [];
|
|
223
1057
|
for (const resource of resources) {
|
|
224
1058
|
const nodeId = String(resource?.node_id || resource?.nodeId || '').toLowerCase();
|
|
@@ -227,11 +1061,13 @@ function enqueueProjectedContentAttachments(rootPath, snapshot, paths, input, da
|
|
|
227
1061
|
if (require('../state/replicas').getReplicaByResourceId(nodeId, { database })) continue;
|
|
228
1062
|
if (require('../state/attachments').getAttachmentByResource(nodeId, { database })) continue;
|
|
229
1063
|
const localPath = path.join(rootPath, ...paths.get(nodeId).split('/'));
|
|
230
|
-
|
|
1064
|
+
const result = require('../state/attachments').enqueueAttachment({
|
|
231
1065
|
attachmentId: `att_${crypto.randomBytes(16).toString('base64url')}`,
|
|
232
1066
|
resourceId: nodeId,
|
|
233
1067
|
localPath,
|
|
234
|
-
}, { database })
|
|
1068
|
+
}, { database, deferEvent: deferredEvents !== null });
|
|
1069
|
+
queued.push(result.attachment);
|
|
1070
|
+
if (deferredEvents && result.deferredEvent) deferredEvents.push(result.deferredEvent);
|
|
235
1071
|
}
|
|
236
1072
|
return queued;
|
|
237
1073
|
}
|
|
@@ -253,139 +1089,2618 @@ function enqueueWorkspaceContentAttachments(workspaceId, contentResources, optio
|
|
|
253
1089
|
);
|
|
254
1090
|
}
|
|
255
1091
|
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
1092
|
+
/**
|
|
1093
|
+
* A document mutation can temporarily own tree membership while an official
|
|
1094
|
+
* tree snapshot omits that UUID. When the last document overlay folds, derive
|
|
1095
|
+
* the physical tree again from the unchanged official floor plus the now
|
|
1096
|
+
* smaller durable overlay. This is an explicit projection transition, not an
|
|
1097
|
+
* observer guess.
|
|
1098
|
+
*/
|
|
1099
|
+
function reconcileContentOverlay(nodeId, options = {}) {
|
|
1100
|
+
const database = options.database || openLocalDb();
|
|
1101
|
+
const row = database.prepare(`
|
|
1102
|
+
SELECT workspace_id FROM workspace_nodes WHERE id = ?
|
|
1103
|
+
`).get(nodeId);
|
|
1104
|
+
if (!row) return { changed: false, reason: 'node-not-projected' };
|
|
1105
|
+
const workspace = workspaceRow(row.workspace_id, database);
|
|
1106
|
+
const replica = require('../state/replicas')
|
|
1107
|
+
.getReplicaByResourceId(row.workspace_id, { database });
|
|
1108
|
+
const floor = require('./tree/floor').read(row.workspace_id, { database });
|
|
1109
|
+
if (!workspace || !replica || !floor) {
|
|
1110
|
+
return { changed: false, reason: 'tree-not-cloud-backed' };
|
|
1111
|
+
}
|
|
1112
|
+
const rootProof = localTree().verifiedWorkspaceRoot(workspace, { database });
|
|
1113
|
+
const before = new Map(
|
|
1114
|
+
database.prepare('SELECT * FROM workspace_nodes WHERE workspace_id = ?').all(workspace.id)
|
|
1115
|
+
.map(rowToNode)
|
|
1116
|
+
.map((node) => [node.id, node]),
|
|
1117
|
+
);
|
|
1118
|
+
let projected = snapshotWithLocalOverlay(
|
|
1119
|
+
floor.snapshot,
|
|
1120
|
+
before,
|
|
1121
|
+
workspace.id,
|
|
1122
|
+
database,
|
|
1123
|
+
);
|
|
1124
|
+
if (projectionIdentity(before) === projectionIdentity(projected.nodes)) {
|
|
1125
|
+
return { changed: false, reason: 'projection-current' };
|
|
259
1126
|
}
|
|
1127
|
+
const beforePaths = pathMapForSnapshot({ workspaceId: workspace.id, nodes: before });
|
|
1128
|
+
const removalCapture = captureRemovalPreconditions(
|
|
1129
|
+
workspace.path,
|
|
1130
|
+
before,
|
|
1131
|
+
projected,
|
|
1132
|
+
beforePaths,
|
|
1133
|
+
);
|
|
1134
|
+
require('../state/docs').captureDocsByNodeIds(
|
|
1135
|
+
removalCapture.removedIds,
|
|
1136
|
+
{ database },
|
|
1137
|
+
);
|
|
1138
|
+
projected = snapshotWithLocalOverlay(
|
|
1139
|
+
floor.snapshot,
|
|
1140
|
+
before,
|
|
1141
|
+
workspace.id,
|
|
1142
|
+
database,
|
|
1143
|
+
);
|
|
1144
|
+
if (projectionIdentity(before) === projectionIdentity(projected.nodes)) {
|
|
1145
|
+
return { changed: false, reason: 'newer-document-overlay' };
|
|
1146
|
+
}
|
|
1147
|
+
const afterPaths = pathMapForSnapshot(projected);
|
|
1148
|
+
const permissions = database.prepare(`
|
|
1149
|
+
SELECT node_id AS nodeId, user_id AS userId, role
|
|
1150
|
+
FROM workspace_node_permissions
|
|
1151
|
+
WHERE workspace_id = ? AND revoked_at IS NULL
|
|
1152
|
+
`).all(workspace.id);
|
|
1153
|
+
const checksum = crypto.createHash('sha256')
|
|
1154
|
+
.update(JSON.stringify({
|
|
1155
|
+
kind: 'workspace-tree-overlay-fold@1',
|
|
1156
|
+
nodeId,
|
|
1157
|
+
floorVersion: floor.version,
|
|
1158
|
+
projection: snapshotRecord(projected),
|
|
1159
|
+
}))
|
|
1160
|
+
.digest('hex');
|
|
1161
|
+
const plan = {
|
|
1162
|
+
...physicalPlan({
|
|
1163
|
+
rootPath: workspace.path,
|
|
1164
|
+
rootIdentity: rootProof.identity,
|
|
1165
|
+
before,
|
|
1166
|
+
officialSnapshot: floor.snapshot,
|
|
1167
|
+
afterSnapshot: projected,
|
|
1168
|
+
beforePaths,
|
|
1169
|
+
afterPaths,
|
|
1170
|
+
envelope: {
|
|
1171
|
+
resourceId: workspace.id,
|
|
1172
|
+
contract: 'workspace-tree@1',
|
|
1173
|
+
schemaVersion: 1,
|
|
1174
|
+
authorityEpoch: floor.authorityEpoch,
|
|
1175
|
+
snapshotVersion: floor.version,
|
|
1176
|
+
headVersion: replica.appliedVersion,
|
|
1177
|
+
},
|
|
1178
|
+
checksum,
|
|
1179
|
+
permissions,
|
|
1180
|
+
contentResources: [],
|
|
1181
|
+
initial: false,
|
|
1182
|
+
removalPreconditions: removalCapture.preconditions,
|
|
1183
|
+
}),
|
|
1184
|
+
projectionOnly: true,
|
|
1185
|
+
overlayFoldNodeId: nodeId,
|
|
1186
|
+
};
|
|
1187
|
+
const held = require('./tree-materialization').insert(plan, database);
|
|
1188
|
+
const result = applyStoredTreePlan(held, database, options.testHooks);
|
|
1189
|
+
return { ...result, changed: true };
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
function installCloudTreeSnapshot(pathInput, input, options = {}) {
|
|
1193
|
+
const replicas = require('../state/replicas');
|
|
1194
|
+
const envelope = replicas.validateSnapshotEnvelope(input, {
|
|
1195
|
+
contract: 'workspace-tree@1',
|
|
1196
|
+
schemaVersion: 1,
|
|
1197
|
+
});
|
|
260
1198
|
const database = options.database || openLocalDb();
|
|
261
1199
|
const { checksum, snapshot } = decodeTreeSnapshot(input);
|
|
262
1200
|
if (snapshot.workspaceId !== input.resourceId) throw invalid('workspace tree identity mismatch', 409);
|
|
1201
|
+
if (snapshot.version !== envelope.snapshotVersion) {
|
|
1202
|
+
throw invalid('workspace tree snapshot version mismatch', 409);
|
|
1203
|
+
}
|
|
263
1204
|
const rootPath = path.resolve(pathInput);
|
|
264
|
-
|
|
265
|
-
|
|
1205
|
+
let existingReplica = replicas.getReplicaByResourceId(input.resourceId, { database });
|
|
1206
|
+
let rootProof = null;
|
|
266
1207
|
const paths = pathMapForSnapshot(snapshot);
|
|
267
1208
|
const permissions = normalizeSnapshotPermissions(input, snapshot);
|
|
268
1209
|
if (existingReplica) {
|
|
1210
|
+
const existingWorkspace = workspaceRow(input.resourceId, database);
|
|
1211
|
+
if (!existingWorkspace) {
|
|
1212
|
+
throw invalid('workspace tree has no registered local folder', 409);
|
|
1213
|
+
}
|
|
1214
|
+
rootProof = localTree().verifiedWorkspaceRoot(existingWorkspace, { database });
|
|
269
1215
|
if (existingReplica.localPath !== rootPath) throw invalid('workspace tree is bound to another folder', 409);
|
|
1216
|
+
const incomingVersion = envelope.snapshotVersion;
|
|
1217
|
+
// Validate the incoming authority before recovering any old physical
|
|
1218
|
+
// plan. A prepared plan from an older permission epoch is canceled while
|
|
1219
|
+
// it is still only a control record; it is never allowed to touch disk.
|
|
1220
|
+
replicas.inspectReplicaSnapshot({
|
|
1221
|
+
resourceId: envelope.resourceId,
|
|
1222
|
+
snapshotVersion: incomingVersion,
|
|
1223
|
+
snapshotChecksum: checksum,
|
|
1224
|
+
authorityEpoch: envelope.authorityEpoch,
|
|
1225
|
+
}, {
|
|
1226
|
+
database,
|
|
1227
|
+
allowAuthorityProjection: true,
|
|
1228
|
+
projectedAuthorityEpoch: projectedAuthorityEpoch(envelope.resourceId, database)
|
|
1229
|
+
|| existingReplica.authorityEpoch,
|
|
1230
|
+
});
|
|
1231
|
+
recoverTreePlans(
|
|
1232
|
+
envelope.resourceId,
|
|
1233
|
+
database,
|
|
1234
|
+
options.testHooks,
|
|
1235
|
+
{ minimumAuthorityEpoch: envelope.authorityEpoch },
|
|
1236
|
+
);
|
|
1237
|
+
existingReplica = replicas.getReplicaByResourceId(input.resourceId, { database });
|
|
1238
|
+
const disposition = replicas.inspectReplicaSnapshot({
|
|
1239
|
+
resourceId: envelope.resourceId,
|
|
1240
|
+
snapshotVersion: incomingVersion,
|
|
1241
|
+
snapshotChecksum: checksum,
|
|
1242
|
+
authorityEpoch: envelope.authorityEpoch,
|
|
1243
|
+
}, {
|
|
1244
|
+
database,
|
|
1245
|
+
allowAuthorityProjection: true,
|
|
1246
|
+
projectedAuthorityEpoch: projectedAuthorityEpoch(envelope.resourceId, database)
|
|
1247
|
+
|| existingReplica.authorityEpoch,
|
|
1248
|
+
});
|
|
1249
|
+
if (disposition.action !== 'install') {
|
|
1250
|
+
const checked = replicas.advanceReplicaSnapshot({
|
|
1251
|
+
resourceId: envelope.resourceId,
|
|
1252
|
+
snapshotVersion: incomingVersion,
|
|
1253
|
+
snapshotChecksum: checksum,
|
|
1254
|
+
authorityEpoch: envelope.authorityEpoch,
|
|
1255
|
+
}, {
|
|
1256
|
+
database,
|
|
1257
|
+
allowAuthorityProjection: true,
|
|
1258
|
+
projectedAuthorityEpoch: projectedAuthorityEpoch(envelope.resourceId, database)
|
|
1259
|
+
|| existingReplica.authorityEpoch,
|
|
1260
|
+
});
|
|
1261
|
+
if (
|
|
1262
|
+
disposition.action === 'duplicate'
|
|
1263
|
+
|| disposition.action === 'metadata'
|
|
1264
|
+
) {
|
|
1265
|
+
return { replica: checked, contentAttachments: [], duplicate: true };
|
|
1266
|
+
}
|
|
1267
|
+
return {
|
|
1268
|
+
replica: checked,
|
|
1269
|
+
contentAttachments: [],
|
|
1270
|
+
duplicate: true,
|
|
1271
|
+
stale: true,
|
|
1272
|
+
};
|
|
1273
|
+
}
|
|
1274
|
+
const captureBefore = new Map(
|
|
1275
|
+
database.prepare('SELECT * FROM workspace_nodes WHERE workspace_id = ?').all(snapshot.workspaceId)
|
|
1276
|
+
.map(rowToNode)
|
|
1277
|
+
.map((node) => [node.id, node]),
|
|
1278
|
+
);
|
|
1279
|
+
const captureBeforePaths = pathMapForSnapshot({
|
|
1280
|
+
workspaceId: snapshot.workspaceId,
|
|
1281
|
+
nodes: captureBefore,
|
|
1282
|
+
});
|
|
1283
|
+
const captureProjected = snapshotWithLocalOverlay(
|
|
1284
|
+
snapshot,
|
|
1285
|
+
captureBefore,
|
|
1286
|
+
envelope.resourceId,
|
|
1287
|
+
database,
|
|
1288
|
+
);
|
|
1289
|
+
const removalCapture = captureRemovalPreconditions(
|
|
1290
|
+
rootPath,
|
|
1291
|
+
captureBefore,
|
|
1292
|
+
captureProjected,
|
|
1293
|
+
captureBeforePaths,
|
|
1294
|
+
);
|
|
1295
|
+
// A save already present when this snapshot arrives gets its document
|
|
1296
|
+
// birth certificate after the physical predecessor was sealed. A save
|
|
1297
|
+
// after this point changes the sealed subtree digest and stops crossing.
|
|
1298
|
+
require('../state/docs').captureDocsByNodeIds(
|
|
1299
|
+
removalCapture.removedIds,
|
|
1300
|
+
{ database },
|
|
1301
|
+
);
|
|
1302
|
+
const observed = localTree().reconcileWorkspaceTree(snapshot.workspaceId, {
|
|
1303
|
+
database,
|
|
1304
|
+
source: 'workspace-tree:pre-snapshot',
|
|
1305
|
+
});
|
|
1306
|
+
if (observed.held?.length) {
|
|
1307
|
+
throw invalid('workspace tree has held local changes', 409);
|
|
1308
|
+
}
|
|
1309
|
+
if (typeof options.testHooks?.afterTreeReconcile === 'function') {
|
|
1310
|
+
options.testHooks.afterTreeReconcile();
|
|
1311
|
+
}
|
|
270
1312
|
const before = new Map(
|
|
271
1313
|
database.prepare('SELECT * FROM workspace_nodes WHERE workspace_id = ?').all(snapshot.workspaceId)
|
|
272
1314
|
.map(rowToNode)
|
|
273
1315
|
.map((node) => [node.id, node]),
|
|
274
1316
|
);
|
|
275
1317
|
const beforePaths = pathMapForSnapshot({ workspaceId: snapshot.workspaceId, nodes: before });
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
1318
|
+
const projected = snapshotWithLocalOverlay(
|
|
1319
|
+
snapshot,
|
|
1320
|
+
before,
|
|
1321
|
+
envelope.resourceId,
|
|
1322
|
+
database,
|
|
1323
|
+
);
|
|
1324
|
+
const projectedPaths = pathMapForSnapshot(projected);
|
|
1325
|
+
const plan = physicalPlan({
|
|
1326
|
+
rootPath,
|
|
1327
|
+
rootIdentity: rootProof.identity,
|
|
1328
|
+
before,
|
|
1329
|
+
officialSnapshot: snapshot,
|
|
1330
|
+
afterSnapshot: projected,
|
|
1331
|
+
beforePaths,
|
|
1332
|
+
afterPaths: projectedPaths,
|
|
1333
|
+
envelope,
|
|
1334
|
+
checksum,
|
|
1335
|
+
permissions,
|
|
1336
|
+
contentResources: input.contentResources,
|
|
1337
|
+
initial: false,
|
|
1338
|
+
removalPreconditions: removalCapture.preconditions,
|
|
292
1339
|
});
|
|
293
|
-
|
|
1340
|
+
const held = require('./tree-materialization').insert(plan, database);
|
|
1341
|
+
return applyStoredTreePlan(held, database, options.testHooks);
|
|
294
1342
|
}
|
|
1343
|
+
const recovered = recoverTreePlans(
|
|
1344
|
+
envelope.resourceId,
|
|
1345
|
+
database,
|
|
1346
|
+
options.testHooks,
|
|
1347
|
+
{ minimumAuthorityEpoch: envelope.authorityEpoch },
|
|
1348
|
+
);
|
|
1349
|
+
if (recovered) return recovered;
|
|
1350
|
+
fs.mkdirSync(rootPath, { recursive: true });
|
|
295
1351
|
if (fs.readdirSync(rootPath).length > 0) {
|
|
296
1352
|
throw invalid('cloud workspace attachment requires an empty folder', 409);
|
|
297
1353
|
}
|
|
298
1354
|
|
|
299
|
-
const
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
1355
|
+
const plan = physicalPlan({
|
|
1356
|
+
rootPath,
|
|
1357
|
+
before: new Map(),
|
|
1358
|
+
officialSnapshot: snapshot,
|
|
1359
|
+
afterSnapshot: snapshot,
|
|
1360
|
+
beforePaths: new Map(),
|
|
1361
|
+
afterPaths: paths,
|
|
1362
|
+
envelope,
|
|
1363
|
+
checksum,
|
|
1364
|
+
permissions,
|
|
1365
|
+
contentResources: input.contentResources,
|
|
1366
|
+
initial: true,
|
|
1367
|
+
});
|
|
1368
|
+
const held = require('./tree-materialization').insert(plan, database);
|
|
1369
|
+
return applyStoredTreePlan(held, database, options.testHooks);
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
function operationPath(workspace, nodes, nodeId) {
|
|
1373
|
+
const relative = relativePathForNode(nodes, nodeId);
|
|
1374
|
+
return relative ? path.join(workspace.path, ...relative.split('/')) : workspace.path;
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
function retainedRecoveryTarget(workspace, nodes, nodeId, mutationId) {
|
|
1378
|
+
const node = nodes.get(nodeId);
|
|
1379
|
+
let parent = node ? nodes.get(node.parentId) : null;
|
|
1380
|
+
while (parent && (parent.trashedAt || parent.purgedAt)) {
|
|
1381
|
+
parent = nodes.get(parent.parentId) || null;
|
|
312
1382
|
}
|
|
1383
|
+
if (!node || !parent || parent.kind !== 'folder') {
|
|
1384
|
+
throw invalid(`purge retention has no live parent for ${nodeId}`, 409);
|
|
1385
|
+
}
|
|
1386
|
+
const extension = node.kind === 'folder' ? '' : path.extname(node.name);
|
|
1387
|
+
const stem = extension ? node.name.slice(0, -extension.length) : node.name;
|
|
1388
|
+
const token = crypto.createHash('sha256')
|
|
1389
|
+
.update(`${mutationId}\0${nodeId}`)
|
|
1390
|
+
.digest('hex')
|
|
1391
|
+
.slice(0, 12);
|
|
1392
|
+
const marker = `.amalgm-retained-${token}`;
|
|
1393
|
+
const keptExtension = extension.slice(-32);
|
|
1394
|
+
const keptStem = stem.slice(0, Math.max(1, 255 - marker.length - keptExtension.length));
|
|
1395
|
+
return {
|
|
1396
|
+
path: path.join(
|
|
1397
|
+
operationPath(workspace, nodes, parent.id),
|
|
1398
|
+
`${keptStem}${marker}${keptExtension}`,
|
|
1399
|
+
),
|
|
1400
|
+
parentNodeId: parent.id,
|
|
1401
|
+
};
|
|
1402
|
+
}
|
|
313
1403
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
resource: `${TREE_RESOURCE_PREFIX}${workspace.id}`,
|
|
328
|
-
op: 'update',
|
|
329
|
-
id: workspace.id,
|
|
330
|
-
value: workspaceTreeSnapshot(workspace.id, { database }),
|
|
331
|
-
sharedResourceId: workspace.id,
|
|
332
|
-
source: 'workspace-tree:cloud-snapshot',
|
|
333
|
-
resourceVersion: Number(input.snapshotVersion),
|
|
334
|
-
});
|
|
335
|
-
return { workspace, replica: registered.replica, contentAttachments, duplicate: false };
|
|
1404
|
+
function trashComponentRoot(nodes, nodeId) {
|
|
1405
|
+
const node = nodes.get(nodeId);
|
|
1406
|
+
if (!node?.trashedAt || node.purgedAt) return null;
|
|
1407
|
+
const root = nodes.get(node.trashComponentId || node.id);
|
|
1408
|
+
if (
|
|
1409
|
+
!root
|
|
1410
|
+
|| !root.trashedAt
|
|
1411
|
+
|| root.purgedAt
|
|
1412
|
+
|| !descendantsInclude(nodes, root.id, node.id)
|
|
1413
|
+
) {
|
|
1414
|
+
throw invalid(`trash component ${node.trashComponentId || node.id} is invalid`, 409);
|
|
1415
|
+
}
|
|
1416
|
+
return root;
|
|
336
1417
|
}
|
|
337
1418
|
|
|
338
|
-
function
|
|
339
|
-
const
|
|
340
|
-
if (
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
1419
|
+
function physicalNodePath(workspace, nodes, nodeId, options = {}) {
|
|
1420
|
+
const node = nodes.get(nodeId);
|
|
1421
|
+
if (!node || node.id === workspace.id || node.purgedAt) return null;
|
|
1422
|
+
if (!node.trashedAt) return operationPath(workspace, nodes, nodeId);
|
|
1423
|
+
const root = trashComponentRoot(nodes, nodeId);
|
|
1424
|
+
if (!root) return null;
|
|
1425
|
+
if (
|
|
1426
|
+
options.availableTrashRoots
|
|
1427
|
+
&& !options.availableTrashRoots.has(root.id)
|
|
1428
|
+
) {
|
|
1429
|
+
return null;
|
|
344
1430
|
}
|
|
345
|
-
const
|
|
346
|
-
|
|
347
|
-
const
|
|
348
|
-
|
|
349
|
-
const
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
1431
|
+
const rootPath = trashPath(workspace.id, root.id, workspace.path);
|
|
1432
|
+
if (root.id === node.id) return rootPath;
|
|
1433
|
+
const relative = relativePathForNode(nodes, node.id);
|
|
1434
|
+
const rootRelative = relativePathForNode(nodes, root.id);
|
|
1435
|
+
const suffix = descendantSuffix(relative, rootRelative);
|
|
1436
|
+
return suffix ? path.join(rootPath, ...suffix.split('/')) : null;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
function physicalParentNodeId(workspace, nodes, nodeId) {
|
|
1440
|
+
const node = nodes.get(nodeId);
|
|
1441
|
+
if (!node || node.id === workspace.id) return null;
|
|
1442
|
+
if (!node.trashedAt) return node.parentId || workspace.id;
|
|
1443
|
+
const root = trashComponentRoot(nodes, nodeId);
|
|
1444
|
+
return root?.id === node.id ? null : node.parentId;
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
function completeOperationBindings(workspace, nodes) {
|
|
1448
|
+
const bindings = [];
|
|
1449
|
+
for (const node of Array.from(nodes.values()).sort((left, right) => (
|
|
1450
|
+
left.id.localeCompare(right.id)
|
|
1451
|
+
))) {
|
|
1452
|
+
if (node.id === workspace.id || node.purgedAt) continue;
|
|
1453
|
+
const source = physicalNodePath(workspace, nodes, node.id);
|
|
1454
|
+
if (!source || !pathExists(source)) continue;
|
|
1455
|
+
bindings.push({
|
|
1456
|
+
nodeId: node.id,
|
|
1457
|
+
from: source,
|
|
1458
|
+
kind: node.kind,
|
|
1459
|
+
symlinkTarget: node.symlinkTarget,
|
|
1460
|
+
identity: require('./tree-materialization').identity(source, { deep: false }),
|
|
1461
|
+
});
|
|
1462
|
+
}
|
|
1463
|
+
return bindings;
|
|
1464
|
+
}
|
|
1465
|
+
|
|
1466
|
+
function fullPhysicalDeltaActions(workspace, before, after) {
|
|
1467
|
+
const treeMaterialization = require('./tree-materialization');
|
|
1468
|
+
const beforePaths = new Map();
|
|
1469
|
+
const availableTrashRoots = new Set();
|
|
1470
|
+
for (const node of before.values()) {
|
|
1471
|
+
if (node.id === workspace.id || node.purgedAt) continue;
|
|
1472
|
+
const source = physicalNodePath(workspace, before, node.id);
|
|
1473
|
+
if (source && pathExists(source)) {
|
|
1474
|
+
beforePaths.set(node.id, source);
|
|
1475
|
+
const root = trashComponentRoot(before, node.id);
|
|
1476
|
+
if (root && root.id === node.id) availableTrashRoots.add(root.id);
|
|
1477
|
+
}
|
|
1478
|
+
}
|
|
1479
|
+
// A visible component that this transition trashes carries its complete
|
|
1480
|
+
// physical subtree into the deterministic trash root. If the root folder
|
|
1481
|
+
// itself disappeared but any descendant inode survived, create that root
|
|
1482
|
+
// explicitly so the survivor has a physical destination. A wholly absent
|
|
1483
|
+
// component stays absent; the planner never invents empty trash merely to
|
|
1484
|
+
// make the database shape easier to realize.
|
|
1485
|
+
for (const node of after.values()) {
|
|
1486
|
+
if (!beforePaths.has(node.id)) continue;
|
|
1487
|
+
const root = trashComponentRoot(after, node.id);
|
|
1488
|
+
if (root) availableTrashRoots.add(root.id);
|
|
1489
|
+
}
|
|
1490
|
+
const afterPaths = new Map();
|
|
1491
|
+
for (const node of after.values()) {
|
|
1492
|
+
if (node.id === workspace.id || node.purgedAt) continue;
|
|
1493
|
+
const target = physicalNodePath(workspace, after, node.id, {
|
|
1494
|
+
availableTrashRoots,
|
|
1495
|
+
});
|
|
1496
|
+
if (target) afterPaths.set(node.id, target);
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
const moveIds = new Set();
|
|
1500
|
+
for (const [nodeId, source] of beforePaths) {
|
|
1501
|
+
const target = afterPaths.get(nodeId);
|
|
1502
|
+
if (!target) continue;
|
|
1503
|
+
const previous = before.get(nodeId);
|
|
1504
|
+
const projected = after.get(nodeId);
|
|
1505
|
+
if (
|
|
1506
|
+
source !== target
|
|
1507
|
+
|| previous?.parentId !== projected?.parentId
|
|
1508
|
+
|| previous?.trashedAt !== projected?.trashedAt
|
|
1509
|
+
) {
|
|
1510
|
+
moveIds.add(nodeId);
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
const moves = [];
|
|
1514
|
+
const carriers = [];
|
|
1515
|
+
for (const nodeId of Array.from(moveIds).sort((left, right) => (
|
|
1516
|
+
beforePaths.get(left).localeCompare(beforePaths.get(right))
|
|
1517
|
+
))) {
|
|
1518
|
+
const source = beforePaths.get(nodeId);
|
|
1519
|
+
const target = afterPaths.get(nodeId);
|
|
1520
|
+
while (
|
|
1521
|
+
carriers.length
|
|
1522
|
+
&& !source.startsWith(`${carriers[carriers.length - 1].source}${path.sep}`)
|
|
1523
|
+
) {
|
|
1524
|
+
carriers.pop();
|
|
1525
|
+
}
|
|
1526
|
+
const carrier = carriers[carriers.length - 1] || null;
|
|
1527
|
+
if (carrier) {
|
|
1528
|
+
const suffix = path.relative(carrier.source, source);
|
|
1529
|
+
if (
|
|
1530
|
+
suffix
|
|
1531
|
+
&& path.join(carrier.target, suffix) === target
|
|
1532
|
+
&& before.get(nodeId)?.parentId === after.get(nodeId)?.parentId
|
|
1533
|
+
) {
|
|
1534
|
+
continue;
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
const node = before.get(nodeId);
|
|
1538
|
+
const projected = after.get(nodeId);
|
|
1539
|
+
const move = {
|
|
1540
|
+
type: 'move',
|
|
1541
|
+
nodeId,
|
|
1542
|
+
source,
|
|
1543
|
+
target,
|
|
1544
|
+
sourceParentNodeId: physicalParentNodeId(workspace, before, nodeId),
|
|
1545
|
+
targetParentNodeId: physicalParentNodeId(workspace, after, nodeId),
|
|
1546
|
+
kind: node.kind,
|
|
1547
|
+
symlinkTarget: node.symlinkTarget,
|
|
1548
|
+
identity: treeMaterialization.identity(source, { deep: false }),
|
|
1549
|
+
};
|
|
1550
|
+
moves.push(move);
|
|
1551
|
+
carriers.push(move);
|
|
1552
|
+
if (
|
|
1553
|
+
node.kind !== projected.kind
|
|
1554
|
+
|| node.symlinkTarget !== projected.symlinkTarget
|
|
1555
|
+
) {
|
|
1556
|
+
throw invalid(`workspace tree node ${nodeId} changed physical kind`, 409);
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1560
|
+
const movedSources = moves.map((move) => move.source);
|
|
1561
|
+
const removedIds = new Set(
|
|
1562
|
+
Array.from(beforePaths.keys()).filter((nodeId) => !afterPaths.has(nodeId)),
|
|
1563
|
+
);
|
|
1564
|
+
const removalRoots = Array.from(removedIds).filter((nodeId) => {
|
|
1565
|
+
let parentId = before.get(nodeId)?.parentId;
|
|
1566
|
+
while (parentId) {
|
|
1567
|
+
if (removedIds.has(parentId)) return false;
|
|
1568
|
+
parentId = before.get(parentId)?.parentId;
|
|
1569
|
+
}
|
|
1570
|
+
return true;
|
|
1571
|
+
});
|
|
1572
|
+
const removals = removalRoots.map((nodeId) => {
|
|
1573
|
+
const source = beforePaths.get(nodeId);
|
|
1574
|
+
const excludedPrefixes = movedSources
|
|
1575
|
+
.filter((candidate) => candidate.startsWith(`${source}${path.sep}`))
|
|
1576
|
+
.map((candidate) => path.relative(source, candidate).split(path.sep).join('/'));
|
|
1577
|
+
const precondition = treeMaterialization.removalPrecondition(source, {
|
|
1578
|
+
excludedPrefixes,
|
|
1579
|
+
});
|
|
1580
|
+
return {
|
|
1581
|
+
type: 'remove',
|
|
1582
|
+
nodeId,
|
|
1583
|
+
source,
|
|
1584
|
+
sourceParentNodeId: physicalParentNodeId(workspace, before, nodeId),
|
|
1585
|
+
retainTarget: source,
|
|
1586
|
+
identity: precondition.identity,
|
|
1587
|
+
retirement: precondition.retirement,
|
|
1588
|
+
};
|
|
1589
|
+
});
|
|
1590
|
+
|
|
1591
|
+
const creates = [];
|
|
1592
|
+
for (const [nodeId, target] of afterPaths) {
|
|
1593
|
+
if (beforePaths.has(nodeId)) continue;
|
|
1594
|
+
const node = after.get(nodeId);
|
|
1595
|
+
if (before.has(nodeId)) {
|
|
1596
|
+
if (node.kind !== 'folder') {
|
|
1597
|
+
throw invalid(
|
|
1598
|
+
`workspace tree node ${nodeId} has no physical source for projection`,
|
|
1599
|
+
409,
|
|
1600
|
+
);
|
|
1601
|
+
}
|
|
1602
|
+
// A missing trash root is created only when a surviving descendant
|
|
1603
|
+
// needs it as an explicit carrier.
|
|
1604
|
+
if (node.trashedAt && !availableTrashRoots.has(node.id)) continue;
|
|
1605
|
+
}
|
|
1606
|
+
creates.push({
|
|
1607
|
+
type: 'create',
|
|
1608
|
+
nodeId,
|
|
1609
|
+
target,
|
|
1610
|
+
targetParentNodeId: physicalParentNodeId(workspace, after, nodeId),
|
|
1611
|
+
kind: node.kind,
|
|
1612
|
+
symlinkTarget: node.symlinkTarget,
|
|
1613
|
+
});
|
|
1614
|
+
}
|
|
1615
|
+
return [...moves, ...removals, ...creates];
|
|
1616
|
+
}
|
|
1617
|
+
|
|
1618
|
+
function projectionIdentity(nodes) {
|
|
1619
|
+
return JSON.stringify(Array.from(nodes.values())
|
|
1620
|
+
.map((node) => ({
|
|
1621
|
+
id: node.id,
|
|
1622
|
+
workspaceId: node.workspaceId,
|
|
1623
|
+
parentId: node.parentId,
|
|
1624
|
+
kind: node.kind,
|
|
1625
|
+
name: node.name,
|
|
1626
|
+
symlinkTarget: node.symlinkTarget,
|
|
1627
|
+
trashedAt: node.trashedAt,
|
|
1628
|
+
purgedAt: node.purgedAt,
|
|
1629
|
+
}))
|
|
1630
|
+
.sort((left, right) => left.id.localeCompare(right.id)));
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
function physicalOperationPlan(
|
|
1634
|
+
workspace,
|
|
1635
|
+
before,
|
|
1636
|
+
after,
|
|
1637
|
+
operation,
|
|
1638
|
+
input,
|
|
1639
|
+
officialAfter = null,
|
|
1640
|
+
options = {},
|
|
1641
|
+
) {
|
|
1642
|
+
const treeMaterialization = require('./tree-materialization');
|
|
1643
|
+
validateLiveSymlinkTargets(after, workspace.id);
|
|
1644
|
+
const verifiedRootIdentity = options.rootIdentity || localTree()
|
|
1645
|
+
.verifiedWorkspaceRoot(workspace, { database: options.database })
|
|
1646
|
+
.identity;
|
|
1647
|
+
let actions = [];
|
|
1648
|
+
const physicalNoop = options.diskAlreadyApplied === true
|
|
1649
|
+
|| projectionIdentity(before) === projectionIdentity(after);
|
|
1650
|
+
if (physicalNoop) {
|
|
1651
|
+
// The numbered event moved the official floor underneath an identical
|
|
1652
|
+
// local overlay. Its physical echo is deliberately empty.
|
|
1653
|
+
} else if (options.projectFullDelta === true) {
|
|
1654
|
+
actions = fullPhysicalDeltaActions(workspace, before, after);
|
|
1655
|
+
} else if (operation.kind === 'tree.create') {
|
|
1656
|
+
const target = operationPath(workspace, after, operation.nodeId);
|
|
1657
|
+
if (pathExists(target)) {
|
|
1658
|
+
throw invalid('create destination already exists', 409);
|
|
1659
|
+
}
|
|
1660
|
+
if (
|
|
1661
|
+
operation.nodeKind === 'symlink'
|
|
1662
|
+
&& !safeSymlinkTarget(workspace.path, target, operation.symlinkTarget)
|
|
1663
|
+
) {
|
|
1664
|
+
throw invalid(`workspace tree symlink escapes its root: ${target}`, 409);
|
|
1665
|
+
}
|
|
1666
|
+
actions.push({
|
|
1667
|
+
type: 'create',
|
|
1668
|
+
nodeId: operation.nodeId,
|
|
1669
|
+
target,
|
|
1670
|
+
targetParentNodeId: after.get(operation.nodeId)?.parentId || workspace.id,
|
|
1671
|
+
kind: operation.nodeKind,
|
|
1672
|
+
symlinkTarget: operation.symlinkTarget || null,
|
|
1673
|
+
});
|
|
1674
|
+
} else if (operation.kind === 'tree.rename' || operation.kind === 'tree.move') {
|
|
1675
|
+
const source = operationPath(workspace, before, operation.nodeId);
|
|
1676
|
+
actions.push({
|
|
1677
|
+
type: 'move',
|
|
1678
|
+
nodeId: operation.nodeId,
|
|
1679
|
+
source,
|
|
1680
|
+
target: operationPath(workspace, after, operation.nodeId),
|
|
1681
|
+
sourceParentNodeId: before.get(operation.nodeId)?.parentId || workspace.id,
|
|
1682
|
+
targetParentNodeId: after.get(operation.nodeId)?.parentId || workspace.id,
|
|
1683
|
+
identity: treeMaterialization.identity(source, { deep: false }),
|
|
1684
|
+
});
|
|
1685
|
+
} else if (operation.kind === 'tree.trash') {
|
|
1686
|
+
const source = operationPath(workspace, before, operation.nodeId);
|
|
1687
|
+
actions.push({
|
|
1688
|
+
type: 'move',
|
|
1689
|
+
nodeId: operation.nodeId,
|
|
1690
|
+
source,
|
|
1691
|
+
target: trashPath(workspace.id, operation.nodeId, workspace.path),
|
|
1692
|
+
sourceParentNodeId: before.get(operation.nodeId)?.parentId || workspace.id,
|
|
1693
|
+
targetParentNodeId: null,
|
|
1694
|
+
identity: treeMaterialization.identity(source, { deep: false }),
|
|
1695
|
+
});
|
|
1696
|
+
} else if (operation.kind === 'tree.restore') {
|
|
1697
|
+
const source = physicalNodePath(workspace, before, operation.nodeId);
|
|
1698
|
+
if (!source || !pathExists(source)) {
|
|
1699
|
+
throw invalid(`restore source does not exist for ${operation.nodeId}`, 409);
|
|
1700
|
+
}
|
|
1701
|
+
actions.push({
|
|
1702
|
+
type: 'move',
|
|
1703
|
+
nodeId: operation.nodeId,
|
|
1704
|
+
source,
|
|
1705
|
+
target: operationPath(workspace, after, operation.nodeId),
|
|
1706
|
+
sourceParentNodeId: physicalParentNodeId(workspace, before, operation.nodeId),
|
|
1707
|
+
targetParentNodeId: after.get(operation.nodeId)?.parentId || workspace.id,
|
|
1708
|
+
identity: treeMaterialization.identity(source, { deep: false }),
|
|
1709
|
+
});
|
|
1710
|
+
} else if (operation.kind === 'tree.purge') {
|
|
1711
|
+
// One trashed component has one physical carrier. Address the requested
|
|
1712
|
+
// subtree through its shared locator, then add only independently trashed
|
|
1713
|
+
// descendant component roots. This retires each physical component once:
|
|
1714
|
+
// never one guessed UUID trash slot per logical descendant.
|
|
1715
|
+
const logicalRoots = new Set([operation.nodeId]);
|
|
1716
|
+
for (const node of before.values()) {
|
|
1717
|
+
if (
|
|
1718
|
+
node.id === operation.nodeId
|
|
1719
|
+
|| !hasAncestorInSet(node, before, new Set([operation.nodeId]))
|
|
1720
|
+
) continue;
|
|
1721
|
+
const componentRoot = trashComponentRoot(before, node.id);
|
|
1722
|
+
if (componentRoot?.id === node.id) logicalRoots.add(node.id);
|
|
1723
|
+
}
|
|
1724
|
+
for (const nodeId of logicalRoots) {
|
|
1725
|
+
const source = physicalNodePath(workspace, before, nodeId);
|
|
1726
|
+
if (!source || !pathExists(source)) continue;
|
|
1727
|
+
const precondition = treeMaterialization.removalPrecondition(source);
|
|
1728
|
+
const recovery = retainedRecoveryTarget(
|
|
1729
|
+
workspace,
|
|
1730
|
+
before,
|
|
1731
|
+
nodeId,
|
|
1732
|
+
input.envelope.mutationId,
|
|
1733
|
+
);
|
|
1734
|
+
actions.push({
|
|
1735
|
+
type: 'remove',
|
|
1736
|
+
nodeId,
|
|
1737
|
+
source,
|
|
1738
|
+
sourceParentNodeId: physicalParentNodeId(
|
|
1739
|
+
workspace,
|
|
1740
|
+
before,
|
|
1741
|
+
nodeId,
|
|
1742
|
+
),
|
|
1743
|
+
// Writer-held bytes cannot return below a parent that remains
|
|
1744
|
+
// trashed. The immutable plan names one deterministic recovery
|
|
1745
|
+
// location below the nearest live ancestor before removal begins.
|
|
1746
|
+
retainTarget: recovery.path,
|
|
1747
|
+
retainTargetParentNodeId: recovery.parentNodeId,
|
|
1748
|
+
retainTargetExclusive: true,
|
|
1749
|
+
identity: precondition.identity,
|
|
1750
|
+
retirement: precondition.retirement,
|
|
1751
|
+
});
|
|
1752
|
+
}
|
|
1753
|
+
}
|
|
1754
|
+
const rollbackBindings = completeOperationBindings(workspace, before);
|
|
1755
|
+
const bindingByNodeId = new Map(
|
|
1756
|
+
rollbackBindings.map((binding) => [binding.nodeId, binding]),
|
|
1757
|
+
);
|
|
1758
|
+
const createdNodeIds = new Set(
|
|
1759
|
+
actions
|
|
1760
|
+
.filter((action) => action.type === 'create')
|
|
1761
|
+
.map((action) => action.nodeId),
|
|
1762
|
+
);
|
|
1763
|
+
const controlRoots = new Set();
|
|
1764
|
+
const isWorkspacePath = (candidate) => (
|
|
1765
|
+
candidate === workspace.path
|
|
1766
|
+
|| candidate.startsWith(`${workspace.path}${path.sep}`)
|
|
1767
|
+
);
|
|
1768
|
+
for (const action of actions) {
|
|
1769
|
+
if (action.source) {
|
|
1770
|
+
const sourceParent = path.dirname(action.source);
|
|
1771
|
+
if (!isWorkspacePath(sourceParent)) controlRoots.add(sourceParent);
|
|
1772
|
+
const sourceParentIdentity = (
|
|
1773
|
+
action.sourceParentNodeId
|
|
1774
|
+
&& bindingByNodeId.get(action.sourceParentNodeId)?.identity
|
|
1775
|
+
) || treeMaterialization.identity(sourceParent, { deep: false });
|
|
1776
|
+
if (sourceParentIdentity.kind !== 'folder') {
|
|
1777
|
+
throw invalid(`workspace tree source parent is not a folder: ${sourceParent}`, 409);
|
|
1778
|
+
}
|
|
1779
|
+
action.sourceParentIdentity = sourceParentIdentity;
|
|
1780
|
+
}
|
|
1781
|
+
if (action.target) {
|
|
1782
|
+
const targetParent = path.dirname(action.target);
|
|
1783
|
+
if (!isWorkspacePath(targetParent)) {
|
|
1784
|
+
treeMaterialization.ensureDurableDirectory(targetParent);
|
|
1785
|
+
controlRoots.add(targetParent);
|
|
1786
|
+
}
|
|
1787
|
+
if (
|
|
1788
|
+
action.targetParentNodeId
|
|
1789
|
+
&& createdNodeIds.has(action.targetParentNodeId)
|
|
1790
|
+
) {
|
|
1791
|
+
action.targetParentIdentity = null;
|
|
1792
|
+
continue;
|
|
1793
|
+
}
|
|
1794
|
+
const targetParentIdentity = (
|
|
1795
|
+
action.targetParentNodeId
|
|
1796
|
+
&& bindingByNodeId.get(action.targetParentNodeId)?.identity
|
|
1797
|
+
) || treeMaterialization.identity(targetParent, { deep: false });
|
|
1798
|
+
if (targetParentIdentity.kind !== 'folder') {
|
|
1799
|
+
throw invalid(`workspace tree target parent is not a folder: ${targetParent}`, 409);
|
|
1800
|
+
}
|
|
1801
|
+
action.targetParentIdentity = targetParentIdentity;
|
|
1802
|
+
}
|
|
1803
|
+
}
|
|
1804
|
+
return {
|
|
1805
|
+
contract: 'workspace-tree-operation-materialization@1',
|
|
1806
|
+
resourceId: workspace.id,
|
|
1807
|
+
mutationId: String(input.envelope.mutationId),
|
|
1808
|
+
channelId: options.channelId || `resource:${workspace.id}`,
|
|
1809
|
+
version: Number(input.version),
|
|
1810
|
+
authorityEpoch: Number(input.envelope.authorityEpoch),
|
|
1811
|
+
rootPath: workspace.path,
|
|
1812
|
+
rootIdentity: {
|
|
1813
|
+
kind: 'folder',
|
|
1814
|
+
dev: String(verifiedRootIdentity.dev ?? verifiedRootIdentity.deviceId),
|
|
1815
|
+
ino: String(verifiedRootIdentity.ino ?? verifiedRootIdentity.inode),
|
|
1816
|
+
},
|
|
1817
|
+
controlRoots: Array.from(controlRoots).sort(),
|
|
1818
|
+
rollbackBindings,
|
|
1819
|
+
operation,
|
|
1820
|
+
beforeNodes: Array.from(before.values()),
|
|
1821
|
+
afterNodes: Array.from(after.values()),
|
|
1822
|
+
officialAfterNodes: officialAfter ? Array.from(officialAfter.values()) : null,
|
|
1823
|
+
actions,
|
|
1824
|
+
event: {
|
|
1825
|
+
resource: `${TREE_RESOURCE_PREFIX}${workspace.id}`,
|
|
1826
|
+
op: 'update',
|
|
1827
|
+
id: operation.nodeId,
|
|
1828
|
+
patch: operation,
|
|
1829
|
+
sharedResourceId: workspace.id,
|
|
1830
|
+
source: options.source || 'workspace-tree:cloud',
|
|
1831
|
+
...(options.resourceVersion === false
|
|
1832
|
+
? {}
|
|
1833
|
+
: { resourceVersion: Number(input.version) }),
|
|
1834
|
+
},
|
|
1835
|
+
};
|
|
1836
|
+
}
|
|
1837
|
+
|
|
1838
|
+
function localTreeOperationPlan(
|
|
1839
|
+
workspace,
|
|
1840
|
+
before,
|
|
1841
|
+
after,
|
|
1842
|
+
operation,
|
|
1843
|
+
mutation,
|
|
1844
|
+
options = {},
|
|
1845
|
+
) {
|
|
1846
|
+
return {
|
|
1847
|
+
...physicalOperationPlan(
|
|
1848
|
+
workspace,
|
|
1849
|
+
before,
|
|
1850
|
+
after,
|
|
1851
|
+
operation,
|
|
1852
|
+
{
|
|
1853
|
+
envelope: {
|
|
1854
|
+
mutationId: mutation.mutationId,
|
|
1855
|
+
authorityEpoch: mutation.authorityEpoch || 1,
|
|
1856
|
+
},
|
|
1857
|
+
version: mutation.baseCloudVersion || 0,
|
|
1858
|
+
},
|
|
1859
|
+
null,
|
|
1860
|
+
{
|
|
1861
|
+
channelId: mutation.channelId,
|
|
1862
|
+
diskAlreadyApplied: options.diskAlreadyApplied === true,
|
|
1863
|
+
source: options.source || 'workspace-tree:local',
|
|
1864
|
+
resourceVersion: false,
|
|
1865
|
+
rootIdentity: options.rootIdentity,
|
|
1866
|
+
},
|
|
1867
|
+
),
|
|
1868
|
+
origin: 'local',
|
|
1869
|
+
};
|
|
1870
|
+
}
|
|
1871
|
+
|
|
1872
|
+
function planNodes(plan, key) {
|
|
1873
|
+
return new Map((plan[key] || []).map((node) => [node.id, node]));
|
|
1874
|
+
}
|
|
1875
|
+
|
|
1876
|
+
function deterministicUuid(seed) {
|
|
1877
|
+
const bytes = crypto.createHash('sha256').update(seed).digest().subarray(0, 16);
|
|
1878
|
+
bytes[6] = (bytes[6] & 0x0f) | 0x70;
|
|
1879
|
+
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
|
1880
|
+
const hex = bytes.toString('hex');
|
|
1881
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-`
|
|
1882
|
+
+ `${hex.slice(16, 20)}-${hex.slice(20)}`;
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1885
|
+
function retentionMutationId(plan, nodeId, operationKind, outputId = '') {
|
|
1886
|
+
return `mut_${crypto.createHash('sha256')
|
|
1887
|
+
.update(
|
|
1888
|
+
`workspace-tree-retained@1\0${plan.resourceId}\0`
|
|
1889
|
+
+ `${plan.snapshotChecksum || plan.mutationId}\0${nodeId}\0`
|
|
1890
|
+
+ `${operationKind}\0${outputId}`,
|
|
1891
|
+
)
|
|
1892
|
+
.digest('hex')}`;
|
|
1893
|
+
}
|
|
1894
|
+
|
|
1895
|
+
function treePlanHoldScope(plan) {
|
|
1896
|
+
const operationPlan = (
|
|
1897
|
+
plan.contract === 'workspace-tree-operation-materialization@1'
|
|
1898
|
+
);
|
|
1899
|
+
return {
|
|
1900
|
+
ownerKind: 'recovery-plan',
|
|
1901
|
+
ownerId: operationPlan
|
|
1902
|
+
? `operation:${plan.mutationId}`
|
|
1903
|
+
: `snapshot:${plan.snapshotVersion}`,
|
|
1904
|
+
};
|
|
1905
|
+
}
|
|
1906
|
+
|
|
1907
|
+
function clearTreePlanHoldInTransaction(plan, database) {
|
|
1908
|
+
return require('./store').replaceTreeObservationInTransaction(
|
|
1909
|
+
plan.resourceId,
|
|
1910
|
+
[],
|
|
1911
|
+
{
|
|
1912
|
+
database,
|
|
1913
|
+
...treePlanHoldScope(plan),
|
|
1914
|
+
source: 'workspace-tree:recovery-plan-settled',
|
|
1915
|
+
},
|
|
1916
|
+
);
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
function holdRetainedSuccessors(
|
|
1920
|
+
plan,
|
|
1921
|
+
reason,
|
|
1922
|
+
operations,
|
|
1923
|
+
database,
|
|
1924
|
+
paths = [],
|
|
1925
|
+
) {
|
|
1926
|
+
const materialization = require('./tree-materialization');
|
|
1927
|
+
const held = plan.contract === 'workspace-tree-operation-materialization@1'
|
|
1928
|
+
? materialization.getOperation(plan.resourceId, plan.mutationId, database)
|
|
1929
|
+
: materialization.get(plan.resourceId, plan.snapshotVersion, database);
|
|
1930
|
+
if (!held) {
|
|
1931
|
+
throw invalid('workspace tree retained-byte hold lost its durable plan', 409);
|
|
1932
|
+
}
|
|
1933
|
+
const hold = {
|
|
1934
|
+
reason,
|
|
1935
|
+
operations,
|
|
1936
|
+
paths: Array.from(new Set(paths)).sort(),
|
|
1937
|
+
};
|
|
1938
|
+
const field = reason === 'forbidden' ? 'permissionHold' : 'retainedHold';
|
|
1939
|
+
const nextPlan = { ...held.plan, [field]: hold };
|
|
1940
|
+
require('./store').commitCompletedTreeObservation(
|
|
1941
|
+
plan.resourceId,
|
|
1942
|
+
[{ reason, paths: hold.paths }],
|
|
1943
|
+
{
|
|
1944
|
+
database,
|
|
1945
|
+
...treePlanHoldScope(plan),
|
|
1946
|
+
source: 'workspace-tree:recovery-plan-hold',
|
|
1947
|
+
mutate() {
|
|
1948
|
+
if (JSON.stringify(held.plan[field] || null) === JSON.stringify(hold)) {
|
|
1949
|
+
return;
|
|
1950
|
+
}
|
|
1951
|
+
if (held.state === 'committed') {
|
|
1952
|
+
materialization.updateCommittedPlan(held, nextPlan, database);
|
|
1953
|
+
} else if (held.state === 'prepared') {
|
|
1954
|
+
materialization.replacePreparedOperation(held, nextPlan, database);
|
|
1955
|
+
} else {
|
|
1956
|
+
materialization.updateUncommittedPlan(held, nextPlan, database);
|
|
1957
|
+
}
|
|
1958
|
+
},
|
|
1959
|
+
},
|
|
1960
|
+
);
|
|
1961
|
+
throw Object.assign(
|
|
1962
|
+
new Error(`workspace tree retained bytes are held: ${reason}`),
|
|
1963
|
+
{
|
|
1964
|
+
code: 'WORKSPACE_TREE_RETRY_PENDING',
|
|
1965
|
+
reason,
|
|
1966
|
+
statusCode: 409,
|
|
1967
|
+
paths: hold.paths,
|
|
1968
|
+
},
|
|
1969
|
+
);
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
function holdClassifiedPlanRetry(plan, error, database) {
|
|
1973
|
+
if (error?.code !== 'WORKSPACE_TREE_RETRY_PENDING') throw error;
|
|
1974
|
+
const paths = Array.from(new Set(
|
|
1975
|
+
(Array.isArray(error.paths) ? error.paths : [])
|
|
1976
|
+
.filter(Boolean)
|
|
1977
|
+
.map((target) => (
|
|
1978
|
+
path.isAbsolute(target)
|
|
1979
|
+
? path.relative(plan.rootPath, target)
|
|
1980
|
+
: target
|
|
1981
|
+
)),
|
|
1982
|
+
)).sort();
|
|
1983
|
+
holdRetainedSuccessors(
|
|
1984
|
+
plan,
|
|
1985
|
+
error.reason || 'materialization-retry-pending',
|
|
1986
|
+
[],
|
|
1987
|
+
database,
|
|
1988
|
+
paths,
|
|
1989
|
+
);
|
|
1990
|
+
}
|
|
1991
|
+
|
|
1992
|
+
/**
|
|
1993
|
+
* Physical cleanup can discover a writer that was already holding an inode
|
|
1994
|
+
* when an official removal staged it. Choose and persist the UUID attached to
|
|
1995
|
+
* each exact retained identity before issuing any birth. The mutation journal
|
|
1996
|
+
* bridges the only remaining crash window (birth committed, plan not yet
|
|
1997
|
+
* marked born), so recovery never re-applies an obsolete receipt to a newer
|
|
1998
|
+
* object at the same spelling.
|
|
1999
|
+
*/
|
|
2000
|
+
function retainRemovalSuccessors(plan, retained, database, options = {}) {
|
|
2001
|
+
const before = planNodes(plan, 'beforeNodes');
|
|
2002
|
+
const outputs = Array.from(new Map(
|
|
2003
|
+
retained.flatMap((item) => (
|
|
2004
|
+
Array.isArray(item.retainedOutputs) ? item.retainedOutputs : []
|
|
2005
|
+
)).map((output) => [output.outputId, output]),
|
|
2006
|
+
).values()).sort((left, right) => (
|
|
2007
|
+
left.path.split(path.sep).length - right.path.split(path.sep).length
|
|
2008
|
+
|| left.path.localeCompare(right.path)
|
|
2009
|
+
|| left.outputId.localeCompare(right.outputId)
|
|
2010
|
+
));
|
|
2011
|
+
const tree = localTree();
|
|
2012
|
+
const attempted = [];
|
|
2013
|
+
const materialization = require('./tree-materialization');
|
|
2014
|
+
let currentPlan = plan;
|
|
2015
|
+
|
|
2016
|
+
const heldPlan = () => {
|
|
2017
|
+
const held = currentPlan.contract === 'workspace-tree-operation-materialization@1'
|
|
2018
|
+
? materialization.getOperation(
|
|
2019
|
+
currentPlan.resourceId,
|
|
2020
|
+
currentPlan.mutationId,
|
|
2021
|
+
database,
|
|
2022
|
+
)
|
|
2023
|
+
: materialization.get(
|
|
2024
|
+
currentPlan.resourceId,
|
|
2025
|
+
currentPlan.snapshotVersion,
|
|
2026
|
+
database,
|
|
2027
|
+
);
|
|
2028
|
+
if (!held) {
|
|
2029
|
+
throw invalid('workspace tree retained adoption lost its committed plan', 409);
|
|
2030
|
+
}
|
|
2031
|
+
return held;
|
|
2032
|
+
};
|
|
2033
|
+
const persistAdoptions = (adoptions) => {
|
|
2034
|
+
const held = heldPlan();
|
|
2035
|
+
const nextPlan = {
|
|
2036
|
+
...held.plan,
|
|
2037
|
+
retainedOutputAdoptions: [...adoptions].sort((left, right) => (
|
|
2038
|
+
left.outputId.localeCompare(right.outputId)
|
|
2039
|
+
)),
|
|
2040
|
+
};
|
|
2041
|
+
if (JSON.stringify(held.plan) !== JSON.stringify(nextPlan)) {
|
|
2042
|
+
currentPlan = (
|
|
2043
|
+
held.state === 'committed'
|
|
2044
|
+
? materialization.updateCommittedPlan(held, nextPlan, database)
|
|
2045
|
+
: materialization.updateUncommittedPlan(held, nextPlan, database)
|
|
2046
|
+
).plan;
|
|
2047
|
+
} else {
|
|
2048
|
+
currentPlan = held.plan;
|
|
2049
|
+
}
|
|
2050
|
+
return currentPlan;
|
|
2051
|
+
};
|
|
2052
|
+
const oldNodeForOutput = (output) => {
|
|
2053
|
+
if (output.provenance !== 'carried-source') return null;
|
|
2054
|
+
if (!Object.prototype.hasOwnProperty.call(output, 'ownerNodeId')) {
|
|
2055
|
+
holdChanged('retained-owner-unresolved', [output.path], [output]);
|
|
2056
|
+
}
|
|
2057
|
+
if (output.ownerNodeId == null) return null;
|
|
2058
|
+
const owner = before.get(output.ownerNodeId);
|
|
2059
|
+
if (!owner || owner.kind !== output.identity.kind) {
|
|
2060
|
+
holdChanged('retained-owner-unresolved', [output.path], [output]);
|
|
2061
|
+
}
|
|
2062
|
+
return owner;
|
|
2063
|
+
};
|
|
2064
|
+
const expectedAdoption = (output, currentPath = output.path) => {
|
|
2065
|
+
const oldNode = oldNodeForOutput(output);
|
|
2066
|
+
const reuseOldId = (
|
|
2067
|
+
output.provenance === 'carried-source'
|
|
2068
|
+
&& options.reuseIds !== false
|
|
2069
|
+
&& oldNode
|
|
2070
|
+
&& oldNode.kind === output.identity.kind
|
|
2071
|
+
);
|
|
2072
|
+
const nodeId = reuseOldId
|
|
2073
|
+
? oldNode.id
|
|
2074
|
+
: deterministicUuid(
|
|
2075
|
+
`workspace-tree-retained-output@1\0${plan.resourceId}\0`
|
|
2076
|
+
+ `${plan.snapshotChecksum || plan.mutationId}\0${output.outputId}\0`
|
|
2077
|
+
+ `${oldNode?.id || ''}\0${output.identity.kind}`,
|
|
2078
|
+
);
|
|
2079
|
+
return {
|
|
2080
|
+
outputId: output.outputId,
|
|
2081
|
+
path: currentPath,
|
|
2082
|
+
nodeId,
|
|
2083
|
+
oldNodeId: oldNode?.id || null,
|
|
2084
|
+
reuseOldId: Boolean(reuseOldId),
|
|
2085
|
+
identity: {
|
|
2086
|
+
kind: output.identity.kind,
|
|
2087
|
+
deviceId: String(output.identity.dev),
|
|
2088
|
+
inode: String(output.identity.ino),
|
|
2089
|
+
},
|
|
2090
|
+
};
|
|
2091
|
+
};
|
|
2092
|
+
const birthRow = (adoption) => {
|
|
2093
|
+
if (!adoption.mutationId) return null;
|
|
2094
|
+
return database.prepare(`
|
|
2095
|
+
SELECT mutation_state, materialized_at
|
|
2096
|
+
FROM mutation_journal
|
|
2097
|
+
WHERE local_resource = ? AND mutation_id = ?
|
|
2098
|
+
`).get(
|
|
2099
|
+
`${TREE_RESOURCE_PREFIX}${currentPlan.resourceId}`,
|
|
2100
|
+
adoption.mutationId,
|
|
2101
|
+
) || null;
|
|
2102
|
+
};
|
|
2103
|
+
const birthCompleted = (adoption) => {
|
|
2104
|
+
if (adoption.born === true) return true;
|
|
2105
|
+
return birthRow(adoption)?.materialized_at != null;
|
|
2106
|
+
};
|
|
2107
|
+
const adoptionMap = () => new Map(
|
|
2108
|
+
(currentPlan.retainedOutputAdoptions || [])
|
|
2109
|
+
.map((adoption) => [adoption.outputId, adoption]),
|
|
2110
|
+
);
|
|
2111
|
+
const persistAdoption = (adoption) => {
|
|
2112
|
+
const byId = adoptionMap();
|
|
2113
|
+
byId.set(adoption.outputId, adoption);
|
|
2114
|
+
persistAdoptions(Array.from(byId.values()));
|
|
2115
|
+
return adoptionMap().get(adoption.outputId);
|
|
2116
|
+
};
|
|
2117
|
+
const holdChanged = (reason, heldPaths, operations = outputs) => {
|
|
2118
|
+
const relativePaths = Array.from(new Set(heldPaths))
|
|
2119
|
+
.map((target) => path.relative(currentPlan.rootPath, target))
|
|
2120
|
+
.sort();
|
|
2121
|
+
holdRetainedSuccessors(
|
|
2122
|
+
currentPlan,
|
|
2123
|
+
reason,
|
|
2124
|
+
operations,
|
|
2125
|
+
database,
|
|
2126
|
+
relativePaths,
|
|
2127
|
+
);
|
|
2128
|
+
};
|
|
2129
|
+
const planBirth = (adoption, output) => {
|
|
2130
|
+
if (adoption.operation && adoption.mutationId) return adoption;
|
|
2131
|
+
const target = adoption.path;
|
|
2132
|
+
const parentPath = path.dirname(target);
|
|
2133
|
+
const parentBinding = physicalBindingAtPath(parentPath, database);
|
|
2134
|
+
if (!parentBinding || parentBinding.kind !== 'folder') {
|
|
2135
|
+
holdChanged('retained-parent-unavailable', [parentPath]);
|
|
2136
|
+
}
|
|
2137
|
+
const oldNode = adoption.oldNodeId
|
|
2138
|
+
? before.get(adoption.oldNodeId)
|
|
2139
|
+
: null;
|
|
2140
|
+
const current = adoption.reuseOldId
|
|
2141
|
+
? database.prepare('SELECT * FROM workspace_nodes WHERE id = ?')
|
|
2142
|
+
.get(adoption.nodeId)
|
|
2143
|
+
: null;
|
|
2144
|
+
const operation = adoption.reuseOldId
|
|
2145
|
+
&& current
|
|
2146
|
+
&& current.trashed_at
|
|
2147
|
+
&& !current.purged_at
|
|
2148
|
+
? {
|
|
2149
|
+
kind: 'tree.restore',
|
|
2150
|
+
workspaceId: currentPlan.resourceId,
|
|
2151
|
+
nodeId: adoption.nodeId,
|
|
2152
|
+
parentId: parentBinding.node.id,
|
|
2153
|
+
name: path.basename(target),
|
|
2154
|
+
changedAt: oldNode.updatedAt || oldNode.createdAt,
|
|
2155
|
+
}
|
|
2156
|
+
: {
|
|
2157
|
+
kind: 'tree.create',
|
|
2158
|
+
workspaceId: currentPlan.resourceId,
|
|
2159
|
+
nodeId: adoption.nodeId,
|
|
2160
|
+
parentId: parentBinding.node.id,
|
|
2161
|
+
nodeKind: adoption.identity.kind,
|
|
2162
|
+
name: path.basename(target),
|
|
2163
|
+
symlinkTarget: adoption.identity.kind === 'symlink'
|
|
2164
|
+
? fs.readlinkSync(target)
|
|
2165
|
+
: undefined,
|
|
2166
|
+
createdAt: adoption.reuseOldId ? oldNode.createdAt : undefined,
|
|
2167
|
+
changedAt: oldNode?.updatedAt || oldNode?.createdAt,
|
|
2168
|
+
};
|
|
2169
|
+
return persistAdoption({
|
|
2170
|
+
...adoption,
|
|
2171
|
+
mutationId: retentionMutationId(
|
|
2172
|
+
currentPlan,
|
|
2173
|
+
adoption.nodeId,
|
|
2174
|
+
operation.kind,
|
|
2175
|
+
output.outputId,
|
|
2176
|
+
),
|
|
2177
|
+
operation,
|
|
2178
|
+
parentIdentity: {
|
|
2179
|
+
path: parentPath,
|
|
2180
|
+
kind: parentBinding.kind,
|
|
2181
|
+
deviceId: parentBinding.deviceId,
|
|
2182
|
+
inode: parentBinding.inode,
|
|
2183
|
+
},
|
|
2184
|
+
});
|
|
2185
|
+
};
|
|
2186
|
+
const capture = (output) => {
|
|
2187
|
+
let adoption = adoptionMap().get(output.outputId);
|
|
2188
|
+
if (!adoption) {
|
|
2189
|
+
throw invalid(
|
|
2190
|
+
`retained tree output ${output.outputId} has no durable adoption`,
|
|
2191
|
+
409,
|
|
2192
|
+
);
|
|
2193
|
+
}
|
|
2194
|
+
if (birthCompleted(adoption)) {
|
|
2195
|
+
if (adoption.born !== true) {
|
|
2196
|
+
adoption = persistAdoption({
|
|
2197
|
+
...adoption,
|
|
2198
|
+
born: true,
|
|
2199
|
+
});
|
|
2200
|
+
}
|
|
2201
|
+
return;
|
|
2202
|
+
}
|
|
2203
|
+
if (adoption.delegatedToObserver === true) return;
|
|
2204
|
+
const atPath = tree.nodeForPath(adoption.path, { database });
|
|
2205
|
+
const owner = heldPlan();
|
|
2206
|
+
if (owner.state !== 'committed') {
|
|
2207
|
+
// A superseded snapshot is returning the prior floor, whose UUID rows
|
|
2208
|
+
// are already durable. Once the exact retained identities and their
|
|
2209
|
+
// current paths are sealed above, reconciliation—not a new create—is
|
|
2210
|
+
// the only legal way to rebind that floor and classify extra occupants.
|
|
2211
|
+
persistAdoption({
|
|
2212
|
+
...adoption,
|
|
2213
|
+
delegatedToObserver: true,
|
|
2214
|
+
});
|
|
2215
|
+
return;
|
|
2216
|
+
}
|
|
2217
|
+
if (atPath && !adoption.mutationId) {
|
|
2218
|
+
if (adoption.reuseOldId && atPath.node.id !== adoption.nodeId) {
|
|
2219
|
+
holdChanged(
|
|
2220
|
+
'retained-uuid-mismatch',
|
|
2221
|
+
[adoption.path],
|
|
2222
|
+
attempted.length ? attempted : outputs,
|
|
2223
|
+
);
|
|
2224
|
+
}
|
|
2225
|
+
// This spelling already has a durable logical owner. The ordinary
|
|
2226
|
+
// observer is the only component allowed to decide whether the exact
|
|
2227
|
+
// retained identity is that owner's refresh or a fresh successor.
|
|
2228
|
+
persistAdoption({
|
|
2229
|
+
...adoption,
|
|
2230
|
+
delegatedToObserver: true,
|
|
2231
|
+
});
|
|
2232
|
+
return;
|
|
2233
|
+
}
|
|
2234
|
+
adoption = planBirth(adoption, output);
|
|
2235
|
+
if (atPath && atPath.node.id !== adoption.nodeId) {
|
|
2236
|
+
holdChanged(
|
|
2237
|
+
'retained-uuid-mismatch',
|
|
2238
|
+
[adoption.path],
|
|
2239
|
+
attempted.length ? attempted : outputs,
|
|
2240
|
+
);
|
|
2241
|
+
}
|
|
2242
|
+
if (typeof options.testHooks?.beforeRetainedOutputBirth === 'function') {
|
|
2243
|
+
options.testHooks.beforeRetainedOutputBirth(output, adoption);
|
|
2244
|
+
}
|
|
2245
|
+
attempted.push(adoption.operation);
|
|
2246
|
+
tree.commitTreeOperation(adoption.operation, {
|
|
2247
|
+
database,
|
|
2248
|
+
diskAlreadyApplied: true,
|
|
2249
|
+
source: 'workspace-tree:retained-after-apply',
|
|
2250
|
+
mutationId: adoption.mutationId,
|
|
2251
|
+
expectedDiskIdentity: {
|
|
2252
|
+
path: adoption.path,
|
|
2253
|
+
...adoption.identity,
|
|
2254
|
+
},
|
|
2255
|
+
expectedParentDiskIdentity: adoption.parentIdentity,
|
|
2256
|
+
});
|
|
2257
|
+
persistAdoption({
|
|
2258
|
+
...adoption,
|
|
2259
|
+
born: true,
|
|
2260
|
+
});
|
|
2261
|
+
};
|
|
2262
|
+
try {
|
|
2263
|
+
const changedOutputs = [];
|
|
2264
|
+
const ambiguousOutputs = [];
|
|
2265
|
+
const existingAdoptions = adoptionMap();
|
|
2266
|
+
const pendingOutputs = outputs.filter((output) => {
|
|
2267
|
+
const adoption = existingAdoptions.get(output.outputId);
|
|
2268
|
+
return !adoption || (
|
|
2269
|
+
adoption.delegatedToObserver !== true
|
|
2270
|
+
&& !birthCompleted(adoption)
|
|
2271
|
+
);
|
|
2272
|
+
});
|
|
2273
|
+
const locations = materialization.locateOperationBindings(
|
|
2274
|
+
currentPlan,
|
|
2275
|
+
pendingOutputs.map((output) => ({
|
|
2276
|
+
nodeId: output.outputId,
|
|
2277
|
+
path: output.path,
|
|
2278
|
+
identity: output.identity,
|
|
2279
|
+
})),
|
|
2280
|
+
);
|
|
2281
|
+
const currentPaths = new Map();
|
|
2282
|
+
for (const output of pendingOutputs) {
|
|
2283
|
+
const candidates = locations.get(output.outputId) || [];
|
|
2284
|
+
if (candidates.length === 0) {
|
|
2285
|
+
changedOutputs.push(output.path);
|
|
2286
|
+
continue;
|
|
2287
|
+
}
|
|
2288
|
+
if (candidates.length > 1) {
|
|
2289
|
+
ambiguousOutputs.push(...candidates);
|
|
2290
|
+
continue;
|
|
2291
|
+
}
|
|
2292
|
+
currentPaths.set(output.outputId, candidates[0]);
|
|
2293
|
+
}
|
|
2294
|
+
if (changedOutputs.length || ambiguousOutputs.length) {
|
|
2295
|
+
const reason = ambiguousOutputs.length
|
|
2296
|
+
? 'ambiguous-physical-alias'
|
|
2297
|
+
: 'retained-output-changed';
|
|
2298
|
+
holdChanged(
|
|
2299
|
+
reason,
|
|
2300
|
+
ambiguousOutputs.length ? ambiguousOutputs : changedOutputs,
|
|
2301
|
+
);
|
|
2302
|
+
}
|
|
2303
|
+
|
|
2304
|
+
const adoptions = adoptionMap();
|
|
2305
|
+
for (const output of outputs) {
|
|
2306
|
+
let existing = adoptions.get(output.outputId);
|
|
2307
|
+
const currentPath = currentPaths.get(output.outputId)
|
|
2308
|
+
|| existing?.path
|
|
2309
|
+
|| output.path;
|
|
2310
|
+
const expected = expectedAdoption(output, currentPath);
|
|
2311
|
+
if (existing) {
|
|
2312
|
+
if (
|
|
2313
|
+
existing.path !== currentPath
|
|
2314
|
+
&& (existing.operation || existing.mutationId)
|
|
2315
|
+
) {
|
|
2316
|
+
// A planned envelope is immutable only after the journal has
|
|
2317
|
+
// accepted it. Before that point the exact retained object may move
|
|
2318
|
+
// again; replace the plan-local envelope and let planBirth derive
|
|
2319
|
+
// the same UUID at its verified current parent.
|
|
2320
|
+
if (birthRow(existing)) {
|
|
2321
|
+
holdChanged('retained-output-changed', [existing.path, currentPath]);
|
|
2322
|
+
}
|
|
2323
|
+
existing = {
|
|
2324
|
+
...existing,
|
|
2325
|
+
path: currentPath,
|
|
2326
|
+
operation: undefined,
|
|
2327
|
+
mutationId: undefined,
|
|
2328
|
+
parentIdentity: undefined,
|
|
2329
|
+
};
|
|
2330
|
+
adoptions.set(output.outputId, existing);
|
|
2331
|
+
}
|
|
2332
|
+
const immutable = {
|
|
2333
|
+
outputId: existing.outputId,
|
|
2334
|
+
path: currentPath,
|
|
2335
|
+
nodeId: existing.nodeId,
|
|
2336
|
+
oldNodeId: existing.oldNodeId || null,
|
|
2337
|
+
reuseOldId: existing.reuseOldId === true,
|
|
2338
|
+
identity: existing.identity,
|
|
2339
|
+
};
|
|
2340
|
+
if (JSON.stringify(immutable) !== JSON.stringify(expected)) {
|
|
2341
|
+
throw invalid(
|
|
2342
|
+
`retained tree output ${output.outputId} changed its durable adoption`,
|
|
2343
|
+
409,
|
|
2344
|
+
);
|
|
2345
|
+
}
|
|
2346
|
+
if (existing.path !== currentPath) {
|
|
2347
|
+
adoptions.set(output.outputId, {
|
|
2348
|
+
...existing,
|
|
2349
|
+
path: currentPath,
|
|
2350
|
+
});
|
|
2351
|
+
}
|
|
2352
|
+
} else {
|
|
2353
|
+
adoptions.set(output.outputId, expected);
|
|
2354
|
+
}
|
|
2355
|
+
}
|
|
2356
|
+
persistAdoptions(Array.from(adoptions.values()));
|
|
2357
|
+
|
|
2358
|
+
for (const output of outputs) capture(output);
|
|
2359
|
+
|
|
2360
|
+
const completeDelegatedAdoptions = () => {
|
|
2361
|
+
const delegated = Array.from(adoptionMap().values())
|
|
2362
|
+
.filter((adoption) => (
|
|
2363
|
+
adoption.delegatedToObserver === true
|
|
2364
|
+
&& adoption.disposition == null
|
|
2365
|
+
));
|
|
2366
|
+
if (!delegated.length) return;
|
|
2367
|
+
const locations = materialization.locateOperationBindings(
|
|
2368
|
+
currentPlan,
|
|
2369
|
+
delegated.map((adoption) => ({
|
|
2370
|
+
nodeId: adoption.outputId,
|
|
2371
|
+
path: adoption.path,
|
|
2372
|
+
identity: {
|
|
2373
|
+
kind: adoption.identity.kind,
|
|
2374
|
+
dev: adoption.identity.deviceId,
|
|
2375
|
+
ino: adoption.identity.inode,
|
|
2376
|
+
},
|
|
2377
|
+
})),
|
|
2378
|
+
);
|
|
2379
|
+
const completed = adoptionMap();
|
|
2380
|
+
for (const adoption of delegated) {
|
|
2381
|
+
const candidates = locations.get(adoption.outputId) || [];
|
|
2382
|
+
if (candidates.length > 1) {
|
|
2383
|
+
holdChanged('ambiguous-physical-alias', candidates);
|
|
2384
|
+
}
|
|
2385
|
+
if (candidates.length === 1) {
|
|
2386
|
+
const binding = physicalBindingAtPath(candidates[0], database);
|
|
2387
|
+
if (!binding) {
|
|
2388
|
+
holdChanged('retained-birth-pending', candidates);
|
|
2389
|
+
}
|
|
2390
|
+
completed.set(adoption.outputId, {
|
|
2391
|
+
...adoption,
|
|
2392
|
+
path: candidates[0],
|
|
2393
|
+
born: true,
|
|
2394
|
+
actualNodeId: binding.node.id,
|
|
2395
|
+
disposition: 'active',
|
|
2396
|
+
});
|
|
2397
|
+
continue;
|
|
2398
|
+
}
|
|
2399
|
+
const prior = adoption.reuseOldId
|
|
2400
|
+
? database.prepare(`
|
|
2401
|
+
SELECT trashed_at, purged_at
|
|
2402
|
+
FROM workspace_nodes
|
|
2403
|
+
WHERE workspace_id = ? AND id = ?
|
|
2404
|
+
`).get(currentPlan.resourceId, adoption.nodeId)
|
|
2405
|
+
: null;
|
|
2406
|
+
completed.set(adoption.outputId, {
|
|
2407
|
+
...adoption,
|
|
2408
|
+
actualNodeId: prior ? adoption.nodeId : null,
|
|
2409
|
+
disposition: prior?.purged_at
|
|
2410
|
+
? 'purged'
|
|
2411
|
+
: prior?.trashed_at
|
|
2412
|
+
? 'trashed'
|
|
2413
|
+
: 'absent',
|
|
2414
|
+
});
|
|
2415
|
+
}
|
|
2416
|
+
persistAdoptions(Array.from(completed.values()));
|
|
2417
|
+
};
|
|
2418
|
+
|
|
2419
|
+
settleRetainedOutputBindings(currentPlan, database, {
|
|
2420
|
+
source: 'workspace-tree:retained-after-apply',
|
|
2421
|
+
testHooks: options.testHooks,
|
|
2422
|
+
afterSettled: completeDelegatedAdoptions,
|
|
2423
|
+
hold(reason, paths, operations) {
|
|
2424
|
+
holdRetainedSuccessors(
|
|
2425
|
+
currentPlan,
|
|
2426
|
+
reason,
|
|
2427
|
+
operations.length ? operations : outputs,
|
|
2428
|
+
database,
|
|
2429
|
+
paths,
|
|
2430
|
+
);
|
|
2431
|
+
},
|
|
2432
|
+
});
|
|
2433
|
+
} catch (error) {
|
|
2434
|
+
if (error?.code === 'WORKSPACE_TREE_PHYSICAL_BINDING_CHANGED') {
|
|
2435
|
+
holdChanged(
|
|
2436
|
+
'retained-output-changed',
|
|
2437
|
+
outputs.map((output) => output.path),
|
|
2438
|
+
attempted.length ? attempted : outputs,
|
|
2439
|
+
);
|
|
2440
|
+
}
|
|
2441
|
+
if (error?.statusCode !== 403) throw error;
|
|
2442
|
+
holdRetainedSuccessors(
|
|
2443
|
+
currentPlan,
|
|
2444
|
+
'forbidden',
|
|
2445
|
+
attempted,
|
|
2446
|
+
database,
|
|
2447
|
+
outputs.map((output) => path.relative(currentPlan.rootPath, output.path)),
|
|
2448
|
+
);
|
|
2449
|
+
}
|
|
2450
|
+
}
|
|
2451
|
+
|
|
2452
|
+
function finishTreeSnapshotPlan(held, database, testHooks = {}) {
|
|
2453
|
+
let observationCommit = null;
|
|
2454
|
+
let finished;
|
|
2455
|
+
try {
|
|
2456
|
+
finished = require('./tree-materialization').finish(held, database, {
|
|
2457
|
+
afterOutputReceipt(outputs, plan) {
|
|
2458
|
+
if (typeof testHooks.afterRetainedOutputReceipt === 'function') {
|
|
2459
|
+
testHooks.afterRetainedOutputReceipt(outputs, plan);
|
|
2460
|
+
}
|
|
2461
|
+
if (testHooks.crashAfterRetainedOutputReceipt === true) {
|
|
2462
|
+
throw new Error('simulated workspace tree crash after retained output receipt');
|
|
2463
|
+
}
|
|
2464
|
+
},
|
|
2465
|
+
afterOutputMove(outputs, plan) {
|
|
2466
|
+
if (typeof testHooks.afterRetainedOutputMove === 'function') {
|
|
2467
|
+
testHooks.afterRetainedOutputMove(outputs, plan);
|
|
2468
|
+
}
|
|
2469
|
+
if (testHooks.crashAfterRetainedOutputMove === true) {
|
|
2470
|
+
throw new Error('simulated workspace tree crash after retained output move');
|
|
2471
|
+
}
|
|
2472
|
+
},
|
|
2473
|
+
retain(retained, plan) {
|
|
2474
|
+
retainRemovalSuccessors(plan, retained, database, {
|
|
2475
|
+
reuseIds: true,
|
|
2476
|
+
testHooks,
|
|
2477
|
+
});
|
|
2478
|
+
},
|
|
2479
|
+
afterRetain() {
|
|
2480
|
+
if (testHooks.crashAfterRetainedBirths === true) {
|
|
2481
|
+
throw new Error('simulated workspace tree crash after retained births');
|
|
2482
|
+
}
|
|
2483
|
+
},
|
|
2484
|
+
finalizeControlState(finalPlan) {
|
|
2485
|
+
observationCommit = clearTreePlanHoldInTransaction(finalPlan, database);
|
|
2486
|
+
},
|
|
2487
|
+
});
|
|
2488
|
+
} catch (error) {
|
|
2489
|
+
holdClassifiedPlanRetry(held.plan, error, database);
|
|
2490
|
+
}
|
|
2491
|
+
require('./store').publishTreeObservationCommit(observationCommit);
|
|
2492
|
+
return finished;
|
|
2493
|
+
}
|
|
2494
|
+
|
|
2495
|
+
function settlePurgeRetentionTargets(held, database) {
|
|
2496
|
+
const materialization = require('./tree-materialization');
|
|
2497
|
+
let current = materialization.getOperation(
|
|
2498
|
+
held.resourceId,
|
|
2499
|
+
held.mutationId,
|
|
2500
|
+
database,
|
|
2501
|
+
);
|
|
2502
|
+
if (
|
|
2503
|
+
!current
|
|
2504
|
+
|| current.state !== 'committed'
|
|
2505
|
+
|| current.plan.operation?.kind !== 'tree.purge'
|
|
2506
|
+
) {
|
|
2507
|
+
return current || held;
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
// A committed physical projection is the floor. Before its cleanup returns
|
|
2511
|
+
// writer-held bytes, capture any local parent moves or replacement entries
|
|
2512
|
+
// that appeared while the runtime was down. The ordinary observer already
|
|
2513
|
+
// has the complete UUID/device/inode rules for that classification.
|
|
2514
|
+
const observed = localTree().reconcileWorkspaceTree(current.plan.resourceId, {
|
|
2515
|
+
database,
|
|
2516
|
+
skipRecovery: true,
|
|
2517
|
+
source: 'workspace-tree:retained-parent-before-cleanup',
|
|
2518
|
+
});
|
|
2519
|
+
if (observed.held?.length) {
|
|
2520
|
+
holdRetainedSuccessors(
|
|
2521
|
+
current.plan,
|
|
2522
|
+
observed.held[0]?.reason || 'retained-parent-pending',
|
|
2523
|
+
observed.held.map((item) => item.operation || item),
|
|
2524
|
+
database,
|
|
2525
|
+
);
|
|
2526
|
+
}
|
|
2527
|
+
|
|
2528
|
+
current = materialization.getOperation(
|
|
2529
|
+
held.resourceId,
|
|
2530
|
+
held.mutationId,
|
|
2531
|
+
database,
|
|
2532
|
+
);
|
|
2533
|
+
const workspace = workspaceRow(current.plan.resourceId, database);
|
|
2534
|
+
const nodes = new Map(localTree().listWorkspaceNodes(workspace.id, {
|
|
2535
|
+
database,
|
|
2536
|
+
includeTrashed: true,
|
|
2537
|
+
includePurged: true,
|
|
2538
|
+
}).map((node) => [node.id, node]));
|
|
2539
|
+
const before = planNodes(current.plan, 'beforeNodes');
|
|
2540
|
+
const bindings = new Map(
|
|
2541
|
+
(current.plan.rollbackBindings || [])
|
|
2542
|
+
.map((binding) => [binding.nodeId, binding]),
|
|
2543
|
+
);
|
|
2544
|
+
let changed = false;
|
|
2545
|
+
const actions = current.plan.actions.map((action) => {
|
|
2546
|
+
if (action.type !== 'remove' || !action.retainTargetParentNodeId) {
|
|
2547
|
+
return action;
|
|
2548
|
+
}
|
|
2549
|
+
let parentId = action.retainTargetParentNodeId;
|
|
2550
|
+
let resolved = null;
|
|
2551
|
+
while (parentId) {
|
|
2552
|
+
const parent = nodes.get(parentId);
|
|
2553
|
+
if (parent && activeNode(parent)) {
|
|
2554
|
+
const parentPath = operationPath(workspace, nodes, parentId);
|
|
2555
|
+
const expected = parentId === workspace.id
|
|
2556
|
+
? current.plan.rootIdentity
|
|
2557
|
+
: bindings.get(parentId)?.identity;
|
|
2558
|
+
if (expected && pathExists(parentPath)) {
|
|
2559
|
+
const actual = materialization.identity(parentPath, { deep: false });
|
|
2560
|
+
if (
|
|
2561
|
+
actual.kind === 'folder'
|
|
2562
|
+
&& actual.dev === expected.dev
|
|
2563
|
+
&& actual.ino === expected.ino
|
|
2564
|
+
) {
|
|
2565
|
+
resolved = {
|
|
2566
|
+
parentId,
|
|
2567
|
+
parentPath,
|
|
2568
|
+
parentIdentity: actual,
|
|
2569
|
+
};
|
|
2570
|
+
break;
|
|
2571
|
+
}
|
|
2572
|
+
}
|
|
2573
|
+
}
|
|
2574
|
+
if (parentId === workspace.id) break;
|
|
2575
|
+
parentId = before.get(parentId)?.parentId || null;
|
|
2576
|
+
}
|
|
2577
|
+
if (!resolved) {
|
|
2578
|
+
holdRetainedSuccessors(
|
|
2579
|
+
current.plan,
|
|
2580
|
+
'retained-parent-unavailable',
|
|
2581
|
+
[],
|
|
2582
|
+
database,
|
|
2583
|
+
[path.relative(current.plan.rootPath, action.retainTarget)],
|
|
2584
|
+
);
|
|
2585
|
+
}
|
|
2586
|
+
const retainTarget = path.join(
|
|
2587
|
+
resolved.parentPath,
|
|
2588
|
+
path.basename(action.retainTarget),
|
|
2589
|
+
);
|
|
2590
|
+
if (
|
|
2591
|
+
retainTarget !== action.retainTarget
|
|
2592
|
+
|| resolved.parentId !== action.retainTargetParentNodeId
|
|
2593
|
+
|| JSON.stringify(resolved.parentIdentity)
|
|
2594
|
+
!== JSON.stringify(action.retainTargetParentIdentity || null)
|
|
2595
|
+
) {
|
|
2596
|
+
changed = true;
|
|
2597
|
+
}
|
|
2598
|
+
return {
|
|
2599
|
+
...action,
|
|
2600
|
+
retainTarget,
|
|
2601
|
+
retainTargetParentNodeId: resolved.parentId,
|
|
2602
|
+
retainTargetParentIdentity: resolved.parentIdentity,
|
|
2603
|
+
};
|
|
2604
|
+
});
|
|
2605
|
+
if (!changed) return current;
|
|
2606
|
+
return materialization.updateCommittedPlan(
|
|
2607
|
+
current,
|
|
2608
|
+
{ ...current.plan, actions },
|
|
2609
|
+
database,
|
|
2610
|
+
);
|
|
2611
|
+
}
|
|
2612
|
+
|
|
2613
|
+
function captureRetainedPurgeOutputs(plan, retained, database) {
|
|
2614
|
+
const observed = localTree().reconcileWorkspaceTree(plan.resourceId, {
|
|
2615
|
+
database,
|
|
2616
|
+
skipRecovery: true,
|
|
2617
|
+
source: 'workspace-tree:retained-after-purge',
|
|
2618
|
+
});
|
|
2619
|
+
if (observed.held?.length) {
|
|
2620
|
+
holdRetainedSuccessors(
|
|
2621
|
+
plan,
|
|
2622
|
+
observed.held[0]?.reason || 'retained-birth-pending',
|
|
2623
|
+
observed.held.map((item) => item.operation || item),
|
|
2624
|
+
database,
|
|
2625
|
+
);
|
|
2626
|
+
}
|
|
2627
|
+
for (const item of retained) {
|
|
2628
|
+
for (const retainedPath of item.retainedPaths || []) {
|
|
2629
|
+
if (
|
|
2630
|
+
pathExists(retainedPath)
|
|
2631
|
+
&& !localTree().nodeForPath(retainedPath, { database })
|
|
2632
|
+
) {
|
|
2633
|
+
holdRetainedSuccessors(
|
|
2634
|
+
plan,
|
|
2635
|
+
'retained-birth-pending',
|
|
2636
|
+
[],
|
|
2637
|
+
database,
|
|
2638
|
+
[path.relative(plan.rootPath, retainedPath)],
|
|
2639
|
+
);
|
|
2640
|
+
}
|
|
2641
|
+
}
|
|
2642
|
+
}
|
|
2643
|
+
}
|
|
2644
|
+
|
|
2645
|
+
function finishTreeOperationPlan(held, database, testHooks = {}) {
|
|
2646
|
+
const materialization = require('./tree-materialization');
|
|
2647
|
+
let settled = held;
|
|
2648
|
+
let observationCommit = null;
|
|
2649
|
+
try {
|
|
2650
|
+
settled = settlePurgeRetentionTargets(held, database);
|
|
2651
|
+
const finished = materialization.finishOperation(settled, database, {
|
|
2652
|
+
afterOutputReceipt(outputs, plan) {
|
|
2653
|
+
if (typeof testHooks.afterRetainedOutputReceipt === 'function') {
|
|
2654
|
+
testHooks.afterRetainedOutputReceipt(outputs, plan);
|
|
2655
|
+
}
|
|
2656
|
+
if (testHooks.crashAfterRetainedOutputReceipt === true) {
|
|
2657
|
+
throw new Error('simulated workspace tree crash after retained output receipt');
|
|
2658
|
+
}
|
|
2659
|
+
},
|
|
2660
|
+
afterOutputMove(outputs, plan) {
|
|
2661
|
+
if (typeof testHooks.afterRetainedOutputMove === 'function') {
|
|
2662
|
+
testHooks.afterRetainedOutputMove(outputs, plan);
|
|
2663
|
+
}
|
|
2664
|
+
if (testHooks.crashAfterRetainedOutputMove === true) {
|
|
2665
|
+
throw new Error('simulated workspace tree crash after retained output move');
|
|
2666
|
+
}
|
|
2667
|
+
},
|
|
2668
|
+
retain(retained, plan) {
|
|
2669
|
+
if (plan.operation?.kind === 'tree.purge') {
|
|
2670
|
+
captureRetainedPurgeOutputs(plan, retained, database);
|
|
2671
|
+
}
|
|
2672
|
+
},
|
|
2673
|
+
afterRetain() {
|
|
2674
|
+
if (testHooks.crashAfterRetainedBirths === true) {
|
|
2675
|
+
throw new Error('simulated workspace tree crash after retained births');
|
|
2676
|
+
}
|
|
2677
|
+
},
|
|
2678
|
+
finalizeControlState(finalPlan) {
|
|
2679
|
+
materialization.clearOperationSuccessors(finalPlan, database);
|
|
2680
|
+
observationCommit = clearTreePlanHoldInTransaction(
|
|
2681
|
+
finalPlan,
|
|
2682
|
+
database,
|
|
2683
|
+
);
|
|
2684
|
+
},
|
|
2685
|
+
});
|
|
2686
|
+
require('./store').publishTreeObservationCommit(observationCommit);
|
|
2687
|
+
return finished;
|
|
2688
|
+
} catch (error) {
|
|
2689
|
+
if (error?.code === 'WORKSPACE_TREE_PARENT_RACE') {
|
|
2690
|
+
holdRetainedSuccessors(
|
|
2691
|
+
settled.plan,
|
|
2692
|
+
'retained-parent-moved',
|
|
2693
|
+
[],
|
|
2694
|
+
database,
|
|
2695
|
+
);
|
|
2696
|
+
}
|
|
2697
|
+
const current = materialization.getOperation(
|
|
2698
|
+
held.resourceId,
|
|
2699
|
+
held.mutationId,
|
|
2700
|
+
database,
|
|
2701
|
+
);
|
|
2702
|
+
holdClassifiedPlanRetry(current?.plan || settled.plan, error, database);
|
|
2703
|
+
}
|
|
2704
|
+
}
|
|
2705
|
+
|
|
2706
|
+
function projectStoredTreeOperation(held, database) {
|
|
2707
|
+
const before = planNodes(held.plan, 'beforeNodes');
|
|
2708
|
+
const after = planNodes(held.plan, 'afterNodes');
|
|
2709
|
+
const changedPaths = persistNodes(
|
|
2710
|
+
database,
|
|
2711
|
+
before,
|
|
2712
|
+
after,
|
|
2713
|
+
held.plan.resourceId,
|
|
2714
|
+
);
|
|
2715
|
+
if (Array.isArray(held.plan.officialAfterNodes)) {
|
|
2716
|
+
require('./tree/floor').write({
|
|
2717
|
+
schemaVersion: 1,
|
|
2718
|
+
workspaceId: held.plan.resourceId,
|
|
2719
|
+
version: held.plan.version,
|
|
2720
|
+
nodes: new Map(held.plan.officialAfterNodes.map((node) => [node.id, node])),
|
|
2721
|
+
}, held.plan.authorityEpoch, { database });
|
|
2722
|
+
}
|
|
2723
|
+
if (changedPaths.length) require('../state/docs').rebindNodePaths(changedPaths);
|
|
2724
|
+
return changedPaths;
|
|
2725
|
+
}
|
|
2726
|
+
|
|
2727
|
+
function causalSuccessorMutationId(plan, operation, index) {
|
|
2728
|
+
return `mut_${crypto.createHash('sha256')
|
|
2729
|
+
.update(
|
|
2730
|
+
`workspace-tree-causal-successor@1\0${plan.resourceId}\0`
|
|
2731
|
+
+ `${plan.mutationId}\0${index}\0${operation.kind}\0`
|
|
2732
|
+
+ `${operation.nodeId}\0${operation.parentId || ''}\0${operation.name || ''}`,
|
|
2733
|
+
)
|
|
2734
|
+
.digest('hex')}`;
|
|
2735
|
+
}
|
|
2736
|
+
|
|
2737
|
+
function holdCarriedSuccessorsForPermission(plan, operations, database) {
|
|
2738
|
+
holdRetainedSuccessors(plan, 'forbidden', operations, database);
|
|
2739
|
+
}
|
|
2740
|
+
|
|
2741
|
+
function adoptCarriedCreateBindings(plan, database, testHooks = {}) {
|
|
2742
|
+
let bindings = Array.isArray(plan.carriedCreates)
|
|
2743
|
+
? [...plan.carriedCreates]
|
|
2744
|
+
: [];
|
|
2745
|
+
let deletionBindings = Array.isArray(plan.carriedCreateDeletions)
|
|
2746
|
+
? [...plan.carriedCreateDeletions]
|
|
2747
|
+
: [];
|
|
2748
|
+
let causalSuccessors = Array.isArray(plan.causalSuccessors)
|
|
2749
|
+
? [...plan.causalSuccessors]
|
|
2750
|
+
: [];
|
|
2751
|
+
if (!bindings.length && !deletionBindings.length) return [];
|
|
2752
|
+
const workspace = workspaceRow(plan.resourceId, database);
|
|
2753
|
+
if (!workspace) {
|
|
2754
|
+
throw invalid('workspace tree carried create lost its workspace', 409);
|
|
2755
|
+
}
|
|
2756
|
+
const materialization = require('./tree-materialization');
|
|
2757
|
+
const intended = planNodes(plan, 'afterNodes');
|
|
2758
|
+
const persistCarriedState = () => {
|
|
2759
|
+
const held = materialization.getOperation(
|
|
2760
|
+
plan.resourceId,
|
|
2761
|
+
plan.mutationId,
|
|
2762
|
+
database,
|
|
2763
|
+
);
|
|
2764
|
+
if (!held || held.state !== 'prepared') {
|
|
2765
|
+
throw invalid(
|
|
2766
|
+
`workspace tree carried state lost operation ${plan.mutationId}`,
|
|
2767
|
+
409,
|
|
2768
|
+
);
|
|
2769
|
+
}
|
|
2770
|
+
const nextPlan = {
|
|
2771
|
+
...held.plan,
|
|
2772
|
+
carriedCreates: bindings,
|
|
2773
|
+
carriedCreateDeletions: deletionBindings,
|
|
2774
|
+
causalSuccessors,
|
|
2775
|
+
};
|
|
2776
|
+
if (JSON.stringify(held.plan) !== JSON.stringify(nextPlan)) {
|
|
2777
|
+
materialization.replacePreparedOperation(held, nextPlan, database);
|
|
2778
|
+
}
|
|
2779
|
+
plan = nextPlan;
|
|
2780
|
+
};
|
|
2781
|
+
|
|
2782
|
+
let locations = bindings.length
|
|
2783
|
+
? materialization.locateOperationBindings(plan, bindings)
|
|
2784
|
+
: new Map();
|
|
2785
|
+
let bindingsChanged = false;
|
|
2786
|
+
const refreshedBindings = [];
|
|
2787
|
+
for (const binding of bindings) {
|
|
2788
|
+
const candidates = locations.get(binding.nodeId) || [];
|
|
2789
|
+
if (candidates.length !== 0) {
|
|
2790
|
+
refreshedBindings.push(binding);
|
|
2791
|
+
continue;
|
|
2792
|
+
}
|
|
2793
|
+
let successorIdentity = null;
|
|
2794
|
+
try {
|
|
2795
|
+
successorIdentity = materialization.identity(
|
|
2796
|
+
binding.path,
|
|
2797
|
+
{ deep: false },
|
|
2798
|
+
);
|
|
2799
|
+
} catch (error) {
|
|
2800
|
+
if (!isPathAbsentError(error)) throw error;
|
|
2801
|
+
}
|
|
2802
|
+
bindingsChanged = true;
|
|
2803
|
+
if (
|
|
2804
|
+
intended.get(binding.nodeId)?.kind === 'file'
|
|
2805
|
+
&& successorIdentity?.kind === 'file'
|
|
2806
|
+
) {
|
|
2807
|
+
refreshedBindings.push({
|
|
2808
|
+
...binding,
|
|
2809
|
+
identity: successorIdentity,
|
|
2810
|
+
refreshAtSamePath: true,
|
|
2811
|
+
});
|
|
2812
|
+
} else if (
|
|
2813
|
+
!deletionBindings.some((entry) => entry.nodeId === binding.nodeId)
|
|
2814
|
+
) {
|
|
2815
|
+
deletionBindings.push({
|
|
2816
|
+
nodeId: binding.nodeId,
|
|
2817
|
+
identity: binding.identity,
|
|
2818
|
+
});
|
|
2819
|
+
}
|
|
2820
|
+
}
|
|
2821
|
+
if (bindingsChanged) {
|
|
2822
|
+
bindings = refreshedBindings;
|
|
2823
|
+
persistCarriedState();
|
|
2824
|
+
locations = bindings.length
|
|
2825
|
+
? materialization.locateOperationBindings(plan, bindings)
|
|
2826
|
+
: new Map();
|
|
2827
|
+
}
|
|
2828
|
+
const resolved = bindings.map((binding) => {
|
|
2829
|
+
const candidates = locations.get(binding.nodeId) || [];
|
|
2830
|
+
if (candidates.length !== 1) {
|
|
2831
|
+
holdRetainedSuccessors(
|
|
2832
|
+
plan,
|
|
2833
|
+
candidates.length > 1
|
|
2834
|
+
? 'ambiguous-physical-alias'
|
|
2835
|
+
: 'retained-output-changed',
|
|
2836
|
+
[],
|
|
2837
|
+
database,
|
|
2838
|
+
(candidates.length ? candidates : [binding.path])
|
|
2839
|
+
.map((target) => path.relative(plan.rootPath, target)),
|
|
2840
|
+
);
|
|
2841
|
+
}
|
|
2842
|
+
const relativePath = relativeWorkspacePath(workspace.path, candidates[0]);
|
|
2843
|
+
if (!relativePath || !shouldSync(relativePath)) {
|
|
2844
|
+
throw invalid(
|
|
2845
|
+
`workspace tree carried create ${binding.nodeId} left its visible workspace`,
|
|
2846
|
+
409,
|
|
2847
|
+
);
|
|
2848
|
+
}
|
|
2849
|
+
return {
|
|
2850
|
+
...binding,
|
|
2851
|
+
path: candidates[0],
|
|
2852
|
+
relativePath,
|
|
2853
|
+
};
|
|
2854
|
+
}).sort((left, right) => (
|
|
2855
|
+
left.relativePath.split('/').length - right.relativePath.split('/').length
|
|
2856
|
+
|| left.relativePath.localeCompare(right.relativePath)
|
|
2857
|
+
));
|
|
2858
|
+
const resolvedById = new Map(
|
|
2859
|
+
resolved.map((binding) => [binding.nodeId, binding]),
|
|
2860
|
+
);
|
|
2861
|
+
let pathsChanged = false;
|
|
2862
|
+
bindings = bindings.map((binding) => {
|
|
2863
|
+
const resolvedBinding = resolvedById.get(binding.nodeId);
|
|
2864
|
+
if (!resolvedBinding || binding.path === resolvedBinding.path) {
|
|
2865
|
+
return binding;
|
|
2866
|
+
}
|
|
2867
|
+
pathsChanged = true;
|
|
2868
|
+
return {
|
|
2869
|
+
...binding,
|
|
2870
|
+
path: resolvedBinding.path,
|
|
2871
|
+
};
|
|
2872
|
+
});
|
|
2873
|
+
if (pathsChanged) persistCarriedState();
|
|
2874
|
+
if (
|
|
2875
|
+
(bindingsChanged || pathsChanged)
|
|
2876
|
+
&& typeof testHooks.afterCarriedCreateRefresh === 'function'
|
|
2877
|
+
) {
|
|
2878
|
+
testHooks.afterCarriedCreateRefresh(bindings, deletionBindings);
|
|
2879
|
+
}
|
|
2880
|
+
|
|
2881
|
+
const before = new Map(
|
|
2882
|
+
database.prepare('SELECT * FROM workspace_nodes WHERE workspace_id = ?')
|
|
2883
|
+
.all(plan.resourceId)
|
|
2884
|
+
.map(rowToNode)
|
|
2885
|
+
.map((node) => [node.id, node]),
|
|
2886
|
+
);
|
|
2887
|
+
let causalState = new Map(intended);
|
|
2888
|
+
for (const operation of causalSuccessors) {
|
|
2889
|
+
causalState = applyTreeOperation(causalState, operation);
|
|
2890
|
+
}
|
|
2891
|
+
const augmented = new Map(before);
|
|
2892
|
+
const carriedByPath = new Map(
|
|
2893
|
+
resolved.map((binding) => [path.resolve(binding.path), binding.nodeId]),
|
|
2894
|
+
);
|
|
2895
|
+
const physicalParentId = (parentPath) => {
|
|
2896
|
+
if (!pathExists(parentPath)) return null;
|
|
2897
|
+
const stat = fs.lstatSync(parentPath);
|
|
2898
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) return null;
|
|
2899
|
+
const matches = database.prepare(`
|
|
2900
|
+
SELECT id
|
|
2901
|
+
FROM workspace_nodes
|
|
2902
|
+
WHERE workspace_id = ?
|
|
2903
|
+
AND kind = 'folder'
|
|
2904
|
+
AND device_id = ?
|
|
2905
|
+
AND inode = ?
|
|
2906
|
+
AND missing_at IS NULL
|
|
2907
|
+
AND trashed_at IS NULL
|
|
2908
|
+
AND purged_at IS NULL
|
|
2909
|
+
`).all(
|
|
2910
|
+
plan.resourceId,
|
|
2911
|
+
String(stat.dev),
|
|
2912
|
+
String(stat.ino),
|
|
2913
|
+
);
|
|
2914
|
+
return matches.length === 1 ? matches[0].id : null;
|
|
2915
|
+
};
|
|
2916
|
+
const newSuccessors = [];
|
|
2917
|
+
const resolvingParents = new Set();
|
|
2918
|
+
const resolvePhysicalParent = (parentPath) => {
|
|
2919
|
+
const resolvedPath = path.resolve(parentPath);
|
|
2920
|
+
if (resolvedPath === path.resolve(workspace.path)) return workspace.id;
|
|
2921
|
+
const carriedId = carriedByPath.get(resolvedPath);
|
|
2922
|
+
if (carriedId) return carriedId;
|
|
2923
|
+
const nodeId = (
|
|
2924
|
+
localTree().nodeForPath(parentPath, { database })?.node.id
|
|
2925
|
+
|| physicalParentId(parentPath)
|
|
2926
|
+
);
|
|
2927
|
+
if (!nodeId || resolvingParents.has(nodeId)) return null;
|
|
2928
|
+
const template = causalState.get(nodeId);
|
|
2929
|
+
if (!template) return null;
|
|
2930
|
+
resolvingParents.add(nodeId);
|
|
2931
|
+
const parentId = resolvePhysicalParent(path.dirname(parentPath));
|
|
2932
|
+
resolvingParents.delete(nodeId);
|
|
2933
|
+
if (!parentId) return null;
|
|
2934
|
+
const name = path.basename(parentPath);
|
|
2935
|
+
if (template.parentId !== parentId || template.name !== name) {
|
|
2936
|
+
const successor = template.parentId === parentId
|
|
2937
|
+
? {
|
|
2938
|
+
kind: 'tree.rename',
|
|
2939
|
+
workspaceId: plan.resourceId,
|
|
2940
|
+
nodeId,
|
|
2941
|
+
name,
|
|
2942
|
+
changedAt: template.updatedAt || template.createdAt,
|
|
2943
|
+
}
|
|
2944
|
+
: {
|
|
2945
|
+
kind: 'tree.move',
|
|
2946
|
+
workspaceId: plan.resourceId,
|
|
2947
|
+
nodeId,
|
|
2948
|
+
parentId,
|
|
2949
|
+
previousParentId: template.parentId,
|
|
2950
|
+
name,
|
|
2951
|
+
changedAt: template.updatedAt || template.createdAt,
|
|
2952
|
+
};
|
|
2953
|
+
newSuccessors.push(successor);
|
|
2954
|
+
causalState = applyTreeOperation(causalState, successor);
|
|
2955
|
+
}
|
|
2956
|
+
augmented.set(nodeId, {
|
|
2957
|
+
...causalState.get(nodeId),
|
|
2958
|
+
missingAt: null,
|
|
2959
|
+
});
|
|
2960
|
+
return nodeId;
|
|
2961
|
+
};
|
|
2962
|
+
const deletedIds = new Set(
|
|
2963
|
+
deletionBindings.map((binding) => binding.nodeId),
|
|
2964
|
+
);
|
|
2965
|
+
const topLevelDeletions = deletionBindings.filter((binding) => {
|
|
2966
|
+
let parentId = causalState.get(binding.nodeId)?.parentId || null;
|
|
2967
|
+
const seen = new Set();
|
|
2968
|
+
while (parentId && !seen.has(parentId)) {
|
|
2969
|
+
if (deletedIds.has(parentId)) return false;
|
|
2970
|
+
seen.add(parentId);
|
|
2971
|
+
parentId = causalState.get(parentId)?.parentId || null;
|
|
2972
|
+
}
|
|
2973
|
+
return true;
|
|
2974
|
+
}).sort((left, right) => left.nodeId.localeCompare(right.nodeId));
|
|
2975
|
+
for (const binding of topLevelDeletions) {
|
|
2976
|
+
const template = causalState.get(binding.nodeId);
|
|
2977
|
+
if (!template) {
|
|
2978
|
+
throw invalid(
|
|
2979
|
+
`workspace tree deleted carried create ${binding.nodeId} has no logical node`,
|
|
2980
|
+
409,
|
|
2981
|
+
);
|
|
2982
|
+
}
|
|
2983
|
+
if (template.trashedAt) continue;
|
|
2984
|
+
if (!template.parentId || !augmented.has(template.parentId)) {
|
|
2985
|
+
throw invalid(
|
|
2986
|
+
`workspace tree deleted carried create ${binding.nodeId} lost its logical parent`,
|
|
2987
|
+
409,
|
|
2988
|
+
);
|
|
2989
|
+
}
|
|
2990
|
+
const changedAt = template.updatedAt || template.createdAt;
|
|
2991
|
+
const successor = {
|
|
2992
|
+
kind: 'tree.trash',
|
|
2993
|
+
workspaceId: plan.resourceId,
|
|
2994
|
+
nodeId: binding.nodeId,
|
|
2995
|
+
changedAt,
|
|
2996
|
+
trashedAt: changedAt,
|
|
2997
|
+
};
|
|
2998
|
+
newSuccessors.push(successor);
|
|
2999
|
+
causalState = applyTreeOperation(causalState, successor);
|
|
3000
|
+
}
|
|
3001
|
+
|
|
3002
|
+
const desiredRelocations = [];
|
|
3003
|
+
for (const binding of resolved) {
|
|
3004
|
+
const template = causalState.get(binding.nodeId);
|
|
3005
|
+
if (!template) {
|
|
3006
|
+
throw invalid(
|
|
3007
|
+
`workspace tree carried create ${binding.nodeId} has no logical node`,
|
|
3008
|
+
409,
|
|
3009
|
+
);
|
|
3010
|
+
}
|
|
3011
|
+
const parentPath = path.dirname(binding.path);
|
|
3012
|
+
const parentId = resolvePhysicalParent(parentPath);
|
|
3013
|
+
if (!parentId || !augmented.has(parentId)) {
|
|
3014
|
+
throw invalid(
|
|
3015
|
+
`workspace tree carried create ${binding.nodeId} lost its logical parent`,
|
|
3016
|
+
409,
|
|
3017
|
+
);
|
|
3018
|
+
}
|
|
3019
|
+
const name = path.basename(binding.path);
|
|
3020
|
+
augmented.set(binding.nodeId, {
|
|
3021
|
+
...template,
|
|
3022
|
+
parentId,
|
|
3023
|
+
name,
|
|
3024
|
+
trashedAt: null,
|
|
3025
|
+
purgedAt: null,
|
|
3026
|
+
missingAt: null,
|
|
3027
|
+
});
|
|
3028
|
+
if (template.parentId === parentId && template.name === name) continue;
|
|
3029
|
+
const changedAt = template.updatedAt || template.createdAt;
|
|
3030
|
+
desiredRelocations.push({
|
|
3031
|
+
binding,
|
|
3032
|
+
changedAt,
|
|
3033
|
+
name,
|
|
3034
|
+
parentId,
|
|
3035
|
+
});
|
|
3036
|
+
}
|
|
3037
|
+
|
|
3038
|
+
const relocatingIds = new Set(
|
|
3039
|
+
desiredRelocations.map(({ binding }) => binding.nodeId),
|
|
3040
|
+
);
|
|
3041
|
+
const blockers = new Set();
|
|
3042
|
+
for (const desired of desiredRelocations) {
|
|
3043
|
+
const occupant = Array.from(causalState.values()).find((node) => (
|
|
3044
|
+
node.workspaceId === plan.resourceId
|
|
3045
|
+
&& node.parentId === desired.parentId
|
|
3046
|
+
&& node.name === desired.name
|
|
3047
|
+
&& node.id !== desired.binding.nodeId
|
|
3048
|
+
&& !node.purgedAt
|
|
3049
|
+
&& !node.trashedAt
|
|
3050
|
+
));
|
|
3051
|
+
if (!occupant) continue;
|
|
3052
|
+
if (!relocatingIds.has(occupant.id)) {
|
|
3053
|
+
throw invalid(
|
|
3054
|
+
`workspace tree carried destination ${desired.binding.relativePath} `
|
|
3055
|
+
+ `is still owned by ${occupant.id}`,
|
|
3056
|
+
409,
|
|
3057
|
+
);
|
|
3058
|
+
}
|
|
3059
|
+
blockers.add(occupant.id);
|
|
3060
|
+
}
|
|
3061
|
+
|
|
3062
|
+
// A carried path permutation is one set transition. Vacate every logical
|
|
3063
|
+
// endpoint that blocks another endpoint before any node enters its final
|
|
3064
|
+
// slot. The temporary names are deterministic, syncable path segments and
|
|
3065
|
+
// become durable in causalSuccessors before the first journal birth.
|
|
3066
|
+
for (const nodeId of [...blockers].sort()) {
|
|
3067
|
+
const template = causalState.get(nodeId);
|
|
3068
|
+
const digest = crypto.createHash('sha256')
|
|
3069
|
+
.update(
|
|
3070
|
+
`workspace-tree-carried-vacancy@1\0${plan.resourceId}\0`
|
|
3071
|
+
+ `${plan.mutationId}\0${nodeId}`,
|
|
3072
|
+
)
|
|
3073
|
+
.digest('hex')
|
|
3074
|
+
.slice(0, 24);
|
|
3075
|
+
let suffix = 0;
|
|
3076
|
+
let name;
|
|
3077
|
+
do {
|
|
3078
|
+
name = `amalgm-relocation-${digest}${suffix ? `-${suffix}` : ''}`;
|
|
3079
|
+
suffix += 1;
|
|
3080
|
+
} while (Array.from(causalState.values()).some((node) => (
|
|
3081
|
+
node.workspaceId === plan.resourceId
|
|
3082
|
+
&& node.parentId === template.parentId
|
|
3083
|
+
&& node.name === name
|
|
3084
|
+
&& node.id !== nodeId
|
|
3085
|
+
&& !node.purgedAt
|
|
3086
|
+
&& !node.trashedAt
|
|
3087
|
+
)));
|
|
3088
|
+
const successor = {
|
|
3089
|
+
kind: 'tree.rename',
|
|
3090
|
+
workspaceId: plan.resourceId,
|
|
3091
|
+
nodeId,
|
|
3092
|
+
name,
|
|
3093
|
+
changedAt: template.updatedAt || template.createdAt,
|
|
3094
|
+
};
|
|
3095
|
+
newSuccessors.push(successor);
|
|
3096
|
+
causalState = applyTreeOperation(causalState, successor);
|
|
3097
|
+
}
|
|
3098
|
+
|
|
3099
|
+
for (const desired of desiredRelocations) {
|
|
3100
|
+
const template = causalState.get(desired.binding.nodeId);
|
|
3101
|
+
const successor = template.parentId === desired.parentId
|
|
3102
|
+
? {
|
|
3103
|
+
kind: 'tree.rename',
|
|
3104
|
+
workspaceId: plan.resourceId,
|
|
3105
|
+
nodeId: desired.binding.nodeId,
|
|
3106
|
+
name: desired.name,
|
|
3107
|
+
changedAt: desired.changedAt,
|
|
3108
|
+
}
|
|
3109
|
+
: {
|
|
3110
|
+
kind: 'tree.move',
|
|
3111
|
+
workspaceId: plan.resourceId,
|
|
3112
|
+
nodeId: desired.binding.nodeId,
|
|
3113
|
+
parentId: desired.parentId,
|
|
3114
|
+
previousParentId: template.parentId,
|
|
3115
|
+
name: desired.name,
|
|
3116
|
+
changedAt: desired.changedAt,
|
|
3117
|
+
};
|
|
3118
|
+
newSuccessors.push(successor);
|
|
3119
|
+
causalState = applyTreeOperation(causalState, successor);
|
|
3120
|
+
}
|
|
3121
|
+
persistNodes(database, before, augmented, plan.resourceId);
|
|
3122
|
+
if (newSuccessors.length) {
|
|
3123
|
+
causalSuccessors = [...causalSuccessors, ...newSuccessors];
|
|
3124
|
+
persistCarriedState();
|
|
3125
|
+
if (typeof testHooks.afterCarriedSuccessorPlan === 'function') {
|
|
3126
|
+
testHooks.afterCarriedSuccessorPlan(causalSuccessors);
|
|
3127
|
+
}
|
|
3128
|
+
}
|
|
3129
|
+
|
|
3130
|
+
try {
|
|
3131
|
+
let chainBefore = new Map(intended);
|
|
3132
|
+
for (const [index, operation] of causalSuccessors.entries()) {
|
|
3133
|
+
const mutationId = causalSuccessorMutationId(
|
|
3134
|
+
plan,
|
|
3135
|
+
operation,
|
|
3136
|
+
index,
|
|
3137
|
+
);
|
|
3138
|
+
const existing = database.prepare(`
|
|
3139
|
+
SELECT mutation_state
|
|
3140
|
+
FROM mutation_journal
|
|
3141
|
+
WHERE local_resource = ? AND mutation_id = ?
|
|
3142
|
+
`).get(`${TREE_RESOURCE_PREFIX}${plan.resourceId}`, mutationId);
|
|
3143
|
+
const chainAfter = applyTreeOperation(chainBefore, operation);
|
|
3144
|
+
if (existing && existing.mutation_state !== 'saving-local') {
|
|
3145
|
+
chainBefore = chainAfter;
|
|
3146
|
+
continue;
|
|
3147
|
+
}
|
|
3148
|
+
const operationBefore = new Map(
|
|
3149
|
+
database.prepare(
|
|
3150
|
+
'SELECT * FROM workspace_nodes WHERE workspace_id = ?',
|
|
3151
|
+
).all(plan.resourceId)
|
|
3152
|
+
.map(rowToNode)
|
|
3153
|
+
.map((node) => [node.id, node]),
|
|
3154
|
+
);
|
|
3155
|
+
const logicalTarget = chainBefore.get(operation.nodeId);
|
|
3156
|
+
if (!logicalTarget) {
|
|
3157
|
+
throw invalid(
|
|
3158
|
+
`workspace tree causal successor ${operation.nodeId} lost its logical node`,
|
|
3159
|
+
409,
|
|
3160
|
+
);
|
|
3161
|
+
}
|
|
3162
|
+
operationBefore.set(operation.nodeId, logicalTarget);
|
|
3163
|
+
const targetExists = Boolean(database.prepare(`
|
|
3164
|
+
SELECT 1 AS present
|
|
3165
|
+
FROM workspace_nodes
|
|
3166
|
+
WHERE workspace_id = ? AND id = ?
|
|
3167
|
+
`).get(plan.resourceId, operation.nodeId));
|
|
3168
|
+
localTree().commitTreeOperation(operation, {
|
|
3169
|
+
database,
|
|
3170
|
+
beforeNodes: operationBefore,
|
|
3171
|
+
diskAlreadyApplied: true,
|
|
3172
|
+
mutationId,
|
|
3173
|
+
source: 'workspace-tree:causal-successor',
|
|
3174
|
+
...(!targetExists ? {
|
|
3175
|
+
role: require('./access-store').currentUserRole(
|
|
3176
|
+
logicalTarget.parentId,
|
|
3177
|
+
{ database },
|
|
3178
|
+
),
|
|
3179
|
+
} : {}),
|
|
3180
|
+
});
|
|
3181
|
+
chainBefore = chainAfter;
|
|
3182
|
+
}
|
|
3183
|
+
} catch (error) {
|
|
3184
|
+
if (error?.statusCode !== 403) throw error;
|
|
3185
|
+
holdCarriedSuccessorsForPermission(
|
|
3186
|
+
plan,
|
|
3187
|
+
causalSuccessors,
|
|
3188
|
+
database,
|
|
3189
|
+
);
|
|
3190
|
+
}
|
|
3191
|
+
if (typeof testHooks.afterCarriedSuccessorBirth === 'function') {
|
|
3192
|
+
testHooks.afterCarriedSuccessorBirth(causalSuccessors);
|
|
3193
|
+
}
|
|
3194
|
+
return causalSuccessors;
|
|
3195
|
+
}
|
|
3196
|
+
|
|
3197
|
+
function holdPreparedRetainedOutputs(plan, reason, paths, database) {
|
|
3198
|
+
holdRetainedSuccessors(
|
|
3199
|
+
plan,
|
|
3200
|
+
reason,
|
|
3201
|
+
[],
|
|
3202
|
+
database,
|
|
3203
|
+
Array.from(new Set(paths)).sort(),
|
|
3204
|
+
);
|
|
3205
|
+
}
|
|
3206
|
+
|
|
3207
|
+
function validatePreparedRetainedOutputs(plan, database) {
|
|
3208
|
+
const outputs = Array.isArray(plan.retainedOutputs)
|
|
3209
|
+
? plan.retainedOutputs
|
|
3210
|
+
: [];
|
|
3211
|
+
if (!outputs.length || plan.retainedOutputsAdopted === true) return;
|
|
3212
|
+
const changed = [];
|
|
3213
|
+
const fileIdentities = new Set();
|
|
3214
|
+
for (const output of outputs) {
|
|
3215
|
+
if (!pathExists(output.path)) {
|
|
3216
|
+
changed.push(output.path);
|
|
3217
|
+
continue;
|
|
3218
|
+
}
|
|
3219
|
+
const actual = fs.lstatSync(output.path);
|
|
3220
|
+
const kind = actual.isSymbolicLink()
|
|
3221
|
+
? 'symlink'
|
|
3222
|
+
: actual.isDirectory()
|
|
3223
|
+
? 'folder'
|
|
3224
|
+
: 'file';
|
|
3225
|
+
if (
|
|
3226
|
+
kind !== output.identity.kind
|
|
3227
|
+
|| String(actual.dev) !== String(output.identity.dev)
|
|
3228
|
+
|| String(actual.ino) !== String(output.identity.ino)
|
|
3229
|
+
) {
|
|
3230
|
+
changed.push(output.path);
|
|
3231
|
+
continue;
|
|
3232
|
+
}
|
|
3233
|
+
if (kind === 'file') fileIdentities.add(`${actual.dev}:${actual.ino}`);
|
|
3234
|
+
}
|
|
3235
|
+
if (changed.length) {
|
|
3236
|
+
holdPreparedRetainedOutputs(
|
|
3237
|
+
plan,
|
|
3238
|
+
'retained-output-changed',
|
|
3239
|
+
changed.map((target) => path.relative(plan.rootPath, target)),
|
|
3240
|
+
database,
|
|
3241
|
+
);
|
|
3242
|
+
}
|
|
3243
|
+
const aliases = localTree().scanWorkspacePhysicalAliases(
|
|
3244
|
+
plan.resourceId,
|
|
3245
|
+
{ database },
|
|
3246
|
+
).filter((group) => (
|
|
3247
|
+
fileIdentities.has(`${group.deviceId}:${group.inode}`)
|
|
3248
|
+
)).flatMap((group) => group.paths);
|
|
3249
|
+
if (aliases.length) {
|
|
3250
|
+
holdPreparedRetainedOutputs(
|
|
3251
|
+
plan,
|
|
3252
|
+
'ambiguous-physical-alias',
|
|
3253
|
+
aliases,
|
|
3254
|
+
database,
|
|
3255
|
+
);
|
|
3256
|
+
}
|
|
3257
|
+
}
|
|
3258
|
+
|
|
3259
|
+
function claimPreparedRetainedOutputs(plan, database) {
|
|
3260
|
+
if (
|
|
3261
|
+
plan.retainedOutputsAdopted === true
|
|
3262
|
+
|| !Array.isArray(plan.retainedOutputs)
|
|
3263
|
+
|| !plan.retainedOutputs.length
|
|
3264
|
+
) {
|
|
3265
|
+
return plan;
|
|
3266
|
+
}
|
|
3267
|
+
const materialization = require('./tree-materialization');
|
|
3268
|
+
const held = materialization.getOperation(
|
|
3269
|
+
plan.resourceId,
|
|
3270
|
+
plan.mutationId,
|
|
3271
|
+
database,
|
|
3272
|
+
);
|
|
3273
|
+
if (!held || held.state !== 'prepared') {
|
|
3274
|
+
throw invalid(
|
|
3275
|
+
`workspace tree retained output claim lost operation ${plan.mutationId}`,
|
|
3276
|
+
409,
|
|
3277
|
+
);
|
|
3278
|
+
}
|
|
3279
|
+
return materialization.replacePreparedOperation(
|
|
3280
|
+
held,
|
|
3281
|
+
{
|
|
3282
|
+
...held.plan,
|
|
3283
|
+
// The exact output receipts were just verified. From this durable point
|
|
3284
|
+
// onward, any different disk identity is a locally owned successor for
|
|
3285
|
+
// the carried chain (or a fresh independent birth), never an excuse to
|
|
3286
|
+
// re-apply the obsolete receipt after a crash.
|
|
3287
|
+
retainedOutputsAdopted: true,
|
|
3288
|
+
},
|
|
3289
|
+
database,
|
|
3290
|
+
).plan;
|
|
3291
|
+
}
|
|
3292
|
+
|
|
3293
|
+
function verifyPreparedRetainedBirths(plan, database, causallyProjected) {
|
|
3294
|
+
const projectedPaths = pathMapForSnapshot({
|
|
3295
|
+
workspaceId: plan.resourceId,
|
|
3296
|
+
nodes: causallyProjected,
|
|
3297
|
+
});
|
|
3298
|
+
const activeByPath = new Map();
|
|
3299
|
+
for (const node of causallyProjected.values()) {
|
|
3300
|
+
if (!activeNode(node)) continue;
|
|
3301
|
+
activeByPath.set(projectedPaths.get(node.id), node);
|
|
3302
|
+
}
|
|
3303
|
+
for (const output of plan.retainedOutputs || []) {
|
|
3304
|
+
const relativePath = relativeWorkspacePath(plan.rootPath, output.path);
|
|
3305
|
+
const expected = relativePath == null ? null : activeByPath.get(relativePath);
|
|
3306
|
+
const physical = physicalBindingAtPath(output.path, database);
|
|
3307
|
+
if (!pathExists(output.path)) {
|
|
3308
|
+
if (!expected) continue;
|
|
3309
|
+
holdPreparedRetainedOutputs(
|
|
3310
|
+
plan,
|
|
3311
|
+
'retained-output-changed',
|
|
3312
|
+
[path.relative(plan.rootPath, output.path)],
|
|
3313
|
+
database,
|
|
3314
|
+
);
|
|
3315
|
+
}
|
|
3316
|
+
if (!physical || !expected || physical.node.id !== expected.id) {
|
|
3317
|
+
holdPreparedRetainedOutputs(
|
|
3318
|
+
plan,
|
|
3319
|
+
'retained-birth-pending',
|
|
3320
|
+
[path.relative(plan.rootPath, output.path)],
|
|
3321
|
+
database,
|
|
3322
|
+
);
|
|
3323
|
+
}
|
|
3324
|
+
}
|
|
3325
|
+
const carriedOwnerIds = new Set(
|
|
3326
|
+
(plan.retainedOutputs || [])
|
|
3327
|
+
.filter((output) => (
|
|
3328
|
+
output.provenance === 'carried-source' && output.ownerNodeId
|
|
3329
|
+
))
|
|
3330
|
+
.map((output) => output.ownerNodeId),
|
|
3331
|
+
);
|
|
3332
|
+
for (const ownerNodeId of carriedOwnerIds) {
|
|
3333
|
+
const owner = rowToNode(database.prepare(`
|
|
3334
|
+
SELECT *
|
|
3335
|
+
FROM workspace_nodes
|
|
3336
|
+
WHERE workspace_id = ? AND id = ?
|
|
3337
|
+
`).get(plan.resourceId, ownerNodeId));
|
|
3338
|
+
if (owner?.trashedAt || owner?.purgedAt) continue;
|
|
3339
|
+
const expected = causallyProjected.get(ownerNodeId);
|
|
3340
|
+
if (!owner || !expected || !activeNode(expected)) {
|
|
3341
|
+
holdPreparedRetainedOutputs(
|
|
3342
|
+
plan,
|
|
3343
|
+
'retained-uuid-mismatch',
|
|
3344
|
+
[],
|
|
3345
|
+
database,
|
|
3346
|
+
);
|
|
3347
|
+
}
|
|
3348
|
+
const ownerRelativePath = projectedPaths.get(ownerNodeId);
|
|
3349
|
+
const ownerPath = ownerRelativePath
|
|
3350
|
+
? path.join(plan.rootPath, ...ownerRelativePath.split('/'))
|
|
3351
|
+
: plan.rootPath;
|
|
3352
|
+
const physical = physicalBindingAtPath(ownerPath, database);
|
|
3353
|
+
if (!physical || physical.node.id !== ownerNodeId) {
|
|
3354
|
+
holdPreparedRetainedOutputs(
|
|
3355
|
+
plan,
|
|
3356
|
+
'retained-uuid-mismatch',
|
|
3357
|
+
[ownerRelativePath],
|
|
3358
|
+
database,
|
|
3359
|
+
);
|
|
3360
|
+
}
|
|
3361
|
+
}
|
|
3362
|
+
}
|
|
3363
|
+
|
|
3364
|
+
function crossTreeOperationWithRaceCapture(held, database, testHooks = {}) {
|
|
3365
|
+
const materialization = require('./tree-materialization');
|
|
3366
|
+
let current = held;
|
|
3367
|
+
const maxAttempts = 4;
|
|
3368
|
+
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
|
3369
|
+
try {
|
|
3370
|
+
return materialization.crossOperation(current, database, testHooks);
|
|
3371
|
+
} catch (error) {
|
|
3372
|
+
if (
|
|
3373
|
+
!['WORKSPACE_TREE_SOURCE_RACE', 'WORKSPACE_TREE_PARENT_RACE']
|
|
3374
|
+
.includes(error?.code)
|
|
3375
|
+
|| !error.operationPlan
|
|
3376
|
+
) {
|
|
3377
|
+
throw error;
|
|
3378
|
+
}
|
|
3379
|
+
let retainedPlan = error.operationPlan;
|
|
3380
|
+
validatePreparedRetainedOutputs(retainedPlan, database);
|
|
3381
|
+
retainedPlan = claimPreparedRetainedOutputs(retainedPlan, database);
|
|
3382
|
+
const carriedSuccessors = adoptCarriedCreateBindings(
|
|
3383
|
+
retainedPlan,
|
|
3384
|
+
database,
|
|
3385
|
+
testHooks,
|
|
3386
|
+
);
|
|
3387
|
+
retainedPlan = materialization.getOperation(
|
|
3388
|
+
retainedPlan.resourceId,
|
|
3389
|
+
retainedPlan.mutationId,
|
|
3390
|
+
database,
|
|
3391
|
+
)?.plan || retainedPlan;
|
|
3392
|
+
const persistObservedOperation = (operation, mutationId) => {
|
|
3393
|
+
materialization.appendOperationSuccessor(
|
|
3394
|
+
retainedPlan,
|
|
3395
|
+
mutationId,
|
|
3396
|
+
operation,
|
|
3397
|
+
database,
|
|
3398
|
+
);
|
|
3399
|
+
};
|
|
3400
|
+
const observedNow = settleRetainedOutputBindings(
|
|
3401
|
+
retainedPlan,
|
|
3402
|
+
database,
|
|
3403
|
+
{
|
|
3404
|
+
source: 'workspace-tree:source-race',
|
|
3405
|
+
testHooks,
|
|
3406
|
+
persistObservedOperation,
|
|
3407
|
+
hold(reason, paths) {
|
|
3408
|
+
holdPreparedRetainedOutputs(
|
|
3409
|
+
retainedPlan,
|
|
3410
|
+
reason,
|
|
3411
|
+
paths,
|
|
3412
|
+
database,
|
|
3413
|
+
);
|
|
3414
|
+
},
|
|
3415
|
+
},
|
|
3416
|
+
);
|
|
3417
|
+
retainedPlan = materialization.getOperation(
|
|
3418
|
+
retainedPlan.resourceId,
|
|
3419
|
+
retainedPlan.mutationId,
|
|
3420
|
+
database,
|
|
3421
|
+
)?.plan || retainedPlan;
|
|
3422
|
+
const durableObserved = materialization.listOperationSuccessors(
|
|
3423
|
+
retainedPlan,
|
|
3424
|
+
database,
|
|
3425
|
+
).map((entry) => entry.operation);
|
|
3426
|
+
const observedOperations = durableObserved.length
|
|
3427
|
+
? durableObserved
|
|
3428
|
+
: observedNow;
|
|
3429
|
+
let causallyProjected = retainedPlan.sourceRaceCaptured === true
|
|
3430
|
+
? planNodes(retainedPlan, 'afterNodes')
|
|
3431
|
+
: applyTreeOperation(
|
|
3432
|
+
planNodes(retainedPlan, 'beforeNodes'),
|
|
3433
|
+
normalizeTreeOperation(retainedPlan.operation),
|
|
3434
|
+
);
|
|
3435
|
+
for (const successor of carriedSuccessors) {
|
|
3436
|
+
causallyProjected = applyTreeOperation(causallyProjected, successor);
|
|
3437
|
+
}
|
|
3438
|
+
for (const successor of observedOperations) {
|
|
3439
|
+
causallyProjected = applyTreeOperation(causallyProjected, successor);
|
|
3440
|
+
}
|
|
3441
|
+
verifyPreparedRetainedBirths(
|
|
3442
|
+
retainedPlan,
|
|
3443
|
+
database,
|
|
3444
|
+
causallyProjected,
|
|
3445
|
+
);
|
|
3446
|
+
const currentNodes = database.prepare(
|
|
3447
|
+
'SELECT * FROM workspace_nodes WHERE workspace_id = ?',
|
|
3448
|
+
).all(retainedPlan.resourceId).map(rowToNode);
|
|
3449
|
+
const workspace = workspaceRow(retainedPlan.resourceId, database);
|
|
3450
|
+
if (!workspace) throw invalid('workspace tree source race lost its workspace', 409);
|
|
3451
|
+
const replacement = {
|
|
3452
|
+
...physicalOperationPlan(
|
|
3453
|
+
workspace,
|
|
3454
|
+
new Map(currentNodes.map((node) => [node.id, node])),
|
|
3455
|
+
causallyProjected,
|
|
3456
|
+
normalizeTreeOperation(retainedPlan.operation),
|
|
3457
|
+
{
|
|
3458
|
+
envelope: {
|
|
3459
|
+
mutationId: retainedPlan.mutationId,
|
|
3460
|
+
authorityEpoch: retainedPlan.authorityEpoch,
|
|
3461
|
+
},
|
|
3462
|
+
version: retainedPlan.version,
|
|
3463
|
+
},
|
|
3464
|
+
Array.isArray(retainedPlan.officialAfterNodes)
|
|
3465
|
+
? new Map(
|
|
3466
|
+
retainedPlan.officialAfterNodes
|
|
3467
|
+
.map((node) => [node.id, node]),
|
|
3468
|
+
)
|
|
3469
|
+
: null,
|
|
3470
|
+
{
|
|
3471
|
+
channelId: retainedPlan.channelId,
|
|
3472
|
+
database,
|
|
3473
|
+
source: retainedPlan.event?.source,
|
|
3474
|
+
resourceVersion: retainedPlan.event?.resourceVersion != null,
|
|
3475
|
+
projectFullDelta: true,
|
|
3476
|
+
},
|
|
3477
|
+
),
|
|
3478
|
+
event: retainedPlan.event,
|
|
3479
|
+
origin: retainedPlan.origin,
|
|
3480
|
+
sourceRaceCaptured: true,
|
|
3481
|
+
};
|
|
3482
|
+
const predecessor = materialization.getOperation(
|
|
3483
|
+
replacement.resourceId,
|
|
3484
|
+
replacement.mutationId,
|
|
3485
|
+
database,
|
|
3486
|
+
);
|
|
3487
|
+
current = predecessor
|
|
3488
|
+
? materialization.replacePreparedOperation(
|
|
3489
|
+
predecessor,
|
|
3490
|
+
replacement,
|
|
3491
|
+
database,
|
|
3492
|
+
)
|
|
3493
|
+
: materialization.insertOperation(replacement, database);
|
|
3494
|
+
}
|
|
3495
|
+
}
|
|
3496
|
+
holdRetainedSuccessors(
|
|
3497
|
+
current.plan,
|
|
3498
|
+
'source-race-retry-exhausted',
|
|
3499
|
+
[],
|
|
3500
|
+
database,
|
|
3501
|
+
);
|
|
3502
|
+
}
|
|
3503
|
+
|
|
3504
|
+
function applyStoredTreeOperationPlan(held, database, testHooks = {}) {
|
|
3505
|
+
const treeMaterialization = require('./tree-materialization');
|
|
3506
|
+
if (held.state === 'committed') {
|
|
3507
|
+
finishTreeOperationPlan(held, database, testHooks);
|
|
3508
|
+
return { duplicate: true, recovered: true };
|
|
3509
|
+
}
|
|
3510
|
+
const operation = normalizeTreeOperation(held.plan.operation);
|
|
3511
|
+
validateLiveSymlinkTargets(
|
|
3512
|
+
planNodes(held.plan, 'afterNodes'),
|
|
3513
|
+
held.plan.resourceId,
|
|
3514
|
+
);
|
|
3515
|
+
if (operation.kind === 'tree.trash' || operation.kind === 'tree.purge') {
|
|
3516
|
+
const before = planNodes(held.plan, 'beforeNodes');
|
|
3517
|
+
const affected = new Set(
|
|
3518
|
+
Array.from(before.values())
|
|
3519
|
+
.filter((node) => (
|
|
3520
|
+
node.id === operation.nodeId
|
|
3521
|
+
|| hasAncestorInSet(node, before, new Set([operation.nodeId]))
|
|
3522
|
+
))
|
|
3523
|
+
.map((node) => node.id),
|
|
3524
|
+
);
|
|
3525
|
+
require('../state/docs').closeDocsByNodeIds(affected);
|
|
3526
|
+
}
|
|
3527
|
+
try {
|
|
3528
|
+
held = crossTreeOperationWithRaceCapture(held, database, testHooks);
|
|
3529
|
+
} catch (error) {
|
|
3530
|
+
holdClassifiedPlanRetry(held.plan, error, database);
|
|
3531
|
+
}
|
|
3532
|
+
projectStoredTreeOperation(held, database);
|
|
3533
|
+
if (testHooks.crashAfterOperationProjection === true) {
|
|
3534
|
+
throw new Error('simulated workspace tree crash after operation projection');
|
|
3535
|
+
}
|
|
3536
|
+
const mutations = require('../state/mutations');
|
|
3537
|
+
const mutation = mutations.getMutation(
|
|
3538
|
+
held.plan.channelId,
|
|
3539
|
+
held.plan.mutationId,
|
|
3540
|
+
{ database, revealOperation: true },
|
|
3541
|
+
);
|
|
3542
|
+
if (!mutation) {
|
|
3543
|
+
throw invalid(`workspace tree operation ${held.plan.mutationId} lost its journal row`, 409);
|
|
3544
|
+
}
|
|
3545
|
+
const completed = (
|
|
3546
|
+
mutation.origin === 'local'
|
|
3547
|
+
&& mutation.state === 'synced'
|
|
3548
|
+
&& mutation.officialCloudVersion != null
|
|
3549
|
+
) ? mutations.completeOfficialMaterialization(
|
|
3550
|
+
held.plan.channelId,
|
|
3551
|
+
held.plan.mutationId,
|
|
3552
|
+
held.plan.event,
|
|
3553
|
+
{
|
|
3554
|
+
database,
|
|
3555
|
+
finalize() {
|
|
3556
|
+
held = treeMaterialization.markOperationCommitted(held, database);
|
|
3557
|
+
},
|
|
3558
|
+
},
|
|
3559
|
+
)
|
|
3560
|
+
: mutations.completeMaterializedMutation(
|
|
3561
|
+
held.plan.channelId,
|
|
3562
|
+
held.plan.mutationId,
|
|
3563
|
+
held.plan.event,
|
|
3564
|
+
{
|
|
3565
|
+
database,
|
|
3566
|
+
finalize() {
|
|
3567
|
+
held = treeMaterialization.markOperationCommitted(held, database);
|
|
3568
|
+
},
|
|
3569
|
+
},
|
|
3570
|
+
);
|
|
3571
|
+
if (testHooks.crashAfterOperationCommit === true) {
|
|
3572
|
+
throw new Error('simulated workspace tree crash after operation commit');
|
|
3573
|
+
}
|
|
3574
|
+
finishTreeOperationPlan(held, database, testHooks);
|
|
3575
|
+
return { ...completed, recovered: true };
|
|
3576
|
+
}
|
|
3577
|
+
|
|
3578
|
+
function recoverTreeOperationPlans(resourceId, database, testHooks = {}) {
|
|
3579
|
+
let result = null;
|
|
3580
|
+
for (const held of require('./tree-materialization').listOperations(database, resourceId)) {
|
|
3581
|
+
result = applyStoredTreeOperationPlan(held, database, testHooks);
|
|
3582
|
+
}
|
|
3583
|
+
return result;
|
|
3584
|
+
}
|
|
3585
|
+
|
|
3586
|
+
function applyCloudTreeMutation(input, options = {}) {
|
|
3587
|
+
const envelope = input?.envelope || {};
|
|
3588
|
+
if (envelope.contract !== 'workspace-tree@1') throw invalid('unsupported workspace tree mutation', 409);
|
|
3589
|
+
const operation = normalizeTreeOperation(envelope.operation);
|
|
3590
|
+
if (operation.workspaceId !== envelope.resourceId || operation.kind !== envelope.operationKind) {
|
|
3591
|
+
throw invalid('workspace tree mutation identity mismatch', 409);
|
|
3592
|
+
}
|
|
3593
|
+
const database = options.database || openLocalDb();
|
|
3594
|
+
recoverTreePlans(envelope.resourceId, database);
|
|
3595
|
+
recoverTreeOperationPlans(envelope.resourceId, database);
|
|
3596
|
+
const workspace = workspaceRow(operation.workspaceId, database);
|
|
3597
|
+
if (!workspace) throw invalid('workspace tree has no local folder', 404);
|
|
3598
|
+
let held = null;
|
|
3599
|
+
const result = require('../state/mutations').ingestCloudMutation({
|
|
3600
|
+
...input,
|
|
3601
|
+
localResource: `${TREE_RESOURCE_PREFIX}${workspace.id}`,
|
|
3602
|
+
}, {
|
|
3603
|
+
database,
|
|
3604
|
+
materialize() {
|
|
3605
|
+
const before = new Map(
|
|
3606
|
+
database.prepare('SELECT * FROM workspace_nodes WHERE workspace_id = ?').all(workspace.id)
|
|
3607
|
+
.map(rowToNode)
|
|
3608
|
+
.map((node) => [node.id, node]),
|
|
3609
|
+
);
|
|
3610
|
+
const replica = require('../state/replicas')
|
|
3611
|
+
.getReplicaByResourceId(workspace.id, { database });
|
|
3612
|
+
let floor = require('./tree/floor').read(workspace.id, { database });
|
|
3613
|
+
if (!floor) {
|
|
3614
|
+
floor = require('./tree/floor').ensure({
|
|
3615
|
+
schemaVersion: 1,
|
|
3616
|
+
workspaceId: workspace.id,
|
|
3617
|
+
version: replica?.appliedVersion || 0,
|
|
3618
|
+
nodes: before,
|
|
3619
|
+
}, projectedAuthorityEpoch(workspace.id, database) || envelope.authorityEpoch, {
|
|
3620
|
+
database,
|
|
3621
|
+
});
|
|
3622
|
+
}
|
|
3623
|
+
if (floor.version !== Number(input.version) - 1) {
|
|
3624
|
+
throw invalid(
|
|
3625
|
+
`workspace tree floor gap: expected ${Number(input.version) - 1}, found ${floor.version}`,
|
|
3626
|
+
409,
|
|
3627
|
+
);
|
|
3628
|
+
}
|
|
3629
|
+
const officialAfterNodes = applyTreeOperation(floor.snapshot.nodes, operation);
|
|
3630
|
+
const officialAfter = {
|
|
3631
|
+
schemaVersion: 1,
|
|
3632
|
+
workspaceId: workspace.id,
|
|
3633
|
+
version: Number(input.version),
|
|
3634
|
+
nodes: officialAfterNodes,
|
|
3635
|
+
};
|
|
3636
|
+
const projected = snapshotWithLocalOverlay(
|
|
3637
|
+
officialAfter,
|
|
3638
|
+
before,
|
|
3639
|
+
workspace.id,
|
|
3640
|
+
database,
|
|
3641
|
+
);
|
|
3642
|
+
held = require('./tree-materialization').insertOperation(
|
|
3643
|
+
physicalOperationPlan(
|
|
3644
|
+
workspace,
|
|
3645
|
+
before,
|
|
3646
|
+
projected.nodes,
|
|
3647
|
+
operation,
|
|
3648
|
+
input,
|
|
3649
|
+
officialAfterNodes,
|
|
3650
|
+
{ database },
|
|
3651
|
+
),
|
|
3652
|
+
database,
|
|
3653
|
+
);
|
|
3654
|
+
if (typeof options.testHooks?.afterOperationPlan === 'function') {
|
|
3655
|
+
options.testHooks.afterOperationPlan(held.plan);
|
|
3656
|
+
}
|
|
3657
|
+
try {
|
|
3658
|
+
held = crossTreeOperationWithRaceCapture(
|
|
3659
|
+
held,
|
|
3660
|
+
database,
|
|
3661
|
+
options.testHooks,
|
|
3662
|
+
);
|
|
3663
|
+
} catch (error) {
|
|
3664
|
+
holdClassifiedPlanRetry(held.plan, error, database);
|
|
3665
|
+
}
|
|
3666
|
+
projectStoredTreeOperation(held, database);
|
|
3667
|
+
if (options.testHooks?.crashAfterOperationProjection === true) {
|
|
3668
|
+
throw new Error('simulated workspace tree crash after operation projection');
|
|
3669
|
+
}
|
|
3670
|
+
},
|
|
3671
|
+
event: () => ({
|
|
3672
|
+
resource: `${TREE_RESOURCE_PREFIX}${workspace.id}`,
|
|
3673
|
+
op: 'update',
|
|
3674
|
+
id: operation.nodeId,
|
|
3675
|
+
patch: operation,
|
|
3676
|
+
sharedResourceId: workspace.id,
|
|
380
3677
|
source: 'workspace-tree:cloud',
|
|
381
3678
|
resourceVersion: Number(input.version),
|
|
382
3679
|
}),
|
|
3680
|
+
finalize() {
|
|
3681
|
+
if (!held) return;
|
|
3682
|
+
held = require('./tree-materialization').markOperationCommitted(held, database);
|
|
3683
|
+
},
|
|
383
3684
|
});
|
|
3685
|
+
if (held) {
|
|
3686
|
+
if (options.testHooks?.crashAfterOperationCommit === true) {
|
|
3687
|
+
throw new Error('simulated workspace tree crash after operation commit');
|
|
3688
|
+
}
|
|
3689
|
+
finishTreeOperationPlan(held, database, options.testHooks);
|
|
3690
|
+
}
|
|
3691
|
+
return result;
|
|
384
3692
|
}
|
|
385
3693
|
|
|
386
3694
|
module.exports = {
|
|
387
3695
|
applyCloudTreeMutation,
|
|
3696
|
+
crossTreeOperationWithRaceCapture,
|
|
388
3697
|
enqueueWorkspaceContentAttachments,
|
|
3698
|
+
finishTreeOperationPlan,
|
|
3699
|
+
holdClassifiedPlanRetry,
|
|
389
3700
|
installCloudTreeSnapshot,
|
|
3701
|
+
localTreeOperationPlan,
|
|
3702
|
+
projectStoredTreeOperation,
|
|
3703
|
+
reconcileContentOverlay,
|
|
3704
|
+
recoverAllTreePlans,
|
|
390
3705
|
workspaceTreeSnapshot,
|
|
391
3706
|
};
|