amalgm 0.1.245 → 0.1.246
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -0
- package/lib/cli.js +146 -8
- package/lib/layout.js +89 -4
- package/lib/shared-realtime-tunnel.js +384 -43
- package/lib/supervisor.js +0 -3
- package/package.json +2 -2
- package/runtime/scripts/amalgm-mcp/adapters/contract.js +197 -0
- package/runtime/scripts/amalgm-mcp/adapters/filesystem.js +135 -0
- package/runtime/scripts/amalgm-mcp/adapters/git.js +84 -0
- package/runtime/scripts/amalgm-mcp/adapters/index.js +43 -0
- package/runtime/scripts/amalgm-mcp/adapters/reference.js +31 -0
- package/runtime/scripts/amalgm-mcp/adapters/truth.js +19 -0
- package/runtime/scripts/amalgm-mcp/browser/cookie-jar.js +20 -68
- package/runtime/scripts/amalgm-mcp/index.js +4 -0
- package/runtime/scripts/amalgm-mcp/lib/layout.js +89 -4
- package/runtime/scripts/amalgm-mcp/observer/README.md +259 -0
- package/runtime/scripts/amalgm-mcp/observer/apply.js +150 -0
- package/runtime/scripts/amalgm-mcp/observer/continuity.js +111 -0
- package/runtime/scripts/amalgm-mcp/observer/edges.js +111 -0
- package/runtime/scripts/amalgm-mcp/observer/index.js +1146 -0
- package/runtime/scripts/amalgm-mcp/observer/scan.js +387 -0
- package/runtime/scripts/amalgm-mcp/observer/store.js +262 -0
- package/runtime/scripts/amalgm-mcp/observer/verify.js +200 -0
- package/runtime/scripts/amalgm-mcp/observer/watch.js +62 -0
- package/runtime/scripts/amalgm-mcp/project-context/store.js +19 -87
- package/runtime/scripts/amalgm-mcp/registration/classify.js +33 -0
- package/runtime/scripts/amalgm-mcp/registration/entity-cloud.js +456 -0
- package/runtime/scripts/amalgm-mcp/registration/entity-content.js +345 -0
- package/runtime/scripts/amalgm-mcp/registration/index.js +752 -0
- package/runtime/scripts/amalgm-mcp/registration/refusal.js +42 -0
- package/runtime/scripts/amalgm-mcp/registration/repo-followers.js +127 -0
- package/runtime/scripts/amalgm-mcp/registration/repo-states.js +109 -0
- package/runtime/scripts/amalgm-mcp/registration/service.js +310 -0
- package/runtime/scripts/amalgm-mcp/registration/tree.js +178 -0
- package/runtime/scripts/amalgm-mcp/registry/evidence.js +826 -0
- package/runtime/scripts/amalgm-mcp/registry/index.js +666 -0
- package/runtime/scripts/amalgm-mcp/registry/store.js +290 -0
- package/runtime/scripts/amalgm-mcp/repocard/README.md +103 -0
- package/runtime/scripts/amalgm-mcp/repocard/apply.js +111 -0
- package/runtime/scripts/amalgm-mcp/repocard/capture-worker.js +94 -0
- package/runtime/scripts/amalgm-mcp/repocard/capture.js +149 -0
- package/runtime/scripts/amalgm-mcp/repocard/follow.js +310 -0
- package/runtime/scripts/amalgm-mcp/repocard/git.js +48 -0
- package/runtime/scripts/amalgm-mcp/repocard/index.js +48 -0
- package/runtime/scripts/amalgm-mcp/server/local-service-router.js +2 -0
- package/runtime/scripts/amalgm-mcp/server/routes/entities.js +287 -0
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +0 -13
- package/runtime/scripts/amalgm-mcp/state/attachments.js +11 -45
- package/runtime/scripts/amalgm-mcp/state/db.js +46 -215
- package/runtime/scripts/amalgm-mcp/state/docs.js +301 -1101
- package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +0 -99
- package/runtime/scripts/amalgm-mcp/state/mutations.js +130 -507
- package/runtime/scripts/amalgm-mcp/state/promotions.js +1 -21
- package/runtime/scripts/amalgm-mcp/state/replicas.js +13 -134
- package/runtime/scripts/amalgm-mcp/state/shared-rest.js +3 -46
- package/runtime/scripts/amalgm-mcp/tests/adapters.test.js +379 -0
- package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +0 -193
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.life.test.js +449 -0
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.rig.js +107 -0
- package/runtime/scripts/amalgm-mcp/tests/doorbell.matrix.watch.test.js +213 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/adapter.js +72 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/assert.js +497 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/bind.js +91 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/graph.js +1031 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/ledger.js +234 -0
- package/runtime/scripts/amalgm-mcp/tests/entity-oracle/referee.js +118 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.oracle.test.js +1365 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.registry.test.js +967 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.rig.js +239 -0
- package/runtime/scripts/amalgm-mcp/tests/entity.storm.test.js +486 -0
- package/runtime/scripts/amalgm-mcp/tests/fake-watch.js +35 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.links.test.js +289 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.rig.js +103 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.roots.test.js +505 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.storm.test.js +389 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.territory.test.js +845 -0
- package/runtime/scripts/amalgm-mcp/tests/observer.test.js +657 -0
- package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +17 -32
- package/runtime/scripts/amalgm-mcp/tests/registration.service.test.js +256 -0
- package/runtime/scripts/amalgm-mcp/tests/registration.test.js +931 -0
- package/runtime/scripts/amalgm-mcp/tests/repocard.storm.test.js +284 -0
- package/runtime/scripts/amalgm-mcp/tests/repocard.test.js +371 -0
- package/runtime/scripts/amalgm-mcp/tests/repofollow.storm.test.js +435 -0
- package/runtime/scripts/amalgm-mcp/tests/repofollow.test.js +675 -0
- package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +0 -84
- package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +13 -376
- package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +0 -309
- package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +30 -758
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +3 -72
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +1 -89
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +24 -55
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +141 -424
- package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +3 -171
- package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +7 -220
- package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +236 -179
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +326 -2228
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +3 -5319
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +1 -191
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +17 -3140
- package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +740 -137
- package/runtime/scripts/amalgm-mcp/workspace/access-store.js +1 -5
- package/runtime/scripts/amalgm-mcp/workspace/card.js +11 -35
- package/runtime/scripts/amalgm-mcp/workspace/cards.js +14 -39
- package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +147 -587
- package/runtime/scripts/amalgm-mcp/workspace/git.js +34 -406
- package/runtime/scripts/amalgm-mcp/workspace/merge.js +181 -102
- package/runtime/scripts/amalgm-mcp/workspace/objects.js +56 -221
- package/runtime/scripts/amalgm-mcp/workspace/reducer.js +538 -1964
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +28 -49
- package/runtime/scripts/amalgm-mcp/workspace/store.js +4 -77
- package/runtime/scripts/amalgm-mcp/workspace/transition.js +192 -1734
- package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +1 -7
- package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +61 -73
- package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +6 -51
- package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +8 -79
- package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +146 -3461
- package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +71 -899
- package/runtime/scripts/amalgm-mcp/workspace/wall.js +538 -370
- package/runtime/scripts/local-gateway.js +1 -0
- package/runtime/scripts/amalgm-mcp/state/doc-disk.js +0 -550
- package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +0 -399
- package/runtime/scripts/amalgm-mcp/tests/fixtures/code-project-stream-v2.json +0 -1
- package/runtime/scripts/amalgm-mcp/tests/workspace-durable.test.js +0 -24
- package/runtime/scripts/amalgm-mcp/tests/workspace-local-intent.test.js +0 -1999
- package/runtime/scripts/amalgm-mcp/tests/workspace-native-file-transaction.test.js +0 -364
- package/runtime/scripts/amalgm-mcp/tests/workspace-provenance.test.js +0 -185
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer-liveness.test.js +0 -26
- package/runtime/scripts/amalgm-mcp/tests/workspace-sync-stats.test.js +0 -119
- package/runtime/scripts/amalgm-mcp/tests/workspace-transition.test.js +0 -1446
- package/runtime/scripts/amalgm-mcp/workspace/content.js +0 -32
- package/runtime/scripts/amalgm-mcp/workspace/durable.js +0 -45
- package/runtime/scripts/amalgm-mcp/workspace/intent.js +0 -535
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/BUILD.md +0 -31
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/darwin-arm64/amalgm-file-transaction +0 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/linux-x64/amalgm-file-transaction +0 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/build.mjs +0 -72
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/client.js +0 -165
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.mod +0 -5
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.sum +0 -2
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/main.go +0 -2201
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/manifest.json +0 -19
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_darwin.go +0 -18
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_linux.go +0 -18
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_darwin.go +0 -33
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_linux.go +0 -47
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/verify.mjs +0 -43
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_darwin.go +0 -114
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_linux.go +0 -84
- package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +0 -771
- package/runtime/scripts/amalgm-mcp/workspace/provenance.js +0 -240
- package/runtime/scripts/amalgm-mcp/workspace/sync-stats.js +0 -240
- package/runtime/scripts/amalgm-mcp/workspace/tree/floor.js +0 -100
- package/runtime/scripts/amalgm-mcp/workspace/tree/observation-holds.js +0 -127
- package/runtime/scripts/amalgm-mcp/workspace/tree/path-state.js +0 -12
- package/runtime/scripts/amalgm-mcp/workspace/tree/root-binding.js +0 -125
- package/runtime/scripts/amalgm-mcp/workspace/tree/symlinks.js +0 -76
- package/runtime/scripts/amalgm-mcp/workspace/tree-materialization.js +0 -3766
|
@@ -3,19 +3,17 @@
|
|
|
3
3
|
const crypto = require('crypto');
|
|
4
4
|
const fs = require('fs');
|
|
5
5
|
const path = require('path');
|
|
6
|
-
const {
|
|
6
|
+
const { AMALGM_DIR } = require('../config');
|
|
7
7
|
const { openLocalDb } = require('../state/db');
|
|
8
|
-
const { applyTreeOperation
|
|
8
|
+
const { applyTreeOperation } = 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');
|
|
13
12
|
const {
|
|
14
13
|
hasAncestorInSet,
|
|
14
|
+
materializeOperation,
|
|
15
15
|
persistNodes,
|
|
16
|
-
relativePathForNode,
|
|
17
16
|
rowToNode,
|
|
18
|
-
trashPath,
|
|
19
17
|
workspaceRow,
|
|
20
18
|
} = require('./tree/local-state');
|
|
21
19
|
|
|
@@ -77,7 +75,7 @@ function safeSymlinkTarget(rootPath, linkPath, target) {
|
|
|
77
75
|
}
|
|
78
76
|
|
|
79
77
|
function activeNode(node) {
|
|
80
|
-
return Boolean(node && !node.
|
|
78
|
+
return Boolean(node && !node.trashedAt && !node.purgedAt);
|
|
81
79
|
}
|
|
82
80
|
|
|
83
81
|
function normalizeSnapshotPermissions(input, snapshot) {
|
|
@@ -102,15 +100,7 @@ function normalizeSnapshotPermissions(input, snapshot) {
|
|
|
102
100
|
});
|
|
103
101
|
}
|
|
104
102
|
|
|
105
|
-
function replaceTreeProjection(
|
|
106
|
-
database,
|
|
107
|
-
workspaceId,
|
|
108
|
-
rootPath,
|
|
109
|
-
snapshot,
|
|
110
|
-
paths,
|
|
111
|
-
permissions,
|
|
112
|
-
authorityEpoch,
|
|
113
|
-
) {
|
|
103
|
+
function replaceTreeProjection(database, workspaceId, snapshot, paths, permissions, authorityEpoch) {
|
|
114
104
|
const ordered = Array.from(snapshot.nodes.values()).sort((left, right) => {
|
|
115
105
|
const leftPath = paths.get(left.id);
|
|
116
106
|
const rightPath = paths.get(right.id);
|
|
@@ -122,11 +112,7 @@ function replaceTreeProjection(
|
|
|
122
112
|
.filter((nodeId) => !snapshot.nodes.has(nodeId));
|
|
123
113
|
if (removedIds.length) {
|
|
124
114
|
const placeholders = removedIds.map(() => '?').join(',');
|
|
125
|
-
|
|
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
|
-
}
|
|
115
|
+
database.prepare(`DELETE FROM doc_states WHERE node_id IN (${placeholders})`).run(...removedIds);
|
|
130
116
|
database.prepare(`DELETE FROM shared_resource_replicas WHERE resource_id IN (${placeholders})`).run(...removedIds);
|
|
131
117
|
database.prepare(`DELETE FROM shared_resource_attachments WHERE resource_id IN (${placeholders})`).run(...removedIds);
|
|
132
118
|
}
|
|
@@ -135,25 +121,14 @@ function replaceTreeProjection(
|
|
|
135
121
|
const insert = database.prepare(`
|
|
136
122
|
INSERT INTO workspace_nodes (
|
|
137
123
|
id, workspace_id, parent_id, kind, name, relative_path, symlink_target,
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
124
|
+
trashed_at, purged_at, created_at, updated_at
|
|
125
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
141
126
|
`);
|
|
142
127
|
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
|
-
}
|
|
152
128
|
insert.run(
|
|
153
129
|
node.id, node.workspaceId, node.parentId, node.kind, node.name,
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
node.purgedAt, node.createdAt, node.updatedAt,
|
|
130
|
+
paths.get(node.id), node.symlinkTarget, node.trashedAt, node.purgedAt,
|
|
131
|
+
node.createdAt, node.updatedAt,
|
|
157
132
|
);
|
|
158
133
|
}
|
|
159
134
|
const insertPermission = database.prepare(`
|
|
@@ -170,889 +145,80 @@ function replaceTreeProjection(
|
|
|
170
145
|
return ordered;
|
|
171
146
|
}
|
|
172
147
|
|
|
173
|
-
function
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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
|
-
}
|
|
225
|
-
}
|
|
226
|
-
return pending;
|
|
227
|
-
}
|
|
228
|
-
|
|
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
|
-
}
|
|
148
|
+
function createProjectedNode(rootPath, node, relativePath) {
|
|
149
|
+
const target = path.join(rootPath, ...relativePath.split('/'));
|
|
150
|
+
if (node.kind === 'folder') fs.mkdirSync(target, { recursive: true });
|
|
151
|
+
else if (node.kind === 'file') {
|
|
152
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
153
|
+
if (!fs.existsSync(target)) fs.writeFileSync(target, '');
|
|
154
|
+
} else if (!fs.existsSync(target) && safeSymlinkTarget(rootPath, target, node.symlinkTarget)) {
|
|
155
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
156
|
+
fs.symlinkSync(node.symlinkTarget, target);
|
|
367
157
|
}
|
|
368
|
-
return operations;
|
|
369
158
|
}
|
|
370
159
|
|
|
371
|
-
function
|
|
160
|
+
function materializeTreeProjection(rootPath, before, afterSnapshot, beforePaths, afterPaths) {
|
|
161
|
+
const after = afterSnapshot.nodes;
|
|
372
162
|
const removedIds = new Set();
|
|
373
163
|
for (const node of before.values()) {
|
|
374
164
|
if (node.id === node.workspaceId || !activeNode(node)) continue;
|
|
375
|
-
if (!activeNode(after.
|
|
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);
|
|
165
|
+
if (!activeNode(after.get(node.id))) removedIds.add(node.id);
|
|
401
166
|
}
|
|
402
|
-
const
|
|
403
|
-
|
|
404
|
-
|
|
167
|
+
const removedRoots = Array.from(removedIds)
|
|
168
|
+
.map((id) => before.get(id))
|
|
169
|
+
.filter((node) => !hasAncestorInSet(node, before, removedIds));
|
|
170
|
+
require('../state/docs').closeDocsByNodeIds(removedIds);
|
|
171
|
+
for (const node of removedRoots) {
|
|
405
172
|
const relative = beforePaths.get(node.id);
|
|
406
|
-
|
|
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;
|
|
173
|
+
if (relative) fs.rmSync(path.join(rootPath, ...relative.split('/')), { recursive: true, force: true });
|
|
491
174
|
}
|
|
492
|
-
return relativePath.slice(ancestorPath.length + 1);
|
|
493
|
-
}
|
|
494
175
|
|
|
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;
|
|
518
176
|
const changedIds = new Set();
|
|
519
177
|
for (const node of after.values()) {
|
|
520
|
-
if (
|
|
521
|
-
|
|
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
|
-
}
|
|
178
|
+
if (node.id === afterSnapshot.workspaceId || !activeNode(node) || !activeNode(before.get(node.id))) continue;
|
|
179
|
+
if (beforePaths.get(node.id) !== afterPaths.get(node.id)) changedIds.add(node.id);
|
|
532
180
|
}
|
|
533
|
-
const
|
|
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)
|
|
181
|
+
const changedRoots = Array.from(changedIds)
|
|
603
182
|
.map((id) => after.get(id))
|
|
604
|
-
.
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
const
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
)
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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
|
-
}];
|
|
183
|
+
.filter((node) => !hasAncestorInSet(node, after, changedIds));
|
|
184
|
+
const stagingRoot = path.join(AMALGM_DIR, 'staging', 'workspace-tree', crypto.randomBytes(12).toString('hex'));
|
|
185
|
+
const staged = [];
|
|
186
|
+
for (const node of changedRoots) {
|
|
187
|
+
const oldRelative = beforePaths.get(node.id);
|
|
188
|
+
const oldPath = path.join(rootPath, ...oldRelative.split('/'));
|
|
189
|
+
if (!fs.existsSync(oldPath)) continue;
|
|
190
|
+
const stagedPath = path.join(stagingRoot, node.id);
|
|
191
|
+
fs.mkdirSync(path.dirname(stagedPath), { recursive: true });
|
|
192
|
+
fs.renameSync(oldPath, stagedPath);
|
|
193
|
+
staged.push({ node, stagedPath });
|
|
194
|
+
}
|
|
195
|
+
for (const item of staged) {
|
|
196
|
+
const nextPath = path.join(rootPath, ...afterPaths.get(item.node.id).split('/'));
|
|
197
|
+
fs.mkdirSync(path.dirname(nextPath), { recursive: true });
|
|
198
|
+
fs.renameSync(item.stagedPath, nextPath);
|
|
199
|
+
}
|
|
200
|
+
fs.rmSync(stagingRoot, { recursive: true, force: true });
|
|
201
|
+
|
|
202
|
+
const ordered = Array.from(after.values()).sort((left, right) => {
|
|
203
|
+
const leftPath = afterPaths.get(left.id);
|
|
204
|
+
const rightPath = afterPaths.get(right.id);
|
|
205
|
+
return (leftPath ? leftPath.split('/').length : 0) - (rightPath ? rightPath.split('/').length : 0);
|
|
733
206
|
});
|
|
734
|
-
const
|
|
735
|
-
.
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
))
|
|
743
|
-
|
|
744
|
-
|
|
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;
|
|
207
|
+
for (const node of ordered) {
|
|
208
|
+
if (node.id === afterSnapshot.workspaceId || !activeNode(node)) continue;
|
|
209
|
+
const target = path.join(rootPath, ...afterPaths.get(node.id).split('/'));
|
|
210
|
+
if (!fs.existsSync(target)) createProjectedNode(rootPath, node, afterPaths.get(node.id));
|
|
211
|
+
}
|
|
212
|
+
const changedPaths = Array.from(changedIds).map((nodeId) => ({
|
|
213
|
+
nodeId,
|
|
214
|
+
oldPath: path.join(rootPath, ...beforePaths.get(nodeId).split('/')),
|
|
215
|
+
newPath: path.join(rootPath, ...afterPaths.get(nodeId).split('/')),
|
|
216
|
+
}));
|
|
217
|
+
if (changedPaths.length) require('../state/docs').rebindNodePaths(changedPaths);
|
|
1051
218
|
}
|
|
1052
219
|
|
|
1053
220
|
function enqueueProjectedContentAttachments(rootPath, snapshot, paths, input, database) {
|
|
1054
221
|
const resources = Array.isArray(input?.contentResources) ? input.contentResources : [];
|
|
1055
|
-
const deferredEvents = Array.isArray(input?.deferredEvents) ? input.deferredEvents : null;
|
|
1056
222
|
const queued = [];
|
|
1057
223
|
for (const resource of resources) {
|
|
1058
224
|
const nodeId = String(resource?.node_id || resource?.nodeId || '').toLowerCase();
|
|
@@ -1061,13 +227,11 @@ function enqueueProjectedContentAttachments(rootPath, snapshot, paths, input, da
|
|
|
1061
227
|
if (require('../state/replicas').getReplicaByResourceId(nodeId, { database })) continue;
|
|
1062
228
|
if (require('../state/attachments').getAttachmentByResource(nodeId, { database })) continue;
|
|
1063
229
|
const localPath = path.join(rootPath, ...paths.get(nodeId).split('/'));
|
|
1064
|
-
|
|
230
|
+
queued.push(require('../state/attachments').enqueueAttachment({
|
|
1065
231
|
attachmentId: `att_${crypto.randomBytes(16).toString('base64url')}`,
|
|
1066
232
|
resourceId: nodeId,
|
|
1067
233
|
localPath,
|
|
1068
|
-
}, { database
|
|
1069
|
-
queued.push(result.attachment);
|
|
1070
|
-
if (deferredEvents && result.deferredEvent) deferredEvents.push(result.deferredEvent);
|
|
234
|
+
}, { database }).attachment);
|
|
1071
235
|
}
|
|
1072
236
|
return queued;
|
|
1073
237
|
}
|
|
@@ -1089,2584 +253,123 @@ function enqueueWorkspaceContentAttachments(workspaceId, contentResources, optio
|
|
|
1089
253
|
);
|
|
1090
254
|
}
|
|
1091
255
|
|
|
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' };
|
|
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
256
|
function installCloudTreeSnapshot(pathInput, input, options = {}) {
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
schemaVersion: 1,
|
|
1197
|
-
});
|
|
257
|
+
if (input?.contract !== 'workspace-tree@1' || Number(input?.schemaVersion) !== 1) {
|
|
258
|
+
throw invalid('unsupported workspace tree contract', 409);
|
|
259
|
+
}
|
|
1198
260
|
const database = options.database || openLocalDb();
|
|
1199
261
|
const { checksum, snapshot } = decodeTreeSnapshot(input);
|
|
1200
262
|
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
|
-
}
|
|
1204
263
|
const rootPath = path.resolve(pathInput);
|
|
1205
|
-
|
|
1206
|
-
|
|
264
|
+
fs.mkdirSync(rootPath, { recursive: true });
|
|
265
|
+
const existingReplica = require('../state/replicas').getReplicaByResourceId(input.resourceId, { database });
|
|
1207
266
|
const paths = pathMapForSnapshot(snapshot);
|
|
1208
267
|
const permissions = normalizeSnapshotPermissions(input, snapshot);
|
|
1209
268
|
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 });
|
|
1215
269
|
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
|
-
}
|
|
1312
270
|
const before = new Map(
|
|
1313
271
|
database.prepare('SELECT * FROM workspace_nodes WHERE workspace_id = ?').all(snapshot.workspaceId)
|
|
1314
272
|
.map(rowToNode)
|
|
1315
273
|
.map((node) => [node.id, node]),
|
|
1316
274
|
);
|
|
1317
275
|
const beforePaths = pathMapForSnapshot({ workspaceId: snapshot.workspaceId, nodes: before });
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
checksum,
|
|
1335
|
-
permissions,
|
|
1336
|
-
contentResources: input.contentResources,
|
|
1337
|
-
initial: false,
|
|
1338
|
-
removalPreconditions: removalCapture.preconditions,
|
|
276
|
+
materializeTreeProjection(rootPath, before, snapshot, beforePaths, paths);
|
|
277
|
+
replaceTreeProjection(database, snapshot.workspaceId, snapshot, paths, permissions, input.authorityEpoch);
|
|
278
|
+
const replica = require('../state/replicas').advanceReplicaSnapshot({
|
|
279
|
+
resourceId: input.resourceId,
|
|
280
|
+
snapshotVersion: input.snapshotVersion,
|
|
281
|
+
snapshotChecksum: checksum,
|
|
282
|
+
authorityEpoch: input.authorityEpoch,
|
|
283
|
+
}, { database });
|
|
284
|
+
const contentAttachments = enqueueProjectedContentAttachments(rootPath, snapshot, paths, input, database);
|
|
285
|
+
require('../state/events').appendStateEvent({
|
|
286
|
+
resource: `${TREE_RESOURCE_PREFIX}${snapshot.workspaceId}`,
|
|
287
|
+
op: 'update', id: snapshot.workspaceId,
|
|
288
|
+
value: workspaceTreeSnapshot(snapshot.workspaceId, { database }),
|
|
289
|
+
sharedResourceId: snapshot.workspaceId,
|
|
290
|
+
source: 'workspace-tree:cloud-resnapshot',
|
|
291
|
+
resourceVersion: Number(input.snapshotVersion),
|
|
1339
292
|
});
|
|
1340
|
-
|
|
1341
|
-
return applyStoredTreePlan(held, database, options.testHooks);
|
|
293
|
+
return { replica, contentAttachments, duplicate: false };
|
|
1342
294
|
}
|
|
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 });
|
|
1351
295
|
if (fs.readdirSync(rootPath).length > 0) {
|
|
1352
296
|
throw invalid('cloud workspace attachment requires an empty folder', 409);
|
|
1353
297
|
}
|
|
1354
298
|
|
|
1355
|
-
const
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
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;
|
|
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
|
-
}
|
|
1403
|
-
|
|
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;
|
|
1417
|
-
}
|
|
1418
|
-
|
|
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;
|
|
299
|
+
const root = snapshot.nodes.get(snapshot.workspaceId);
|
|
300
|
+
const workspace = require('./store').upsertWorkspace({
|
|
301
|
+
id: snapshot.workspaceId,
|
|
302
|
+
path: rootPath,
|
|
303
|
+
name: root.name,
|
|
304
|
+
source: 'cloud',
|
|
305
|
+
}, { source: 'workspace:cloud-attach', seedOwner: false });
|
|
306
|
+
const ordered = replaceTreeProjection(
|
|
307
|
+
database, workspace.id, snapshot, paths, permissions, input.authorityEpoch,
|
|
308
|
+
);
|
|
309
|
+
for (const node of ordered) {
|
|
310
|
+
if (node.id === workspace.id || node.trashedAt || node.purgedAt) continue;
|
|
311
|
+
createProjectedNode(rootPath, node, paths.get(node.id));
|
|
1430
312
|
}
|
|
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
313
|
|
|
1447
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
314
|
+
const registered = require('../state/replicas').registerReplica({
|
|
315
|
+
resourceId: input.resourceId,
|
|
316
|
+
localResource: `${TREE_RESOURCE_PREFIX}${workspace.id}`,
|
|
317
|
+
localPath: rootPath,
|
|
318
|
+
contract: input.contract,
|
|
319
|
+
schemaVersion: input.schemaVersion,
|
|
320
|
+
authorityEpoch: input.authorityEpoch,
|
|
321
|
+
appliedVersion: input.snapshotVersion,
|
|
322
|
+
snapshotVersion: input.snapshotVersion,
|
|
323
|
+
snapshotChecksum: checksum,
|
|
324
|
+
}, { database });
|
|
325
|
+
const contentAttachments = enqueueProjectedContentAttachments(rootPath, snapshot, paths, input, database);
|
|
326
|
+
require('../state/events').appendStateEvent({
|
|
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 };
|
|
1464
336
|
}
|
|
1465
337
|
|
|
1466
|
-
function
|
|
1467
|
-
const
|
|
1468
|
-
|
|
1469
|
-
const
|
|
1470
|
-
|
|
1471
|
-
|
|
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
|
-
}
|
|
338
|
+
function applyCloudTreeMutation(input, options = {}) {
|
|
339
|
+
const envelope = input?.envelope || {};
|
|
340
|
+
if (envelope.contract !== 'workspace-tree@1') throw invalid('unsupported workspace tree mutation', 409);
|
|
341
|
+
const operation = normalizeTreeOperation(envelope.operation);
|
|
342
|
+
if (operation.workspaceId !== envelope.resourceId || operation.kind !== envelope.operationKind) {
|
|
343
|
+
throw invalid('workspace tree mutation identity mismatch', 409);
|
|
1512
344
|
}
|
|
1513
|
-
const
|
|
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
|
-
));
|
|
345
|
+
const database = options.database || openLocalDb();
|
|
2011
346
|
const tree = localTree();
|
|
2012
|
-
const
|
|
2013
|
-
|
|
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
|
-
|
|
347
|
+
const workspace = workspaceRow(operation.workspaceId, database);
|
|
348
|
+
if (!workspace) throw invalid('workspace tree has no local folder', 404);
|
|
2881
349
|
const before = new Map(
|
|
2882
|
-
database.prepare('SELECT * FROM workspace_nodes WHERE workspace_id = ?')
|
|
2883
|
-
.all(plan.resourceId)
|
|
350
|
+
database.prepare('SELECT * FROM workspace_nodes WHERE workspace_id = ?').all(workspace.id)
|
|
2884
351
|
.map(rowToNode)
|
|
2885
352
|
.map((node) => [node.id, node]),
|
|
2886
353
|
);
|
|
2887
|
-
|
|
2888
|
-
|
|
2889
|
-
|
|
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({
|
|
354
|
+
const after = applyTreeOperation(before, operation);
|
|
355
|
+
let paths;
|
|
356
|
+
return require('../state/mutations').ingestCloudMutation({
|
|
3600
357
|
...input,
|
|
3601
358
|
localResource: `${TREE_RESOURCE_PREFIX}${workspace.id}`,
|
|
3602
359
|
}, {
|
|
3603
360
|
database,
|
|
3604
361
|
materialize() {
|
|
3605
|
-
|
|
3606
|
-
|
|
3607
|
-
.
|
|
3608
|
-
|
|
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,
|
|
362
|
+
if (operation.kind === 'tree.trash' || operation.kind === 'tree.purge') {
|
|
363
|
+
const affected = new Set(
|
|
364
|
+
Array.from(before.values())
|
|
365
|
+
.filter((node) => node.id === operation.nodeId || hasAncestorInSet(node, before, new Set([operation.nodeId])))
|
|
366
|
+
.map((node) => node.id),
|
|
3627
367
|
);
|
|
368
|
+
require('../state/docs').closeDocsByNodeIds(affected);
|
|
3628
369
|
}
|
|
3629
|
-
|
|
3630
|
-
|
|
3631
|
-
|
|
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
|
-
}
|
|
370
|
+
paths = materializeOperation(workspace, before, after, operation);
|
|
371
|
+
paths.changedPaths = persistNodes(database, before, after, workspace.id);
|
|
372
|
+
if (paths.changedPaths.length) require('../state/docs').rebindNodePaths(paths.changedPaths);
|
|
3670
373
|
},
|
|
3671
374
|
event: () => ({
|
|
3672
375
|
resource: `${TREE_RESOURCE_PREFIX}${workspace.id}`,
|
|
@@ -3677,30 +380,12 @@ function applyCloudTreeMutation(input, options = {}) {
|
|
|
3677
380
|
source: 'workspace-tree:cloud',
|
|
3678
381
|
resourceVersion: Number(input.version),
|
|
3679
382
|
}),
|
|
3680
|
-
finalize() {
|
|
3681
|
-
if (!held) return;
|
|
3682
|
-
held = require('./tree-materialization').markOperationCommitted(held, database);
|
|
3683
|
-
},
|
|
3684
383
|
});
|
|
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;
|
|
3692
384
|
}
|
|
3693
385
|
|
|
3694
386
|
module.exports = {
|
|
3695
387
|
applyCloudTreeMutation,
|
|
3696
|
-
crossTreeOperationWithRaceCapture,
|
|
3697
388
|
enqueueWorkspaceContentAttachments,
|
|
3698
|
-
finishTreeOperationPlan,
|
|
3699
|
-
holdClassifiedPlanRetry,
|
|
3700
389
|
installCloudTreeSnapshot,
|
|
3701
|
-
localTreeOperationPlan,
|
|
3702
|
-
projectStoredTreeOperation,
|
|
3703
|
-
reconcileContentOverlay,
|
|
3704
|
-
recoverAllTreePlans,
|
|
3705
390
|
workspaceTreeSnapshot,
|
|
3706
391
|
};
|