amalgm 0.1.238 → 0.1.240
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/shared-realtime-tunnel.js +22 -14
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/browser/cookie-jar.js +68 -20
- package/runtime/scripts/amalgm-mcp/project-context/store.js +87 -19
- package/runtime/scripts/amalgm-mcp/state/attachments.js +40 -12
- package/runtime/scripts/amalgm-mcp/state/db.js +216 -17
- package/runtime/scripts/amalgm-mcp/state/doc-disk.js +550 -0
- package/runtime/scripts/amalgm-mcp/state/docs.js +1101 -301
- package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +99 -0
- package/runtime/scripts/amalgm-mcp/state/mutations.js +507 -130
- package/runtime/scripts/amalgm-mcp/state/promotions.js +21 -1
- package/runtime/scripts/amalgm-mcp/state/replicas.js +134 -13
- package/runtime/scripts/amalgm-mcp/state/shared-rest.js +14 -8
- package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +193 -0
- package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +313 -94
- package/runtime/scripts/amalgm-mcp/tests/fixtures/code-project-stream-v2.json +1 -0
- package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +32 -17
- package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +84 -0
- package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +376 -13
- package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +309 -0
- package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +758 -30
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +72 -3
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +33 -6
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +27 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +334 -191
- package/runtime/scripts/amalgm-mcp/tests/workspace-durable.test.js +24 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-local-intent.test.js +1999 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-native-file-transaction.test.js +364 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +81 -3
- package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +114 -8
- package/runtime/scripts/amalgm-mcp/tests/workspace-provenance.test.js +185 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +174 -269
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer-liveness.test.js +26 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +2060 -407
- package/runtime/scripts/amalgm-mcp/tests/workspace-sync-stats.test.js +119 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-transition.test.js +1446 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +5319 -3
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +191 -1
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +3140 -17
- package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +135 -637
- package/runtime/scripts/amalgm-mcp/workspace/access-store.js +5 -1
- package/runtime/scripts/amalgm-mcp/workspace/card.js +7 -7
- package/runtime/scripts/amalgm-mcp/workspace/cards.js +36 -12
- package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +436 -173
- package/runtime/scripts/amalgm-mcp/workspace/content.js +32 -0
- package/runtime/scripts/amalgm-mcp/workspace/durable.js +45 -0
- package/runtime/scripts/amalgm-mcp/workspace/git.js +391 -37
- package/runtime/scripts/amalgm-mcp/workspace/intent.js +535 -0
- package/runtime/scripts/amalgm-mcp/workspace/merge.js +89 -6
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/BUILD.md +31 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/darwin-arm64/amalgm-file-transaction +0 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/linux-x64/amalgm-file-transaction +0 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/build.mjs +72 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/client.js +165 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.mod +5 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.sum +2 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/main.go +2201 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/manifest.json +19 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_darwin.go +18 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_linux.go +18 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_darwin.go +33 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_linux.go +47 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/verify.mjs +43 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_darwin.go +114 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_linux.go +84 -0
- package/runtime/scripts/amalgm-mcp/workspace/objects.js +119 -54
- package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +616 -168
- package/runtime/scripts/amalgm-mcp/workspace/provenance.js +240 -0
- package/runtime/scripts/amalgm-mcp/workspace/reducer.js +1979 -570
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +49 -28
- package/runtime/scripts/amalgm-mcp/workspace/store.js +77 -4
- package/runtime/scripts/amalgm-mcp/workspace/sync-stats.js +240 -0
- package/runtime/scripts/amalgm-mcp/workspace/transition.js +1729 -192
- package/runtime/scripts/amalgm-mcp/workspace/tree/floor.js +100 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +7 -1
- package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +73 -61
- package/runtime/scripts/amalgm-mcp/workspace/tree/observation-holds.js +127 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/path-state.js +12 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +51 -6
- package/runtime/scripts/amalgm-mcp/workspace/tree/root-binding.js +125 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +79 -8
- package/runtime/scripts/amalgm-mcp/workspace/tree/symlinks.js +76 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +3480 -165
- package/runtime/scripts/amalgm-mcp/workspace/tree-materialization.js +3766 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +899 -71
- package/runtime/scripts/amalgm-mcp/workspace/wall.js +352 -497
|
@@ -0,0 +1,3766 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Durable physical plan for workspace-tree snapshots.
|
|
5
|
+
*
|
|
6
|
+
* The tree database and replica cursor are control records. Before either can
|
|
7
|
+
* move, every destructive filesystem action is named here with its exact
|
|
8
|
+
* source identity and a deterministic, same-filesystem staging path. A crash
|
|
9
|
+
* can therefore resume the one plan instead of guessing from whatever paths
|
|
10
|
+
* happen to exist after restart.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const crypto = require('crypto');
|
|
14
|
+
const fs = require('fs');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const { shouldEnroll } = require('./tree/inclusion');
|
|
17
|
+
const { isPathAbsentError } = require('./tree/path-state');
|
|
18
|
+
const { openLocalDb } = require('../state/db');
|
|
19
|
+
const nativeFileTransaction = require('./native-file-transaction/client');
|
|
20
|
+
|
|
21
|
+
const CONTRACT = 'workspace-tree-materialization@1';
|
|
22
|
+
const OPERATION_CONTRACT = 'workspace-tree-operation-materialization@1';
|
|
23
|
+
|
|
24
|
+
function ensureSchema(database = openLocalDb()) {
|
|
25
|
+
database.exec(`
|
|
26
|
+
CREATE TABLE IF NOT EXISTS workspace_tree_materializations (
|
|
27
|
+
resource_id TEXT NOT NULL,
|
|
28
|
+
snapshot_version INTEGER NOT NULL,
|
|
29
|
+
snapshot_checksum TEXT NOT NULL,
|
|
30
|
+
local_path TEXT NOT NULL,
|
|
31
|
+
plan_json TEXT NOT NULL,
|
|
32
|
+
materialization_state TEXT NOT NULL DEFAULT 'prepared'
|
|
33
|
+
CHECK (materialization_state IN ('prepared', 'crossed', 'committed')),
|
|
34
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
35
|
+
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
36
|
+
PRIMARY KEY(resource_id, snapshot_version)
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
CREATE TABLE IF NOT EXISTS workspace_tree_operation_materializations (
|
|
40
|
+
resource_id TEXT NOT NULL,
|
|
41
|
+
mutation_id TEXT NOT NULL,
|
|
42
|
+
official_version INTEGER NOT NULL,
|
|
43
|
+
plan_json TEXT NOT NULL,
|
|
44
|
+
materialization_state TEXT NOT NULL DEFAULT 'prepared'
|
|
45
|
+
CHECK (materialization_state IN ('prepared', 'crossed', 'committed')),
|
|
46
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
47
|
+
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
48
|
+
PRIMARY KEY(resource_id, mutation_id)
|
|
49
|
+
);
|
|
50
|
+
|
|
51
|
+
CREATE TABLE IF NOT EXISTS workspace_tree_operation_successors (
|
|
52
|
+
successor_seq INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
53
|
+
resource_id TEXT NOT NULL,
|
|
54
|
+
owner_mutation_id TEXT NOT NULL,
|
|
55
|
+
successor_mutation_id TEXT NOT NULL,
|
|
56
|
+
operation_json TEXT NOT NULL,
|
|
57
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
58
|
+
UNIQUE(resource_id, owner_mutation_id, successor_mutation_id)
|
|
59
|
+
);
|
|
60
|
+
`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function normalize(row) {
|
|
64
|
+
if (!row) return null;
|
|
65
|
+
return {
|
|
66
|
+
resourceId: row.resource_id,
|
|
67
|
+
snapshotVersion: Number(row.snapshot_version),
|
|
68
|
+
snapshotChecksum: row.snapshot_checksum,
|
|
69
|
+
localPath: row.local_path,
|
|
70
|
+
plan: JSON.parse(row.plan_json),
|
|
71
|
+
state: row.materialization_state,
|
|
72
|
+
createdAt: row.created_at,
|
|
73
|
+
updatedAt: row.updated_at,
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function list(database = openLocalDb(), resourceId = null) {
|
|
78
|
+
ensureSchema(database);
|
|
79
|
+
const rows = resourceId
|
|
80
|
+
? database.prepare(`
|
|
81
|
+
SELECT * FROM workspace_tree_materializations
|
|
82
|
+
WHERE resource_id = ?
|
|
83
|
+
ORDER BY snapshot_version ASC
|
|
84
|
+
`).all(resourceId)
|
|
85
|
+
: database.prepare(`
|
|
86
|
+
SELECT * FROM workspace_tree_materializations
|
|
87
|
+
ORDER BY created_at ASC, resource_id ASC, snapshot_version ASC
|
|
88
|
+
`).all();
|
|
89
|
+
return rows.map(normalize);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function get(resourceId, snapshotVersion, database = openLocalDb()) {
|
|
93
|
+
ensureSchema(database);
|
|
94
|
+
return normalize(database.prepare(`
|
|
95
|
+
SELECT * FROM workspace_tree_materializations
|
|
96
|
+
WHERE resource_id = ? AND snapshot_version = ?
|
|
97
|
+
`).get(resourceId, snapshotVersion));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function insert(plan, database = openLocalDb()) {
|
|
101
|
+
ensureSchema(database);
|
|
102
|
+
const body = JSON.stringify(plan);
|
|
103
|
+
database.prepare(`
|
|
104
|
+
INSERT INTO workspace_tree_materializations (
|
|
105
|
+
resource_id, snapshot_version, snapshot_checksum, local_path, plan_json
|
|
106
|
+
) VALUES (?, ?, ?, ?, ?)
|
|
107
|
+
ON CONFLICT(resource_id, snapshot_version) DO NOTHING
|
|
108
|
+
`).run(
|
|
109
|
+
plan.resourceId,
|
|
110
|
+
plan.snapshotVersion,
|
|
111
|
+
plan.snapshotChecksum,
|
|
112
|
+
plan.rootPath,
|
|
113
|
+
body,
|
|
114
|
+
);
|
|
115
|
+
const held = get(plan.resourceId, plan.snapshotVersion, database);
|
|
116
|
+
if (
|
|
117
|
+
!held
|
|
118
|
+
|| held.snapshotChecksum !== plan.snapshotChecksum
|
|
119
|
+
|| JSON.stringify(held.plan) !== body
|
|
120
|
+
) {
|
|
121
|
+
throw new Error(
|
|
122
|
+
`workspace tree snapshot ${plan.resourceId}@${plan.snapshotVersion} changed its physical plan`,
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
return held;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function normalizedExcludedPrefixes(values = []) {
|
|
129
|
+
const normalized = Array.from(new Set(values.map((value) => (
|
|
130
|
+
String(value || '').split('/').filter(Boolean).join('/')
|
|
131
|
+
)).filter(Boolean))).sort((left, right) => (
|
|
132
|
+
left.split('/').length - right.split('/').length
|
|
133
|
+
|| left.localeCompare(right)
|
|
134
|
+
));
|
|
135
|
+
const roots = [];
|
|
136
|
+
for (const candidate of normalized) {
|
|
137
|
+
if (roots.some((root) => (
|
|
138
|
+
candidate === root || candidate.startsWith(`${root}/`)
|
|
139
|
+
))) continue;
|
|
140
|
+
roots.push(candidate);
|
|
141
|
+
}
|
|
142
|
+
return roots;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function excludedPathMatcher(values) {
|
|
146
|
+
const prefixes = normalizedExcludedPrefixes(values);
|
|
147
|
+
const root = { terminal: false, children: new Map() };
|
|
148
|
+
for (const prefix of prefixes) {
|
|
149
|
+
let current = root;
|
|
150
|
+
for (const segment of prefix.split('/')) {
|
|
151
|
+
if (!current.children.has(segment)) {
|
|
152
|
+
current.children.set(segment, { terminal: false, children: new Map() });
|
|
153
|
+
}
|
|
154
|
+
current = current.children.get(segment);
|
|
155
|
+
}
|
|
156
|
+
current.terminal = true;
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
prefixes,
|
|
160
|
+
matches(relativePath) {
|
|
161
|
+
let current = root;
|
|
162
|
+
for (const segment of relativePath.split('/').filter(Boolean)) {
|
|
163
|
+
current = current.children.get(segment);
|
|
164
|
+
if (!current) return false;
|
|
165
|
+
if (current.terminal) return true;
|
|
166
|
+
}
|
|
167
|
+
return current.terminal;
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function contentDigest(target, stat, options = {}) {
|
|
173
|
+
const hash = crypto.createHash('sha256');
|
|
174
|
+
const excluded = excludedPathMatcher(options.excludedPrefixes);
|
|
175
|
+
const visit = (candidate, relative) => {
|
|
176
|
+
if (relative && excluded.matches(relative)) return;
|
|
177
|
+
if (typeof options.onVisit === 'function') options.onVisit(candidate, relative);
|
|
178
|
+
const held = fs.lstatSync(candidate, { bigint: true });
|
|
179
|
+
const kind = held.isSymbolicLink()
|
|
180
|
+
? 'symlink'
|
|
181
|
+
: held.isDirectory()
|
|
182
|
+
? 'folder'
|
|
183
|
+
: held.isFile()
|
|
184
|
+
? 'file'
|
|
185
|
+
: 'other';
|
|
186
|
+
hash.update(`${relative}\0${kind}\0${held.mode}\0${held.size}\0`);
|
|
187
|
+
if (kind === 'symlink') {
|
|
188
|
+
hash.update(fs.readlinkSync(candidate));
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (kind === 'file') {
|
|
192
|
+
hash.update(fs.readFileSync(candidate));
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (kind !== 'folder') return;
|
|
196
|
+
for (const name of fs.readdirSync(candidate).sort()) {
|
|
197
|
+
visit(path.join(candidate, name), relative ? `${relative}/${name}` : name);
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
visit(target, '');
|
|
201
|
+
hash.update(`\0root:${stat.dev}:${stat.ino}`);
|
|
202
|
+
return hash.digest('hex');
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function identity(target, options = {}) {
|
|
206
|
+
const stat = fs.lstatSync(target, { bigint: true });
|
|
207
|
+
const excludedPrefixes = normalizedExcludedPrefixes(options.excludedPrefixes);
|
|
208
|
+
const value = {
|
|
209
|
+
kind: stat.isSymbolicLink()
|
|
210
|
+
? 'symlink'
|
|
211
|
+
: stat.isDirectory()
|
|
212
|
+
? 'folder'
|
|
213
|
+
: stat.isFile()
|
|
214
|
+
? 'file'
|
|
215
|
+
: 'other',
|
|
216
|
+
dev: stat.dev.toString(),
|
|
217
|
+
ino: stat.ino.toString(),
|
|
218
|
+
};
|
|
219
|
+
if (options.deep !== false) {
|
|
220
|
+
value.digest = contentDigest(target, stat, {
|
|
221
|
+
excludedPrefixes,
|
|
222
|
+
onVisit: options.onVisit,
|
|
223
|
+
});
|
|
224
|
+
if (excludedPrefixes.length) value.excludedPrefixes = excludedPrefixes;
|
|
225
|
+
}
|
|
226
|
+
return value;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function nativeByteIdentity(target, stat = fs.lstatSync(target, { bigint: true })) {
|
|
230
|
+
let content;
|
|
231
|
+
let kind;
|
|
232
|
+
if (stat.isSymbolicLink()) {
|
|
233
|
+
kind = 'symlink';
|
|
234
|
+
content = Buffer.from(fs.readlinkSync(target));
|
|
235
|
+
} else if (stat.isFile()) {
|
|
236
|
+
kind = 'file';
|
|
237
|
+
content = fs.readFileSync(target);
|
|
238
|
+
} else {
|
|
239
|
+
throw new Error(`workspace tree retirement only supports byte leaves: ${target}`);
|
|
240
|
+
}
|
|
241
|
+
return {
|
|
242
|
+
kind,
|
|
243
|
+
exec: kind === 'file' && (stat.mode & 0o100n) !== 0n,
|
|
244
|
+
bytes: content.length,
|
|
245
|
+
sha256: crypto.createHash('sha256').update(content).digest('hex'),
|
|
246
|
+
...((kind === 'file' || kind === 'symlink') ? {
|
|
247
|
+
dev: stat.dev.toString(),
|
|
248
|
+
ino: stat.ino.toString(),
|
|
249
|
+
} : {}),
|
|
250
|
+
};
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Capture the exact leaf set before a destructive tree transition. The deep
|
|
255
|
+
* root identity proves nothing changed before staging; the leaf manifest lets
|
|
256
|
+
* cleanup retire unchanged bytes individually while retaining only a newer or
|
|
257
|
+
* still-open writer and the ancestors that writer needs.
|
|
258
|
+
*/
|
|
259
|
+
function removalPrecondition(target, options = {}) {
|
|
260
|
+
const entries = [];
|
|
261
|
+
const directories = [];
|
|
262
|
+
const excluded = excludedPathMatcher(options.excludedPrefixes);
|
|
263
|
+
const visit = (candidate, relativePath) => {
|
|
264
|
+
if (relativePath && excluded.matches(relativePath)) return;
|
|
265
|
+
if (typeof options.onVisit === 'function') options.onVisit(candidate, relativePath);
|
|
266
|
+
const stat = fs.lstatSync(candidate, { bigint: true });
|
|
267
|
+
if (stat.isDirectory()) {
|
|
268
|
+
directories.push({
|
|
269
|
+
relativePath,
|
|
270
|
+
dev: stat.dev.toString(),
|
|
271
|
+
ino: stat.ino.toString(),
|
|
272
|
+
});
|
|
273
|
+
for (const name of fs.readdirSync(candidate).sort()) {
|
|
274
|
+
visit(
|
|
275
|
+
path.join(candidate, name),
|
|
276
|
+
relativePath ? `${relativePath}/${name}` : name,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
entries.push({
|
|
282
|
+
relativePath,
|
|
283
|
+
identity: nativeByteIdentity(candidate, stat),
|
|
284
|
+
});
|
|
285
|
+
};
|
|
286
|
+
visit(target, '');
|
|
287
|
+
return {
|
|
288
|
+
identity: identity(target, {
|
|
289
|
+
excludedPrefixes: excluded.prefixes,
|
|
290
|
+
}),
|
|
291
|
+
retirement: { entries, directories },
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
function sameIdentity(target, expected) {
|
|
296
|
+
try {
|
|
297
|
+
return JSON.stringify(identity(target, {
|
|
298
|
+
deep: Object.prototype.hasOwnProperty.call(expected || {}, 'digest'),
|
|
299
|
+
excludedPrefixes: expected?.excludedPrefixes,
|
|
300
|
+
})) === JSON.stringify(expected);
|
|
301
|
+
} catch (error) {
|
|
302
|
+
if (isPathAbsentError(error)) return false;
|
|
303
|
+
throw error;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function exists(target) {
|
|
308
|
+
try {
|
|
309
|
+
fs.lstatSync(target);
|
|
310
|
+
return true;
|
|
311
|
+
} catch (error) {
|
|
312
|
+
if (isPathAbsentError(error)) return false;
|
|
313
|
+
throw error;
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function stagingRoot(plan) {
|
|
318
|
+
const id = crypto.createHash('sha256')
|
|
319
|
+
.update(
|
|
320
|
+
`${plan.contract || CONTRACT}\0${plan.resourceId}\0`
|
|
321
|
+
+ `${plan.snapshotVersion ?? plan.version}\0${plan.snapshotChecksum || plan.mutationId}`,
|
|
322
|
+
)
|
|
323
|
+
.digest('hex')
|
|
324
|
+
.slice(0, 24);
|
|
325
|
+
// A receipt is not user content. Keep it beside the registered workspace so
|
|
326
|
+
// it remains on the same filesystem without ever entering that workspace's
|
|
327
|
+
// observer namespace.
|
|
328
|
+
return path.join(path.dirname(plan.rootPath), `.amalgm-tree-apply-${id}`);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function normalizeOperation(row) {
|
|
332
|
+
if (!row) return null;
|
|
333
|
+
return {
|
|
334
|
+
resourceId: row.resource_id,
|
|
335
|
+
mutationId: row.mutation_id,
|
|
336
|
+
version: Number(row.official_version),
|
|
337
|
+
plan: JSON.parse(row.plan_json),
|
|
338
|
+
state: row.materialization_state,
|
|
339
|
+
createdAt: row.created_at,
|
|
340
|
+
updatedAt: row.updated_at,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
function getOperation(resourceId, mutationId, database = openLocalDb()) {
|
|
345
|
+
ensureSchema(database);
|
|
346
|
+
return normalizeOperation(database.prepare(`
|
|
347
|
+
SELECT * FROM workspace_tree_operation_materializations
|
|
348
|
+
WHERE resource_id = ? AND mutation_id = ?
|
|
349
|
+
`).get(resourceId, mutationId));
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function listOperations(database = openLocalDb(), resourceId = null) {
|
|
353
|
+
ensureSchema(database);
|
|
354
|
+
const rows = resourceId
|
|
355
|
+
? database.prepare(`
|
|
356
|
+
SELECT * FROM workspace_tree_operation_materializations
|
|
357
|
+
WHERE resource_id = ?
|
|
358
|
+
ORDER BY official_version ASC, created_at ASC
|
|
359
|
+
`).all(resourceId)
|
|
360
|
+
: database.prepare(`
|
|
361
|
+
SELECT * FROM workspace_tree_operation_materializations
|
|
362
|
+
ORDER BY created_at ASC, resource_id ASC, official_version ASC
|
|
363
|
+
`).all();
|
|
364
|
+
return rows.map(normalizeOperation);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function appendOperationSuccessor(
|
|
368
|
+
owner,
|
|
369
|
+
successorMutationId,
|
|
370
|
+
operation,
|
|
371
|
+
database = openLocalDb(),
|
|
372
|
+
) {
|
|
373
|
+
ensureSchema(database);
|
|
374
|
+
// Cancellation may remove a prepared control row immediately before the
|
|
375
|
+
// observer discovers a structural successor. Recreate that exact owner in
|
|
376
|
+
// the same transaction as the first successor append: a crash may leave
|
|
377
|
+
// both facts or neither, never an orphan chain with no recovery plan.
|
|
378
|
+
if (!getOperation(owner.resourceId, owner.mutationId, database)) {
|
|
379
|
+
insertOperation(owner, database);
|
|
380
|
+
}
|
|
381
|
+
const operationJson = JSON.stringify(operation);
|
|
382
|
+
database.prepare(`
|
|
383
|
+
INSERT INTO workspace_tree_operation_successors (
|
|
384
|
+
resource_id, owner_mutation_id, successor_mutation_id, operation_json
|
|
385
|
+
) VALUES (?, ?, ?, ?)
|
|
386
|
+
ON CONFLICT(resource_id, owner_mutation_id, successor_mutation_id)
|
|
387
|
+
DO UPDATE SET operation_json = excluded.operation_json
|
|
388
|
+
`).run(
|
|
389
|
+
owner.resourceId,
|
|
390
|
+
owner.mutationId,
|
|
391
|
+
successorMutationId,
|
|
392
|
+
operationJson,
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
function listOperationSuccessors(owner, database = openLocalDb()) {
|
|
397
|
+
ensureSchema(database);
|
|
398
|
+
return database.prepare(`
|
|
399
|
+
SELECT successor_mutation_id, operation_json
|
|
400
|
+
FROM workspace_tree_operation_successors
|
|
401
|
+
WHERE resource_id = ? AND owner_mutation_id = ?
|
|
402
|
+
ORDER BY successor_seq
|
|
403
|
+
`).all(owner.resourceId, owner.mutationId).map((row) => ({
|
|
404
|
+
mutationId: row.successor_mutation_id,
|
|
405
|
+
operation: JSON.parse(row.operation_json),
|
|
406
|
+
}));
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function clearOperationSuccessors(owner, database = openLocalDb()) {
|
|
410
|
+
ensureSchema(database);
|
|
411
|
+
database.prepare(`
|
|
412
|
+
DELETE FROM workspace_tree_operation_successors
|
|
413
|
+
WHERE resource_id = ? AND owner_mutation_id = ?
|
|
414
|
+
`).run(owner.resourceId, owner.mutationId);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
function insertOperation(plan, database = openLocalDb()) {
|
|
418
|
+
ensureSchema(database);
|
|
419
|
+
const body = JSON.stringify(plan);
|
|
420
|
+
database.prepare(`
|
|
421
|
+
INSERT INTO workspace_tree_operation_materializations (
|
|
422
|
+
resource_id, mutation_id, official_version, plan_json
|
|
423
|
+
) VALUES (?, ?, ?, ?)
|
|
424
|
+
ON CONFLICT(resource_id, mutation_id) DO NOTHING
|
|
425
|
+
`).run(plan.resourceId, plan.mutationId, plan.version, body);
|
|
426
|
+
const held = getOperation(plan.resourceId, plan.mutationId, database);
|
|
427
|
+
if (!held || held.version !== plan.version || JSON.stringify(held.plan) !== body) {
|
|
428
|
+
throw new Error(
|
|
429
|
+
`workspace tree operation ${plan.resourceId}/${plan.mutationId} changed its physical plan`,
|
|
430
|
+
);
|
|
431
|
+
}
|
|
432
|
+
return held;
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function replacePreparedOperation(held, plan, database = openLocalDb()) {
|
|
436
|
+
if (
|
|
437
|
+
!held
|
|
438
|
+
|| held.state !== 'prepared'
|
|
439
|
+
|| held.resourceId !== plan.resourceId
|
|
440
|
+
|| held.mutationId !== plan.mutationId
|
|
441
|
+
) {
|
|
442
|
+
throw new Error('workspace tree replacement plan has no prepared predecessor');
|
|
443
|
+
}
|
|
444
|
+
const body = JSON.stringify(plan);
|
|
445
|
+
const result = database.prepare(`
|
|
446
|
+
UPDATE workspace_tree_operation_materializations
|
|
447
|
+
SET official_version = ?,
|
|
448
|
+
plan_json = ?,
|
|
449
|
+
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
450
|
+
WHERE resource_id = ?
|
|
451
|
+
AND mutation_id = ?
|
|
452
|
+
AND materialization_state = 'prepared'
|
|
453
|
+
`).run(
|
|
454
|
+
plan.version,
|
|
455
|
+
body,
|
|
456
|
+
plan.resourceId,
|
|
457
|
+
plan.mutationId,
|
|
458
|
+
);
|
|
459
|
+
if (result.changes !== 1) {
|
|
460
|
+
throw new Error(
|
|
461
|
+
`workspace tree operation ${plan.resourceId}/${plan.mutationId} lost its prepared plan`,
|
|
462
|
+
);
|
|
463
|
+
}
|
|
464
|
+
const replaced = getOperation(plan.resourceId, plan.mutationId, database);
|
|
465
|
+
if (
|
|
466
|
+
!replaced
|
|
467
|
+
|| replaced.version !== plan.version
|
|
468
|
+
|| JSON.stringify(replaced.plan) !== body
|
|
469
|
+
) {
|
|
470
|
+
throw new Error(
|
|
471
|
+
`workspace tree operation ${plan.resourceId}/${plan.mutationId} changed its replacement plan`,
|
|
472
|
+
);
|
|
473
|
+
}
|
|
474
|
+
return replaced;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* A committed plan remains the durable owner of post-commit cleanup. Cleanup
|
|
479
|
+
* may acquire a named wait (for example, current authority forbids birthing
|
|
480
|
+
* retained writer bytes), so record that wait on the same immutable plan
|
|
481
|
+
* identity instead of creating a second control record.
|
|
482
|
+
*/
|
|
483
|
+
function updateCommittedPlan(held, plan, database = openLocalDb()) {
|
|
484
|
+
if (!held || held.state !== 'committed' || held.resourceId !== plan.resourceId) {
|
|
485
|
+
throw new Error('workspace tree committed plan update has no committed predecessor');
|
|
486
|
+
}
|
|
487
|
+
const operation = held.mutationId != null;
|
|
488
|
+
if (
|
|
489
|
+
operation
|
|
490
|
+
? held.mutationId !== plan.mutationId || held.version !== Number(plan.version)
|
|
491
|
+
: held.snapshotVersion !== Number(plan.snapshotVersion)
|
|
492
|
+
|| held.snapshotChecksum !== plan.snapshotChecksum
|
|
493
|
+
) {
|
|
494
|
+
throw new Error('workspace tree committed plan update changed plan identity');
|
|
495
|
+
}
|
|
496
|
+
const body = JSON.stringify(plan);
|
|
497
|
+
const result = operation
|
|
498
|
+
? database.prepare(`
|
|
499
|
+
UPDATE workspace_tree_operation_materializations
|
|
500
|
+
SET plan_json = ?,
|
|
501
|
+
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
502
|
+
WHERE resource_id = ?
|
|
503
|
+
AND mutation_id = ?
|
|
504
|
+
AND materialization_state = 'committed'
|
|
505
|
+
`).run(body, held.resourceId, held.mutationId)
|
|
506
|
+
: database.prepare(`
|
|
507
|
+
UPDATE workspace_tree_materializations
|
|
508
|
+
SET plan_json = ?,
|
|
509
|
+
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
510
|
+
WHERE resource_id = ?
|
|
511
|
+
AND snapshot_version = ?
|
|
512
|
+
AND snapshot_checksum = ?
|
|
513
|
+
AND materialization_state = 'committed'
|
|
514
|
+
`).run(
|
|
515
|
+
body,
|
|
516
|
+
held.resourceId,
|
|
517
|
+
held.snapshotVersion,
|
|
518
|
+
held.snapshotChecksum,
|
|
519
|
+
);
|
|
520
|
+
if (result.changes !== 1) {
|
|
521
|
+
throw new Error('workspace tree committed plan lost its durable owner');
|
|
522
|
+
}
|
|
523
|
+
const updated = operation
|
|
524
|
+
? getOperation(held.resourceId, held.mutationId, database)
|
|
525
|
+
: get(held.resourceId, held.snapshotVersion, database);
|
|
526
|
+
if (!updated || updated.state !== 'committed' || JSON.stringify(updated.plan) !== body) {
|
|
527
|
+
throw new Error('workspace tree committed plan changed during durable update');
|
|
528
|
+
}
|
|
529
|
+
return updated;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
function updateUncommittedPlan(held, plan, database = openLocalDb()) {
|
|
533
|
+
if (
|
|
534
|
+
!held
|
|
535
|
+
|| held.state === 'committed'
|
|
536
|
+
|| held.resourceId !== plan.resourceId
|
|
537
|
+
|| held.snapshotVersion !== Number(plan.snapshotVersion)
|
|
538
|
+
|| held.snapshotChecksum !== plan.snapshotChecksum
|
|
539
|
+
) {
|
|
540
|
+
throw new Error('workspace tree uncommitted plan update has no exact predecessor');
|
|
541
|
+
}
|
|
542
|
+
const body = JSON.stringify(plan);
|
|
543
|
+
const result = database.prepare(`
|
|
544
|
+
UPDATE workspace_tree_materializations
|
|
545
|
+
SET plan_json = ?,
|
|
546
|
+
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
547
|
+
WHERE resource_id = ?
|
|
548
|
+
AND snapshot_version = ?
|
|
549
|
+
AND snapshot_checksum = ?
|
|
550
|
+
AND materialization_state = ?
|
|
551
|
+
`).run(
|
|
552
|
+
body,
|
|
553
|
+
held.resourceId,
|
|
554
|
+
held.snapshotVersion,
|
|
555
|
+
held.snapshotChecksum,
|
|
556
|
+
held.state,
|
|
557
|
+
);
|
|
558
|
+
if (result.changes !== 1) {
|
|
559
|
+
throw new Error('workspace tree uncommitted plan lost its durable owner');
|
|
560
|
+
}
|
|
561
|
+
const updated = get(held.resourceId, held.snapshotVersion, database);
|
|
562
|
+
if (
|
|
563
|
+
!updated
|
|
564
|
+
|| updated.state !== held.state
|
|
565
|
+
|| JSON.stringify(updated.plan) !== body
|
|
566
|
+
) {
|
|
567
|
+
throw new Error('workspace tree uncommitted plan changed during durable update');
|
|
568
|
+
}
|
|
569
|
+
return updated;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function ensureDurableDirectory(directory) {
|
|
573
|
+
const target = path.resolve(directory);
|
|
574
|
+
const missing = [];
|
|
575
|
+
let current = target;
|
|
576
|
+
for (;;) {
|
|
577
|
+
try {
|
|
578
|
+
const stat = fs.lstatSync(current);
|
|
579
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) {
|
|
580
|
+
throw new Error(`workspace tree parent is not a physical directory: ${current}`);
|
|
581
|
+
}
|
|
582
|
+
break;
|
|
583
|
+
} catch (error) {
|
|
584
|
+
if (!isPathAbsentError(error)) throw error;
|
|
585
|
+
missing.push(current);
|
|
586
|
+
const parent = path.dirname(current);
|
|
587
|
+
if (parent === current) {
|
|
588
|
+
throw new Error(`workspace tree directory has no existing ancestor: ${target}`);
|
|
589
|
+
}
|
|
590
|
+
current = parent;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
for (const candidate of missing.reverse()) {
|
|
594
|
+
try {
|
|
595
|
+
fs.mkdirSync(candidate);
|
|
596
|
+
} catch (error) {
|
|
597
|
+
if (error?.code !== 'EEXIST') throw error;
|
|
598
|
+
const stat = fs.lstatSync(candidate);
|
|
599
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw error;
|
|
600
|
+
}
|
|
601
|
+
// The new name is not durable until its parent entry is synced. Sync the
|
|
602
|
+
// new directory too before another child can depend on it.
|
|
603
|
+
syncDirectory(path.dirname(candidate));
|
|
604
|
+
syncDirectory(candidate);
|
|
605
|
+
}
|
|
606
|
+
syncDirectory(target);
|
|
607
|
+
return identity(target, { deep: false });
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function ensureOperationDirectory(directory) {
|
|
611
|
+
return ensureDurableDirectory(directory);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function createOperationTargetAt(target, action) {
|
|
615
|
+
if (exists(target)) {
|
|
616
|
+
throw new Error(`workspace tree operation prepared target already exists for ${action.nodeId}`);
|
|
617
|
+
}
|
|
618
|
+
ensureOperationDirectory(path.dirname(target));
|
|
619
|
+
if (action.kind === 'folder') ensureOperationDirectory(target);
|
|
620
|
+
else if (action.kind === 'file') {
|
|
621
|
+
const descriptor = fs.openSync(target, 'wx');
|
|
622
|
+
try {
|
|
623
|
+
fs.fsyncSync(descriptor);
|
|
624
|
+
} finally {
|
|
625
|
+
fs.closeSync(descriptor);
|
|
626
|
+
}
|
|
627
|
+
syncDirectory(path.dirname(target));
|
|
628
|
+
} else if (action.kind === 'symlink') {
|
|
629
|
+
fs.symlinkSync(action.symlinkTarget, target);
|
|
630
|
+
syncDirectory(path.dirname(target));
|
|
631
|
+
} else {
|
|
632
|
+
throw new Error(`unsupported workspace tree operation kind ${action.kind}`);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function receiptPath(plan, lane, nodeId) {
|
|
637
|
+
return path.join(stagingRoot(plan), 'receipts', `${lane}-${nodeId}.json`);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
function readReceipt(plan, lane, nodeId) {
|
|
641
|
+
const file = receiptPath(plan, lane, nodeId);
|
|
642
|
+
if (!exists(file)) return null;
|
|
643
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
function writeReceipt(plan, lane, nodeId, value) {
|
|
647
|
+
const file = receiptPath(plan, lane, nodeId);
|
|
648
|
+
const body = JSON.stringify(value);
|
|
649
|
+
const existing = readReceipt(plan, lane, nodeId);
|
|
650
|
+
if (existing) {
|
|
651
|
+
if (JSON.stringify(existing) !== body) {
|
|
652
|
+
throw new Error(`workspace tree receipt changed for ${nodeId}`);
|
|
653
|
+
}
|
|
654
|
+
return existing;
|
|
655
|
+
}
|
|
656
|
+
ensureDirectory(plan, path.dirname(file));
|
|
657
|
+
const descriptor = fs.openSync(file, 'wx');
|
|
658
|
+
try {
|
|
659
|
+
fs.writeFileSync(descriptor, body);
|
|
660
|
+
fs.fsyncSync(descriptor);
|
|
661
|
+
} finally {
|
|
662
|
+
fs.closeSync(descriptor);
|
|
663
|
+
}
|
|
664
|
+
syncDirectory(path.dirname(file));
|
|
665
|
+
return value;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function ownedIdentity(target, expected) {
|
|
669
|
+
if (!expected) return false;
|
|
670
|
+
try {
|
|
671
|
+
const observed = identity(target, { deep: false });
|
|
672
|
+
return observed.kind === expected.kind
|
|
673
|
+
&& observed.dev === expected.dev
|
|
674
|
+
&& observed.ino === expected.ino;
|
|
675
|
+
} catch (error) {
|
|
676
|
+
if (isPathAbsentError(error)) return false;
|
|
677
|
+
throw error;
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function plannedParentIdentity(plan, nodeId, phase) {
|
|
682
|
+
if (!nodeId || nodeId === plan.resourceId) return plan.rootIdentity;
|
|
683
|
+
if (phase === 'target') {
|
|
684
|
+
const created = readReceipt(plan, 'creates', nodeId);
|
|
685
|
+
if (created) return created;
|
|
686
|
+
const projected = (plan.projectedBindings || [])
|
|
687
|
+
.find((binding) => binding.nodeId === nodeId);
|
|
688
|
+
if (projected) return projected.identity;
|
|
689
|
+
}
|
|
690
|
+
const rollback = (plan.rollbackBindings || plan.projectedBindings || [])
|
|
691
|
+
.find((binding) => binding.nodeId === nodeId);
|
|
692
|
+
if (rollback) return rollback.identity;
|
|
693
|
+
throw new Error(
|
|
694
|
+
`workspace tree ${phase} parent ${nodeId} has no physical binding`,
|
|
695
|
+
);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function requireOwnedDirectory(directory, expected, label) {
|
|
699
|
+
if (!expected || expected.kind !== 'folder' || !ownedIdentity(directory, expected)) {
|
|
700
|
+
throw parentRace(`workspace tree ${label} parent changed: ${directory}`);
|
|
701
|
+
}
|
|
702
|
+
syncDirectory(directory);
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function sourceRace(message) {
|
|
706
|
+
return Object.assign(new Error(message), {
|
|
707
|
+
code: 'WORKSPACE_TREE_SOURCE_RACE',
|
|
708
|
+
});
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
function parentRace(message) {
|
|
712
|
+
return Object.assign(new Error(message), {
|
|
713
|
+
code: 'WORKSPACE_TREE_PARENT_RACE',
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function operationRecoveryContext(plan, options = {}) {
|
|
718
|
+
const bindings = new Map(
|
|
719
|
+
(plan.rollbackBindings || []).map((binding) => [binding.nodeId, binding]),
|
|
720
|
+
);
|
|
721
|
+
const actions = new Map(
|
|
722
|
+
(plan.actions || []).map((action) => [action.nodeId, action]),
|
|
723
|
+
);
|
|
724
|
+
const operationIdentities = Array.from(actions.keys()).map((nodeId) => (
|
|
725
|
+
readReceipt(plan, 'create', nodeId)
|
|
726
|
+
|| readReceipt(plan, 'move', nodeId)
|
|
727
|
+
|| readReceipt(plan, 'remove', nodeId)
|
|
728
|
+
)).filter(Boolean);
|
|
729
|
+
let indexed = indexProjectedBindingLocations(plan, {
|
|
730
|
+
identities: operationIdentities,
|
|
731
|
+
});
|
|
732
|
+
let refreshed = false;
|
|
733
|
+
const indexedLocation = (expected) => {
|
|
734
|
+
const key = receiptIdentityKey(expected);
|
|
735
|
+
const candidates = indexed.get(key) || [];
|
|
736
|
+
const current = candidates.find((candidate) => ownedIdentity(candidate, expected));
|
|
737
|
+
if (current) return current;
|
|
738
|
+
if (!refreshed) {
|
|
739
|
+
indexed = indexProjectedBindingLocations(plan, {
|
|
740
|
+
identities: operationIdentities,
|
|
741
|
+
});
|
|
742
|
+
refreshed = true;
|
|
743
|
+
return (indexed.get(key) || [])
|
|
744
|
+
.find((candidate) => ownedIdentity(candidate, expected)) || null;
|
|
745
|
+
}
|
|
746
|
+
if (candidates.length && refreshed) {
|
|
747
|
+
throw sourceRace('workspace tree operation recovery index changed twice');
|
|
748
|
+
}
|
|
749
|
+
return null;
|
|
750
|
+
};
|
|
751
|
+
const nodeLocation = (nodeId) => {
|
|
752
|
+
if (!nodeId || nodeId === plan.resourceId) return plan.rootPath;
|
|
753
|
+
const action = actions.get(nodeId);
|
|
754
|
+
const binding = bindings.get(nodeId);
|
|
755
|
+
const expected = (
|
|
756
|
+
readReceipt(plan, 'create', nodeId)
|
|
757
|
+
|| readReceipt(plan, 'move', nodeId)
|
|
758
|
+
|| binding?.identity
|
|
759
|
+
);
|
|
760
|
+
if (!expected) return null;
|
|
761
|
+
const exact = [
|
|
762
|
+
action?.target,
|
|
763
|
+
action?.source,
|
|
764
|
+
action ? stagedPath(plan, 'moves', nodeId) : null,
|
|
765
|
+
action ? stagedPath(plan, 'creates', nodeId) : null,
|
|
766
|
+
binding?.from,
|
|
767
|
+
].filter(Boolean).find((candidate) => ownedIdentity(candidate, expected));
|
|
768
|
+
return exact || indexedLocation(expected);
|
|
769
|
+
};
|
|
770
|
+
const parent = (action, phase) => {
|
|
771
|
+
const nodeId = action[`${phase}ParentNodeId`];
|
|
772
|
+
if (nodeId === plan.resourceId) {
|
|
773
|
+
return { path: plan.rootPath, identity: plan.rootIdentity };
|
|
774
|
+
}
|
|
775
|
+
if (nodeId) {
|
|
776
|
+
const location = nodeLocation(nodeId);
|
|
777
|
+
if (!location) return null;
|
|
778
|
+
if (options.allowRelocated !== true) {
|
|
779
|
+
const ownerAction = actions.get(nodeId);
|
|
780
|
+
const binding = bindings.get(nodeId);
|
|
781
|
+
const permitted = [
|
|
782
|
+
binding?.from,
|
|
783
|
+
(
|
|
784
|
+
ownerAction
|
|
785
|
+
&& (
|
|
786
|
+
readReceipt(plan, 'move-installing', nodeId)
|
|
787
|
+
|| readReceipt(plan, 'create-installing', nodeId)
|
|
788
|
+
)
|
|
789
|
+
) ? ownerAction.target : null,
|
|
790
|
+
ownerAction ? stagedPath(plan, 'moves', nodeId) : null,
|
|
791
|
+
ownerAction ? stagedPath(plan, 'creates', nodeId) : null,
|
|
792
|
+
].filter(Boolean);
|
|
793
|
+
if (!permitted.some((candidate) => ownedIdentity(candidate, identity(
|
|
794
|
+
location,
|
|
795
|
+
{ deep: false },
|
|
796
|
+
)))) {
|
|
797
|
+
return null;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
return {
|
|
801
|
+
path: location,
|
|
802
|
+
identity: identity(location, { deep: false }),
|
|
803
|
+
};
|
|
804
|
+
}
|
|
805
|
+
const spelled = path.dirname(action[phase]);
|
|
806
|
+
const expected = action[`${phase}ParentIdentity`];
|
|
807
|
+
if (!expected || !ownedIdentity(spelled, expected)) return null;
|
|
808
|
+
return { path: spelled, identity: expected };
|
|
809
|
+
};
|
|
810
|
+
return {
|
|
811
|
+
nodeLocation,
|
|
812
|
+
resolve(action, phase) {
|
|
813
|
+
const held = parent(action, phase);
|
|
814
|
+
if (!held) return null;
|
|
815
|
+
return {
|
|
816
|
+
path: path.join(held.path, path.basename(action[phase])),
|
|
817
|
+
parentIdentity: held.identity,
|
|
818
|
+
};
|
|
819
|
+
},
|
|
820
|
+
};
|
|
821
|
+
}
|
|
822
|
+
|
|
823
|
+
function retainPrivatePath(plan, staged, preferred, token, options = {}) {
|
|
824
|
+
const provenance = options.provenance || 'independent-occupant';
|
|
825
|
+
const existingOutputs = options.item && options.lane
|
|
826
|
+
? (plan.retainedOutputs || []).filter((output) => (
|
|
827
|
+
output.removalNodeId === options.item.nodeId
|
|
828
|
+
&& output.lane === options.lane
|
|
829
|
+
&& output.provenance === provenance
|
|
830
|
+
))
|
|
831
|
+
: [];
|
|
832
|
+
if (!exists(staged)) {
|
|
833
|
+
const retained = existingOutputs.filter((output) => (
|
|
834
|
+
ownedIdentity(output.path, output.identity)
|
|
835
|
+
));
|
|
836
|
+
return retained.length === 1 ? retained[0].path : null;
|
|
837
|
+
}
|
|
838
|
+
const expected = identity(staged, { deep: false });
|
|
839
|
+
let target = preferred;
|
|
840
|
+
let targetParentIdentity = null;
|
|
841
|
+
if (target && exists(path.dirname(target))) {
|
|
842
|
+
const parent = identity(path.dirname(target), { deep: false });
|
|
843
|
+
if (parent.kind === 'folder') targetParentIdentity = parent;
|
|
844
|
+
}
|
|
845
|
+
if (!targetParentIdentity) {
|
|
846
|
+
target = path.join(
|
|
847
|
+
plan.rootPath,
|
|
848
|
+
`.amalgm-retained-${token}-${path.basename(preferred || staged)}`,
|
|
849
|
+
);
|
|
850
|
+
targetParentIdentity = plan.rootIdentity;
|
|
851
|
+
}
|
|
852
|
+
if (exists(target)) target = retainedConflictPath(target, token);
|
|
853
|
+
if (options.item && options.lane) {
|
|
854
|
+
const relativePath = provenance === 'carried-source'
|
|
855
|
+
? ''
|
|
856
|
+
: `entry-${expected.kind}-${expected.dev}-${expected.ino}`;
|
|
857
|
+
const role = provenance === 'carried-source' ? 'carried' : 'occupant';
|
|
858
|
+
const outputId = retainedOutputId(
|
|
859
|
+
plan,
|
|
860
|
+
options.item,
|
|
861
|
+
options.lane,
|
|
862
|
+
relativePath,
|
|
863
|
+
role,
|
|
864
|
+
);
|
|
865
|
+
const existing = existingOutputs.find(
|
|
866
|
+
(output) => output.outputId === outputId,
|
|
867
|
+
);
|
|
868
|
+
if (existing) target = existing.path;
|
|
869
|
+
const retained = mergeRetained(
|
|
870
|
+
plan,
|
|
871
|
+
options.item,
|
|
872
|
+
staged,
|
|
873
|
+
target,
|
|
874
|
+
token,
|
|
875
|
+
{
|
|
876
|
+
...(options.outputOptions || {}),
|
|
877
|
+
lane: options.lane,
|
|
878
|
+
provenance,
|
|
879
|
+
exclusive: true,
|
|
880
|
+
targetParentIdentity,
|
|
881
|
+
},
|
|
882
|
+
relativePath,
|
|
883
|
+
);
|
|
884
|
+
return retained.find((output) => output.outputId === outputId)?.path || target;
|
|
885
|
+
}
|
|
886
|
+
exclusiveRename(
|
|
887
|
+
plan,
|
|
888
|
+
staged,
|
|
889
|
+
target,
|
|
890
|
+
`retain-private-${token}`,
|
|
891
|
+
expected,
|
|
892
|
+
{ targetParentIdentity },
|
|
893
|
+
);
|
|
894
|
+
return target;
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function retainUnexpectedStage(
|
|
898
|
+
plan,
|
|
899
|
+
source,
|
|
900
|
+
staged,
|
|
901
|
+
nodeId,
|
|
902
|
+
sourceParentIdentity,
|
|
903
|
+
) {
|
|
904
|
+
if (!exists(staged)) return null;
|
|
905
|
+
const unexpected = identity(staged, { deep: false });
|
|
906
|
+
const token = crypto.createHash('sha256')
|
|
907
|
+
.update(
|
|
908
|
+
`${plan.snapshotChecksum || plan.mutationId}\0source-race\0${nodeId}`,
|
|
909
|
+
)
|
|
910
|
+
.digest('hex')
|
|
911
|
+
.slice(0, 12);
|
|
912
|
+
const target = exists(source)
|
|
913
|
+
? retainedConflictPath(source, token)
|
|
914
|
+
: source;
|
|
915
|
+
const result = exclusiveRename(
|
|
916
|
+
plan,
|
|
917
|
+
staged,
|
|
918
|
+
target,
|
|
919
|
+
`retain-raced-stage-${nodeId}`,
|
|
920
|
+
unexpected,
|
|
921
|
+
{
|
|
922
|
+
targetParentIdentity: sourceParentIdentity,
|
|
923
|
+
allowChanged: true,
|
|
924
|
+
},
|
|
925
|
+
);
|
|
926
|
+
if (result.status !== 'applied' && exists(staged)) {
|
|
927
|
+
throw new Error(`workspace tree raced stage could not be retained for ${nodeId}`);
|
|
928
|
+
}
|
|
929
|
+
return target;
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
function stageOwnedPath({
|
|
933
|
+
plan,
|
|
934
|
+
source,
|
|
935
|
+
staged,
|
|
936
|
+
nodeId,
|
|
937
|
+
receiptLane,
|
|
938
|
+
expected,
|
|
939
|
+
sourceParentIdentity,
|
|
940
|
+
operation,
|
|
941
|
+
renameTestHooks = {},
|
|
942
|
+
}) {
|
|
943
|
+
let receipt = readReceipt(plan, receiptLane, nodeId);
|
|
944
|
+
if (!receipt) {
|
|
945
|
+
receipt = writeReceipt(plan, receiptLane, nodeId, {
|
|
946
|
+
kind: expected.kind,
|
|
947
|
+
dev: expected.dev,
|
|
948
|
+
ino: expected.ino,
|
|
949
|
+
});
|
|
950
|
+
}
|
|
951
|
+
if (ownedIdentity(staged, receipt)) return receipt;
|
|
952
|
+
if (exists(staged)) {
|
|
953
|
+
retainUnexpectedStage(
|
|
954
|
+
plan,
|
|
955
|
+
source,
|
|
956
|
+
staged,
|
|
957
|
+
nodeId,
|
|
958
|
+
sourceParentIdentity,
|
|
959
|
+
);
|
|
960
|
+
throw sourceRace(`workspace tree source raced staging for ${nodeId}`);
|
|
961
|
+
}
|
|
962
|
+
if (!ownedIdentity(source, receipt)) {
|
|
963
|
+
throw sourceRace(`workspace tree source changed before staging for ${nodeId}`);
|
|
964
|
+
}
|
|
965
|
+
ensureDirectory(plan, path.dirname(staged));
|
|
966
|
+
const result = exclusiveRename(
|
|
967
|
+
plan,
|
|
968
|
+
source,
|
|
969
|
+
staged,
|
|
970
|
+
operation,
|
|
971
|
+
receipt,
|
|
972
|
+
{
|
|
973
|
+
sourceParentIdentity,
|
|
974
|
+
allowChanged: true,
|
|
975
|
+
testHooks: renameTestHooks,
|
|
976
|
+
},
|
|
977
|
+
);
|
|
978
|
+
if (ownedIdentity(staged, receipt)) return receipt;
|
|
979
|
+
if (exists(staged)) {
|
|
980
|
+
retainUnexpectedStage(
|
|
981
|
+
plan,
|
|
982
|
+
source,
|
|
983
|
+
staged,
|
|
984
|
+
nodeId,
|
|
985
|
+
sourceParentIdentity,
|
|
986
|
+
);
|
|
987
|
+
}
|
|
988
|
+
throw sourceRace(
|
|
989
|
+
`workspace tree source could not be staged for ${nodeId}: `
|
|
990
|
+
+ `${result.reason || 'source changed'}`,
|
|
991
|
+
);
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
function crossCreateAction(plan, action, context, testHooks = {}) {
|
|
995
|
+
const prepared = stagedPath(plan, 'creates', action.nodeId);
|
|
996
|
+
let receipt = readReceipt(plan, 'create', action.nodeId);
|
|
997
|
+
if (!receipt) {
|
|
998
|
+
if (!exists(prepared)) createOperationTargetAt(prepared, action);
|
|
999
|
+
receipt = writeReceipt(
|
|
1000
|
+
plan,
|
|
1001
|
+
'create',
|
|
1002
|
+
action.nodeId,
|
|
1003
|
+
identity(prepared, { deep: false }),
|
|
1004
|
+
);
|
|
1005
|
+
}
|
|
1006
|
+
if (exists(prepared)) {
|
|
1007
|
+
if (!ownedIdentity(prepared, receipt)) {
|
|
1008
|
+
throw new Error(`workspace tree prepared create changed for ${plan.mutationId}`);
|
|
1009
|
+
}
|
|
1010
|
+
if (typeof testHooks.afterOperationCreatePrepared === 'function') {
|
|
1011
|
+
testHooks.afterOperationCreatePrepared(action, prepared);
|
|
1012
|
+
}
|
|
1013
|
+
requireOwnedDirectory(
|
|
1014
|
+
path.dirname(action.target),
|
|
1015
|
+
action.targetParentIdentity,
|
|
1016
|
+
'operation create',
|
|
1017
|
+
);
|
|
1018
|
+
writeReceipt(plan, 'create-installing', action.nodeId, receipt);
|
|
1019
|
+
if (!installOwnedPath(
|
|
1020
|
+
plan,
|
|
1021
|
+
prepared,
|
|
1022
|
+
action.target,
|
|
1023
|
+
receipt,
|
|
1024
|
+
`operation-create-install-${action.nodeId}`,
|
|
1025
|
+
{
|
|
1026
|
+
operationParent: true,
|
|
1027
|
+
targetParentIdentity: action.targetParentIdentity,
|
|
1028
|
+
},
|
|
1029
|
+
)) {
|
|
1030
|
+
throw new Error(`workspace tree create lost its receipt for ${plan.mutationId}`);
|
|
1031
|
+
}
|
|
1032
|
+
return;
|
|
1033
|
+
}
|
|
1034
|
+
const installing = readReceipt(
|
|
1035
|
+
plan,
|
|
1036
|
+
'create-installing',
|
|
1037
|
+
action.nodeId,
|
|
1038
|
+
);
|
|
1039
|
+
if (installing && ownedIdentity(action.target, receipt)) return;
|
|
1040
|
+
const installedLocation = installing
|
|
1041
|
+
? context.nodeLocation(action.nodeId)
|
|
1042
|
+
: null;
|
|
1043
|
+
if (
|
|
1044
|
+
installedLocation
|
|
1045
|
+
&& !pathInside(installedLocation, stagingRoot(plan))
|
|
1046
|
+
&& ownedIdentity(installedLocation, receipt)
|
|
1047
|
+
) {
|
|
1048
|
+
throw sourceRace(
|
|
1049
|
+
`workspace tree installed create moved before recovery for ${plan.mutationId}`,
|
|
1050
|
+
);
|
|
1051
|
+
}
|
|
1052
|
+
if (installing && !installedLocation) {
|
|
1053
|
+
throw sourceRace(
|
|
1054
|
+
`workspace tree installed create disappeared before recovery for ${plan.mutationId}`,
|
|
1055
|
+
);
|
|
1056
|
+
}
|
|
1057
|
+
if (!installing || !ownedIdentity(action.target, receipt)) {
|
|
1058
|
+
throw new Error(`workspace tree create has no crossing receipt for ${plan.mutationId}`);
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
function crossMoveAction(plan, action, testHooks = {}) {
|
|
1063
|
+
const staged = stagedPath(plan, 'moves', action.nodeId);
|
|
1064
|
+
const installedReceipt = readReceipt(plan, 'move-installed', action.nodeId);
|
|
1065
|
+
if (installedReceipt && ownedIdentity(action.target, installedReceipt)) return;
|
|
1066
|
+
const movingReceipt = readReceipt(plan, 'move', action.nodeId);
|
|
1067
|
+
const installingReceipt = readReceipt(plan, 'move-installing', action.nodeId);
|
|
1068
|
+
if (
|
|
1069
|
+
movingReceipt
|
|
1070
|
+
&& installingReceipt
|
|
1071
|
+
&& !exists(staged)
|
|
1072
|
+
&& ownedIdentity(action.target, movingReceipt)
|
|
1073
|
+
) {
|
|
1074
|
+
writeReceipt(plan, 'move-installed', action.nodeId, movingReceipt);
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
const receipt = stageOwnedPath({
|
|
1078
|
+
plan,
|
|
1079
|
+
source: action.source,
|
|
1080
|
+
staged,
|
|
1081
|
+
nodeId: action.nodeId,
|
|
1082
|
+
receiptLane: 'move',
|
|
1083
|
+
expected: action.identity,
|
|
1084
|
+
sourceParentIdentity: action.sourceParentIdentity,
|
|
1085
|
+
operation: `operation-move-stage-${action.nodeId}`,
|
|
1086
|
+
renameTestHooks: testHooks.operationStageRename || {},
|
|
1087
|
+
});
|
|
1088
|
+
if (exists(staged)) {
|
|
1089
|
+
if (!ownedIdentity(staged, receipt)) {
|
|
1090
|
+
throw new Error(`workspace tree staged move lost ownership for ${plan.mutationId}`);
|
|
1091
|
+
}
|
|
1092
|
+
requireOwnedDirectory(
|
|
1093
|
+
path.dirname(action.target),
|
|
1094
|
+
action.targetParentIdentity,
|
|
1095
|
+
'operation move',
|
|
1096
|
+
);
|
|
1097
|
+
writeReceipt(plan, 'move-installing', action.nodeId, receipt);
|
|
1098
|
+
if (!installOwnedPath(
|
|
1099
|
+
plan,
|
|
1100
|
+
staged,
|
|
1101
|
+
action.target,
|
|
1102
|
+
receipt,
|
|
1103
|
+
`operation-move-install-${action.nodeId}`,
|
|
1104
|
+
{
|
|
1105
|
+
operationParent: true,
|
|
1106
|
+
targetParentIdentity: action.targetParentIdentity,
|
|
1107
|
+
},
|
|
1108
|
+
)) {
|
|
1109
|
+
throw new Error(`workspace tree move lost its receipt for ${plan.mutationId}`);
|
|
1110
|
+
}
|
|
1111
|
+
if (typeof testHooks.afterOperationMoveInstall === 'function') {
|
|
1112
|
+
testHooks.afterOperationMoveInstall(action);
|
|
1113
|
+
}
|
|
1114
|
+
writeReceipt(plan, 'move-installed', action.nodeId, receipt);
|
|
1115
|
+
return;
|
|
1116
|
+
}
|
|
1117
|
+
if (ownedIdentity(action.target, receipt)) {
|
|
1118
|
+
throw sourceRace(
|
|
1119
|
+
`workspace tree move target has no operation crossing receipt for ${plan.mutationId}`,
|
|
1120
|
+
);
|
|
1121
|
+
}
|
|
1122
|
+
throw new Error(`workspace tree move has no crossing receipt for ${plan.mutationId}`);
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
function crossRemoveAction(plan, action, testHooks = {}) {
|
|
1126
|
+
const staged = stagedPath(plan, 'removed', action.nodeId);
|
|
1127
|
+
const receipt = stageOwnedPath({
|
|
1128
|
+
plan,
|
|
1129
|
+
source: action.source,
|
|
1130
|
+
staged,
|
|
1131
|
+
nodeId: action.nodeId,
|
|
1132
|
+
receiptLane: 'remove',
|
|
1133
|
+
expected: action.identity,
|
|
1134
|
+
sourceParentIdentity: action.sourceParentIdentity,
|
|
1135
|
+
operation: `operation-remove-stage-${action.nodeId}`,
|
|
1136
|
+
renameTestHooks: testHooks.operationStageRename || {},
|
|
1137
|
+
});
|
|
1138
|
+
if (!ownedIdentity(staged, receipt)) {
|
|
1139
|
+
throw new Error(`workspace tree removal has no crossing receipt for ${plan.mutationId}`);
|
|
1140
|
+
}
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
function rollbackOperationCreate(
|
|
1144
|
+
plan,
|
|
1145
|
+
action,
|
|
1146
|
+
context,
|
|
1147
|
+
testHooks = {},
|
|
1148
|
+
outputOptions = {},
|
|
1149
|
+
) {
|
|
1150
|
+
const receipt = readReceipt(plan, 'create', action.nodeId);
|
|
1151
|
+
if (!receipt) return;
|
|
1152
|
+
const prepared = stagedPath(plan, 'creates', action.nodeId);
|
|
1153
|
+
const cancelId = `cancel-operation-create-${action.nodeId}`;
|
|
1154
|
+
const cancelled = stagedPath(plan, 'removed', cancelId);
|
|
1155
|
+
if (exists(cancelled) && !ownedIdentity(cancelled, receipt)) {
|
|
1156
|
+
retainPrivatePath(
|
|
1157
|
+
plan,
|
|
1158
|
+
cancelled,
|
|
1159
|
+
action.target,
|
|
1160
|
+
`cancel-create-unexpected-${action.nodeId}`,
|
|
1161
|
+
{
|
|
1162
|
+
item: { nodeId: `cancel-create-unexpected-${action.nodeId}` },
|
|
1163
|
+
lane: 'cancel-create-unexpected',
|
|
1164
|
+
outputOptions,
|
|
1165
|
+
},
|
|
1166
|
+
);
|
|
1167
|
+
}
|
|
1168
|
+
if (exists(prepared) && !ownedIdentity(prepared, receipt)) {
|
|
1169
|
+
retainPrivatePath(
|
|
1170
|
+
plan,
|
|
1171
|
+
prepared,
|
|
1172
|
+
action.target,
|
|
1173
|
+
`cancel-create-prepared-${action.nodeId}`,
|
|
1174
|
+
{
|
|
1175
|
+
item: { nodeId: `cancel-create-prepared-${action.nodeId}` },
|
|
1176
|
+
lane: 'cancel-create-prepared',
|
|
1177
|
+
outputOptions,
|
|
1178
|
+
},
|
|
1179
|
+
);
|
|
1180
|
+
}
|
|
1181
|
+
if (ownedIdentity(prepared, receipt) && !exists(cancelled)) {
|
|
1182
|
+
ensureDirectory(plan, path.dirname(cancelled));
|
|
1183
|
+
writeReceipt(plan, 'create-cancelling', action.nodeId, receipt);
|
|
1184
|
+
exclusiveRename(
|
|
1185
|
+
plan,
|
|
1186
|
+
prepared,
|
|
1187
|
+
cancelled,
|
|
1188
|
+
`cancel-operation-create-prepared-${action.nodeId}`,
|
|
1189
|
+
receipt,
|
|
1190
|
+
);
|
|
1191
|
+
}
|
|
1192
|
+
const resolved = context.resolve(action, 'target');
|
|
1193
|
+
if (
|
|
1194
|
+
!ownedIdentity(cancelled, receipt)
|
|
1195
|
+
&& readReceipt(plan, 'create-installing', action.nodeId)
|
|
1196
|
+
&& resolved
|
|
1197
|
+
&& ownedIdentity(resolved.path, receipt)
|
|
1198
|
+
) {
|
|
1199
|
+
ensureDirectory(plan, path.dirname(cancelled));
|
|
1200
|
+
writeReceipt(plan, 'create-cancelling', action.nodeId, receipt);
|
|
1201
|
+
exclusiveRename(
|
|
1202
|
+
plan,
|
|
1203
|
+
resolved.path,
|
|
1204
|
+
cancelled,
|
|
1205
|
+
`cancel-operation-create-installed-${action.nodeId}`,
|
|
1206
|
+
receipt,
|
|
1207
|
+
{ sourceParentIdentity: resolved.parentIdentity },
|
|
1208
|
+
);
|
|
1209
|
+
}
|
|
1210
|
+
if (
|
|
1211
|
+
ownedIdentity(cancelled, receipt)
|
|
1212
|
+
&& typeof testHooks.afterOperationCreateCancellationStage === 'function'
|
|
1213
|
+
) {
|
|
1214
|
+
testHooks.afterOperationCreateCancellationStage(action, cancelled);
|
|
1215
|
+
}
|
|
1216
|
+
if (!ownedIdentity(cancelled, receipt)) return;
|
|
1217
|
+
const retainedTarget = resolved?.path || action.target;
|
|
1218
|
+
return retireRemoval(plan, {
|
|
1219
|
+
nodeId: cancelId,
|
|
1220
|
+
ownerNodeId: action.nodeId,
|
|
1221
|
+
source: retainedTarget,
|
|
1222
|
+
retainTarget: retainedTarget,
|
|
1223
|
+
retirement: initialCreateRetirement(action, receipt),
|
|
1224
|
+
}, {
|
|
1225
|
+
...outputOptions,
|
|
1226
|
+
preparePlan(nextPlan, outputs) {
|
|
1227
|
+
if (typeof outputOptions.preparePlan === 'function') {
|
|
1228
|
+
outputOptions.preparePlan(nextPlan, outputs);
|
|
1229
|
+
}
|
|
1230
|
+
const carriedRoot = outputs.find((output) => (
|
|
1231
|
+
output.provenance === 'carried-source'
|
|
1232
|
+
&& output.sourceRelativePath === ''
|
|
1233
|
+
));
|
|
1234
|
+
if (carriedRoot) {
|
|
1235
|
+
const bindings = Array.isArray(nextPlan.carriedCreates)
|
|
1236
|
+
? [...nextPlan.carriedCreates]
|
|
1237
|
+
: [];
|
|
1238
|
+
const replacement = {
|
|
1239
|
+
nodeId: action.nodeId,
|
|
1240
|
+
path: carriedRoot.path,
|
|
1241
|
+
identity: carriedRoot.identity,
|
|
1242
|
+
};
|
|
1243
|
+
const index = bindings.findIndex(
|
|
1244
|
+
(binding) => binding.nodeId === action.nodeId,
|
|
1245
|
+
);
|
|
1246
|
+
if (index === -1) bindings.push(replacement);
|
|
1247
|
+
else bindings[index] = replacement;
|
|
1248
|
+
bindings.sort((left, right) => left.nodeId.localeCompare(right.nodeId));
|
|
1249
|
+
nextPlan.carriedCreates = bindings;
|
|
1250
|
+
if (Array.isArray(nextPlan.carriedCreateDeletions)) {
|
|
1251
|
+
nextPlan.carriedCreateDeletions = (
|
|
1252
|
+
nextPlan.carriedCreateDeletions
|
|
1253
|
+
.filter((binding) => binding.nodeId !== action.nodeId)
|
|
1254
|
+
);
|
|
1255
|
+
}
|
|
1256
|
+
return;
|
|
1257
|
+
}
|
|
1258
|
+
if (!outputs.some((output) => (
|
|
1259
|
+
output.provenance === 'independent-occupant'
|
|
1260
|
+
&& path.resolve(output.path) === path.resolve(retainedTarget)
|
|
1261
|
+
))) return;
|
|
1262
|
+
const deletions = Array.isArray(nextPlan.carriedCreateDeletions)
|
|
1263
|
+
? [...nextPlan.carriedCreateDeletions]
|
|
1264
|
+
: [];
|
|
1265
|
+
if (!deletions.some((binding) => binding.nodeId === action.nodeId)) {
|
|
1266
|
+
deletions.push({
|
|
1267
|
+
nodeId: action.nodeId,
|
|
1268
|
+
identity: receipt,
|
|
1269
|
+
});
|
|
1270
|
+
deletions.sort((left, right) => left.nodeId.localeCompare(right.nodeId));
|
|
1271
|
+
nextPlan.carriedCreateDeletions = deletions;
|
|
1272
|
+
}
|
|
1273
|
+
},
|
|
1274
|
+
});
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
function cancelPreparedOperation(
|
|
1278
|
+
held,
|
|
1279
|
+
database = openLocalDb(),
|
|
1280
|
+
testHooks = {},
|
|
1281
|
+
) {
|
|
1282
|
+
let current = getOperation(held.resourceId, held.mutationId, database);
|
|
1283
|
+
if (!current) return true;
|
|
1284
|
+
if (current.state !== 'prepared') return false;
|
|
1285
|
+
let plan = current.plan;
|
|
1286
|
+
const outputOptions = {
|
|
1287
|
+
durable: true,
|
|
1288
|
+
persistPlan(nextPlan) {
|
|
1289
|
+
current = replacePreparedOperation(current, nextPlan, database);
|
|
1290
|
+
plan = current.plan;
|
|
1291
|
+
},
|
|
1292
|
+
afterOutputReceipt: testHooks.afterOperationCancellationOutputReceipt,
|
|
1293
|
+
afterOutputMove: testHooks.afterOperationCancellationOutputMove,
|
|
1294
|
+
};
|
|
1295
|
+
let context = operationRecoveryContext(plan, { allowRelocated: true });
|
|
1296
|
+
const targetActions = plan.actions
|
|
1297
|
+
.filter((action) => action.target)
|
|
1298
|
+
.sort((left, right) => (
|
|
1299
|
+
right.target.split(path.sep).length - left.target.split(path.sep).length
|
|
1300
|
+
));
|
|
1301
|
+
const carriedCreates = Array.isArray(plan.carriedCreates)
|
|
1302
|
+
? [...plan.carriedCreates]
|
|
1303
|
+
: [];
|
|
1304
|
+
const carriedCreateDeletions = Array.isArray(plan.carriedCreateDeletions)
|
|
1305
|
+
? [...plan.carriedCreateDeletions]
|
|
1306
|
+
: [];
|
|
1307
|
+
const carriedCreateIds = new Set(
|
|
1308
|
+
[...carriedCreates, ...carriedCreateDeletions]
|
|
1309
|
+
.map((binding) => binding.nodeId),
|
|
1310
|
+
);
|
|
1311
|
+
for (const action of targetActions) {
|
|
1312
|
+
if (action.type !== 'create' || carriedCreateIds.has(action.nodeId)) continue;
|
|
1313
|
+
const receipt = readReceipt(plan, 'create', action.nodeId);
|
|
1314
|
+
if (!receipt || !readReceipt(plan, 'create-installing', action.nodeId)) continue;
|
|
1315
|
+
const location = context.nodeLocation(action.nodeId);
|
|
1316
|
+
if (!location && !exists(stagedPath(plan, 'creates', action.nodeId))) {
|
|
1317
|
+
const resolvedTarget = context.resolve(action, 'target')?.path
|
|
1318
|
+
|| action.target;
|
|
1319
|
+
const replacementIdentity = exists(resolvedTarget)
|
|
1320
|
+
? identity(resolvedTarget, { deep: false })
|
|
1321
|
+
: null;
|
|
1322
|
+
if (
|
|
1323
|
+
action.kind === 'file'
|
|
1324
|
+
&& replacementIdentity?.kind === 'file'
|
|
1325
|
+
) {
|
|
1326
|
+
// A regular file keeps its document UUID across atomic same-path
|
|
1327
|
+
// replacement. The new inode is later content, not a new tree node.
|
|
1328
|
+
carriedCreates.push({
|
|
1329
|
+
nodeId: action.nodeId,
|
|
1330
|
+
path: resolvedTarget,
|
|
1331
|
+
identity: replacementIdentity,
|
|
1332
|
+
refreshAtSamePath: true,
|
|
1333
|
+
});
|
|
1334
|
+
} else {
|
|
1335
|
+
// A missing original receipt proves the installed value was removed.
|
|
1336
|
+
// Any different folder, symlink, or kind at its name is independent
|
|
1337
|
+
// and will receive a fresh UUID after this one becomes local trash.
|
|
1338
|
+
carriedCreateDeletions.push({
|
|
1339
|
+
nodeId: action.nodeId,
|
|
1340
|
+
identity: receipt,
|
|
1341
|
+
});
|
|
1342
|
+
}
|
|
1343
|
+
carriedCreateIds.add(action.nodeId);
|
|
1344
|
+
continue;
|
|
1345
|
+
}
|
|
1346
|
+
if (
|
|
1347
|
+
!location
|
|
1348
|
+
|| pathInside(location, stagingRoot(plan))
|
|
1349
|
+
|| !ownedIdentity(location, receipt)
|
|
1350
|
+
) {
|
|
1351
|
+
continue;
|
|
1352
|
+
}
|
|
1353
|
+
carriedCreates.push({
|
|
1354
|
+
nodeId: action.nodeId,
|
|
1355
|
+
path: location,
|
|
1356
|
+
identity: receipt,
|
|
1357
|
+
});
|
|
1358
|
+
carriedCreateIds.add(action.nodeId);
|
|
1359
|
+
}
|
|
1360
|
+
if (carriedCreates.length || carriedCreateDeletions.length) {
|
|
1361
|
+
// Once a plan-created inode becomes visible, its receipt is UUID
|
|
1362
|
+
// provenance. Preserve a surviving binding or its physical disappearance
|
|
1363
|
+
// in the control row before cleaning private staging so a parent/child
|
|
1364
|
+
// race can replan through the same UUID instead of birthing a relocation
|
|
1365
|
+
// or resurrecting a local delete.
|
|
1366
|
+
plan.carriedCreates = carriedCreates;
|
|
1367
|
+
plan.carriedCreateDeletions = carriedCreateDeletions;
|
|
1368
|
+
replacePreparedOperation(current, plan, database);
|
|
1369
|
+
if (typeof testHooks.afterOperationCarriedCreateCapture === 'function') {
|
|
1370
|
+
testHooks.afterOperationCarriedCreateCapture(carriedCreates);
|
|
1371
|
+
}
|
|
1372
|
+
if (
|
|
1373
|
+
carriedCreateDeletions.length
|
|
1374
|
+
&& typeof testHooks.afterOperationCarriedCreateDeletionCapture === 'function'
|
|
1375
|
+
) {
|
|
1376
|
+
testHooks.afterOperationCarriedCreateDeletionCapture(
|
|
1377
|
+
carriedCreateDeletions,
|
|
1378
|
+
);
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
// First pull every installed operation-owned value back into private
|
|
1382
|
+
// staging. The durable *-installing receipt is the only authority for
|
|
1383
|
+
// interpreting an exact target inode as output of this plan.
|
|
1384
|
+
for (const action of targetActions) {
|
|
1385
|
+
if (action.type !== 'move') continue;
|
|
1386
|
+
const receipt = readReceipt(plan, 'move', action.nodeId);
|
|
1387
|
+
const installing = readReceipt(plan, 'move-installing', action.nodeId);
|
|
1388
|
+
const staged = stagedPath(plan, 'moves', action.nodeId);
|
|
1389
|
+
const resolved = context.resolve(action, 'target');
|
|
1390
|
+
if (
|
|
1391
|
+
receipt
|
|
1392
|
+
&& installing
|
|
1393
|
+
&& resolved
|
|
1394
|
+
&& !ownedIdentity(staged, receipt)
|
|
1395
|
+
&& ownedIdentity(resolved.path, receipt)
|
|
1396
|
+
) {
|
|
1397
|
+
ensureDirectory(plan, path.dirname(staged));
|
|
1398
|
+
exclusiveRename(
|
|
1399
|
+
plan,
|
|
1400
|
+
resolved.path,
|
|
1401
|
+
staged,
|
|
1402
|
+
`cancel-operation-extract-${action.nodeId}`,
|
|
1403
|
+
receipt,
|
|
1404
|
+
{ sourceParentIdentity: resolved.parentIdentity },
|
|
1405
|
+
);
|
|
1406
|
+
}
|
|
1407
|
+
}
|
|
1408
|
+
context = operationRecoveryContext(plan, { allowRelocated: true });
|
|
1409
|
+
const sourceActions = plan.actions
|
|
1410
|
+
.filter((action) => action.type === 'move' || action.type === 'remove')
|
|
1411
|
+
.sort((left, right) => (
|
|
1412
|
+
left.source.split(path.sep).length - right.source.split(path.sep).length
|
|
1413
|
+
));
|
|
1414
|
+
for (const action of sourceActions) {
|
|
1415
|
+
const lane = action.type === 'move' ? 'move' : 'remove';
|
|
1416
|
+
const stagedLane = action.type === 'move' ? 'moves' : 'removed';
|
|
1417
|
+
const receipt = readReceipt(plan, lane, action.nodeId);
|
|
1418
|
+
if (!receipt) continue;
|
|
1419
|
+
const staged = stagedPath(plan, stagedLane, action.nodeId);
|
|
1420
|
+
if (exists(staged) && !ownedIdentity(staged, receipt)) {
|
|
1421
|
+
retainPrivatePath(
|
|
1422
|
+
plan,
|
|
1423
|
+
staged,
|
|
1424
|
+
action.source,
|
|
1425
|
+
`cancel-unexpected-${action.nodeId}`,
|
|
1426
|
+
{
|
|
1427
|
+
item: { nodeId: `cancel-unexpected-${action.nodeId}` },
|
|
1428
|
+
lane: `cancel-unexpected-${lane}`,
|
|
1429
|
+
outputOptions,
|
|
1430
|
+
},
|
|
1431
|
+
);
|
|
1432
|
+
continue;
|
|
1433
|
+
}
|
|
1434
|
+
if (!ownedIdentity(staged, receipt)) continue;
|
|
1435
|
+
const resolved = context.resolve(action, 'source');
|
|
1436
|
+
if (!resolved) {
|
|
1437
|
+
retainPrivatePath(
|
|
1438
|
+
plan,
|
|
1439
|
+
staged,
|
|
1440
|
+
action.source,
|
|
1441
|
+
`cancel-missing-parent-${action.nodeId}`,
|
|
1442
|
+
{
|
|
1443
|
+
item: { nodeId: action.nodeId },
|
|
1444
|
+
lane: `cancel-missing-parent-${lane}`,
|
|
1445
|
+
provenance: 'carried-source',
|
|
1446
|
+
outputOptions,
|
|
1447
|
+
},
|
|
1448
|
+
);
|
|
1449
|
+
continue;
|
|
1450
|
+
}
|
|
1451
|
+
installOwnedPath(
|
|
1452
|
+
plan,
|
|
1453
|
+
staged,
|
|
1454
|
+
resolved.path,
|
|
1455
|
+
receipt,
|
|
1456
|
+
`cancel-operation-${action.nodeId}`,
|
|
1457
|
+
{
|
|
1458
|
+
targetParentIdentity: resolved.parentIdentity,
|
|
1459
|
+
retainedCollision: {
|
|
1460
|
+
item: { nodeId: `cancel-source-${action.nodeId}` },
|
|
1461
|
+
lane: `cancel-source-${lane}`,
|
|
1462
|
+
outputOptions,
|
|
1463
|
+
},
|
|
1464
|
+
},
|
|
1465
|
+
);
|
|
1466
|
+
}
|
|
1467
|
+
for (const action of targetActions) {
|
|
1468
|
+
if (action.type !== 'create') continue;
|
|
1469
|
+
const cancellationStillPrivate = (
|
|
1470
|
+
exists(stagedPath(plan, 'creates', action.nodeId))
|
|
1471
|
+
|| exists(stagedPath(
|
|
1472
|
+
plan,
|
|
1473
|
+
'removed',
|
|
1474
|
+
`cancel-operation-create-${action.nodeId}`,
|
|
1475
|
+
))
|
|
1476
|
+
);
|
|
1477
|
+
if (carriedCreateIds.has(action.nodeId) && !cancellationStillPrivate) {
|
|
1478
|
+
continue;
|
|
1479
|
+
}
|
|
1480
|
+
rollbackOperationCreate(
|
|
1481
|
+
plan,
|
|
1482
|
+
action,
|
|
1483
|
+
context,
|
|
1484
|
+
testHooks,
|
|
1485
|
+
outputOptions,
|
|
1486
|
+
);
|
|
1487
|
+
}
|
|
1488
|
+
cleanupStagingRoot(plan);
|
|
1489
|
+
if (
|
|
1490
|
+
carriedCreates.length
|
|
1491
|
+
|| carriedCreateDeletions.length
|
|
1492
|
+
|| (Array.isArray(plan.retainedOutputs) && plan.retainedOutputs.length)
|
|
1493
|
+
) return true;
|
|
1494
|
+
const result = database.prepare(`
|
|
1495
|
+
DELETE FROM workspace_tree_operation_materializations
|
|
1496
|
+
WHERE resource_id = ? AND mutation_id = ? AND materialization_state = 'prepared'
|
|
1497
|
+
`).run(current.resourceId, current.mutationId);
|
|
1498
|
+
return result.changes === 1;
|
|
1499
|
+
}
|
|
1500
|
+
|
|
1501
|
+
function resolvedOperationAction(plan, action, phase, context) {
|
|
1502
|
+
if (!action[phase]) return action;
|
|
1503
|
+
const resolved = context.resolve(action, phase);
|
|
1504
|
+
if (!resolved) {
|
|
1505
|
+
throw parentRace(
|
|
1506
|
+
`workspace tree operation ${phase} parent changed for ${action.nodeId}`,
|
|
1507
|
+
);
|
|
1508
|
+
}
|
|
1509
|
+
return {
|
|
1510
|
+
...action,
|
|
1511
|
+
[phase]: resolved.path,
|
|
1512
|
+
[`${phase}ParentIdentity`]: resolved.parentIdentity,
|
|
1513
|
+
};
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
function stageOperationAction(plan, action, context, testHooks = {}) {
|
|
1517
|
+
const resolved = resolvedOperationAction(plan, action, 'source', context);
|
|
1518
|
+
const lane = action.type === 'move' ? 'move' : 'remove';
|
|
1519
|
+
const stagedLane = action.type === 'move' ? 'moves' : 'removed';
|
|
1520
|
+
stageOwnedPath({
|
|
1521
|
+
plan,
|
|
1522
|
+
source: resolved.source,
|
|
1523
|
+
staged: stagedPath(plan, stagedLane, action.nodeId),
|
|
1524
|
+
nodeId: action.nodeId,
|
|
1525
|
+
receiptLane: lane,
|
|
1526
|
+
expected: action.identity,
|
|
1527
|
+
sourceParentIdentity: resolved.sourceParentIdentity,
|
|
1528
|
+
operation: `operation-${lane}-stage-${action.nodeId}`,
|
|
1529
|
+
renameTestHooks: testHooks.operationStageRename || {},
|
|
1530
|
+
});
|
|
1531
|
+
}
|
|
1532
|
+
|
|
1533
|
+
function crossOperation(held, database = openLocalDb(), testHooks = {}) {
|
|
1534
|
+
if (!held) throw new Error('workspace tree operation plan is missing');
|
|
1535
|
+
const plan = held.plan;
|
|
1536
|
+
if (held.state === 'committed') return held;
|
|
1537
|
+
if (held.state === 'crossed') {
|
|
1538
|
+
const context = operationRecoveryContext(plan);
|
|
1539
|
+
for (const action of plan.actions) {
|
|
1540
|
+
const resolved = action.target
|
|
1541
|
+
? resolvedOperationAction(plan, action, 'target', context)
|
|
1542
|
+
: action;
|
|
1543
|
+
if (
|
|
1544
|
+
action.type === 'create'
|
|
1545
|
+
&& !ownedIdentity(
|
|
1546
|
+
resolved.target,
|
|
1547
|
+
readReceipt(plan, 'create', action.nodeId),
|
|
1548
|
+
)
|
|
1549
|
+
) {
|
|
1550
|
+
throw new Error(`workspace tree create receipt changed for ${plan.mutationId}`);
|
|
1551
|
+
}
|
|
1552
|
+
if (
|
|
1553
|
+
action.type === 'move'
|
|
1554
|
+
&& !ownedIdentity(
|
|
1555
|
+
resolved.target,
|
|
1556
|
+
readReceipt(plan, 'move-installed', action.nodeId),
|
|
1557
|
+
)
|
|
1558
|
+
) {
|
|
1559
|
+
throw new Error(`workspace tree move receipt changed for ${plan.mutationId}`);
|
|
1560
|
+
}
|
|
1561
|
+
if (action.type === 'remove') {
|
|
1562
|
+
const staged = stagedPath(plan, 'removed', action.nodeId);
|
|
1563
|
+
if (!ownedIdentity(staged, readReceipt(plan, 'remove', action.nodeId))) {
|
|
1564
|
+
throw new Error(`workspace tree removal receipt changed for ${plan.mutationId}`);
|
|
1565
|
+
}
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
return held;
|
|
1569
|
+
}
|
|
1570
|
+
requireRoot(plan, database);
|
|
1571
|
+
if (
|
|
1572
|
+
(Array.isArray(plan.carriedCreates) && plan.carriedCreates.length)
|
|
1573
|
+
|| (
|
|
1574
|
+
Array.isArray(plan.carriedCreateDeletions)
|
|
1575
|
+
&& plan.carriedCreateDeletions.length
|
|
1576
|
+
)
|
|
1577
|
+
|| (
|
|
1578
|
+
Array.isArray(plan.retainedOutputs)
|
|
1579
|
+
&& plan.retainedOutputs.length
|
|
1580
|
+
)
|
|
1581
|
+
) {
|
|
1582
|
+
if (!cancelPreparedOperation(held, database, testHooks)) {
|
|
1583
|
+
throw new Error(
|
|
1584
|
+
`workspace tree carried create cancellation did not finish for ${plan.mutationId}`,
|
|
1585
|
+
);
|
|
1586
|
+
}
|
|
1587
|
+
const error = sourceRace(
|
|
1588
|
+
`workspace tree operation resumes from its carried create bindings for ${plan.mutationId}`,
|
|
1589
|
+
);
|
|
1590
|
+
error.operationPlan = (
|
|
1591
|
+
getOperation(plan.resourceId, plan.mutationId, database)?.plan
|
|
1592
|
+
|| plan
|
|
1593
|
+
);
|
|
1594
|
+
throw error;
|
|
1595
|
+
}
|
|
1596
|
+
const cancellationStarted = plan.actions.some((action) => (
|
|
1597
|
+
action.type === 'create'
|
|
1598
|
+
&& readReceipt(plan, 'create-cancelling', action.nodeId)
|
|
1599
|
+
));
|
|
1600
|
+
if (cancellationStarted) {
|
|
1601
|
+
if (!cancelPreparedOperation(held, database, testHooks)) {
|
|
1602
|
+
throw new Error(
|
|
1603
|
+
`workspace tree operation cancellation did not finish for ${plan.mutationId}`,
|
|
1604
|
+
);
|
|
1605
|
+
}
|
|
1606
|
+
const error = sourceRace(
|
|
1607
|
+
`workspace tree operation resumes from its durable cancellation for ${plan.mutationId}`,
|
|
1608
|
+
);
|
|
1609
|
+
error.operationPlan = plan;
|
|
1610
|
+
throw error;
|
|
1611
|
+
}
|
|
1612
|
+
try {
|
|
1613
|
+
let context = operationRecoveryContext(plan);
|
|
1614
|
+
const extraction = plan.actions
|
|
1615
|
+
.filter((action) => action.type === 'move' || action.type === 'remove')
|
|
1616
|
+
.sort((left, right) => (
|
|
1617
|
+
right.source.split(path.sep).length - left.source.split(path.sep).length
|
|
1618
|
+
));
|
|
1619
|
+
for (const action of extraction) {
|
|
1620
|
+
stageOperationAction(plan, action, context, testHooks);
|
|
1621
|
+
}
|
|
1622
|
+
if (typeof testHooks.afterOperationStaging === 'function') {
|
|
1623
|
+
testHooks.afterOperationStaging(plan);
|
|
1624
|
+
}
|
|
1625
|
+
context = operationRecoveryContext(plan);
|
|
1626
|
+
const installation = plan.actions
|
|
1627
|
+
.filter((action) => action.type === 'create' || action.type === 'move')
|
|
1628
|
+
.sort((left, right) => (
|
|
1629
|
+
left.target.split(path.sep).length - right.target.split(path.sep).length
|
|
1630
|
+
));
|
|
1631
|
+
for (const action of installation) {
|
|
1632
|
+
const resolved = resolvedOperationAction(
|
|
1633
|
+
plan,
|
|
1634
|
+
action,
|
|
1635
|
+
'target',
|
|
1636
|
+
context,
|
|
1637
|
+
);
|
|
1638
|
+
if (action.type === 'create') {
|
|
1639
|
+
crossCreateAction(plan, resolved, context, testHooks);
|
|
1640
|
+
} else {
|
|
1641
|
+
crossMoveAction(plan, resolved, testHooks);
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
} catch (error) {
|
|
1645
|
+
if (
|
|
1646
|
+
error?.code === 'WORKSPACE_TREE_SOURCE_RACE'
|
|
1647
|
+
|| error?.code === 'WORKSPACE_TREE_PARENT_RACE'
|
|
1648
|
+
) {
|
|
1649
|
+
cancelPreparedOperation(held, database, testHooks);
|
|
1650
|
+
error.operationPlan = (
|
|
1651
|
+
getOperation(plan.resourceId, plan.mutationId, database)?.plan
|
|
1652
|
+
|| plan
|
|
1653
|
+
);
|
|
1654
|
+
}
|
|
1655
|
+
throw error;
|
|
1656
|
+
}
|
|
1657
|
+
if (testHooks.crashAfterOperationPhysical === true) {
|
|
1658
|
+
throw new Error('simulated workspace tree crash after official operation');
|
|
1659
|
+
}
|
|
1660
|
+
database.prepare(`
|
|
1661
|
+
UPDATE workspace_tree_operation_materializations
|
|
1662
|
+
SET materialization_state = 'crossed',
|
|
1663
|
+
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
1664
|
+
WHERE resource_id = ? AND mutation_id = ? AND materialization_state = 'prepared'
|
|
1665
|
+
`).run(held.resourceId, held.mutationId);
|
|
1666
|
+
return getOperation(held.resourceId, held.mutationId, database);
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
function markOperationCommitted(held, database = openLocalDb()) {
|
|
1670
|
+
database.prepare(`
|
|
1671
|
+
UPDATE workspace_tree_operation_materializations
|
|
1672
|
+
SET materialization_state = 'committed',
|
|
1673
|
+
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
1674
|
+
WHERE resource_id = ? AND mutation_id = ? AND materialization_state = 'crossed'
|
|
1675
|
+
`).run(held.resourceId, held.mutationId);
|
|
1676
|
+
const current = getOperation(held.resourceId, held.mutationId, database);
|
|
1677
|
+
if (!current || current.state !== 'committed') {
|
|
1678
|
+
throw new Error(`workspace tree operation ${held.mutationId} did not commit`);
|
|
1679
|
+
}
|
|
1680
|
+
return current;
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
function retirementTransaction(plan, item, entry) {
|
|
1684
|
+
const spelledRoot = stagingRoot(plan);
|
|
1685
|
+
const root = fs.realpathSync(spelledRoot);
|
|
1686
|
+
const rootStat = fs.statSync(root, { bigint: true });
|
|
1687
|
+
const staged = stagedPath(plan, 'removed', item.nodeId);
|
|
1688
|
+
const candidate = entry.relativePath
|
|
1689
|
+
? path.join(staged, ...entry.relativePath.split('/'))
|
|
1690
|
+
: staged;
|
|
1691
|
+
const relativePath = path.relative(spelledRoot, candidate).split(path.sep).join('/');
|
|
1692
|
+
const id = crypto.createHash('sha256')
|
|
1693
|
+
.update(
|
|
1694
|
+
`${plan.contract}\0retire\0${plan.resourceId}\0`
|
|
1695
|
+
+ `${plan.snapshotVersion ?? plan.version}\0${plan.snapshotChecksum || plan.mutationId}`
|
|
1696
|
+
+ `\0${item.nodeId}\0${entry.relativePath}`,
|
|
1697
|
+
)
|
|
1698
|
+
.digest('hex');
|
|
1699
|
+
return {
|
|
1700
|
+
candidate,
|
|
1701
|
+
scratch: path.join(path.dirname(candidate), `.amalgm-apply-${id}`),
|
|
1702
|
+
transaction: {
|
|
1703
|
+
root,
|
|
1704
|
+
rootDev: rootStat.dev.toString(),
|
|
1705
|
+
rootIno: rootStat.ino.toString(),
|
|
1706
|
+
path: relativePath,
|
|
1707
|
+
id,
|
|
1708
|
+
operation: `workspace-tree-retire:${plan.resourceId}:${item.nodeId}:${entry.relativePath}`,
|
|
1709
|
+
expected: entry.identity,
|
|
1710
|
+
target: null,
|
|
1711
|
+
},
|
|
1712
|
+
};
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
function retireLeaf(plan, item, entry) {
|
|
1716
|
+
const prepared = retirementTransaction(plan, item, entry);
|
|
1717
|
+
let result;
|
|
1718
|
+
if (exists(prepared.scratch)) {
|
|
1719
|
+
result = nativeFileTransaction.run(prepared.transaction, 'recover');
|
|
1720
|
+
} else if (!exists(prepared.candidate)) {
|
|
1721
|
+
return false;
|
|
1722
|
+
} else {
|
|
1723
|
+
const stat = fs.lstatSync(prepared.candidate);
|
|
1724
|
+
if (!stat.isFile() && !stat.isSymbolicLink()) return true;
|
|
1725
|
+
result = nativeFileTransaction.run(prepared.transaction, 'apply');
|
|
1726
|
+
}
|
|
1727
|
+
if (result.status === 'applied') {
|
|
1728
|
+
const released = nativeFileTransaction.run(prepared.transaction, 'release');
|
|
1729
|
+
if (released.status !== 'released') {
|
|
1730
|
+
throw new Error(
|
|
1731
|
+
`workspace tree retirement receipt stayed ${released.status} for ${item.nodeId}`,
|
|
1732
|
+
);
|
|
1733
|
+
}
|
|
1734
|
+
return false;
|
|
1735
|
+
}
|
|
1736
|
+
if (result.status === 'changed') return exists(prepared.candidate);
|
|
1737
|
+
throw new Error(
|
|
1738
|
+
`workspace tree retirement stayed ${result.status} for ${item.nodeId}: `
|
|
1739
|
+
+ `${result.reason || 'no reason'}`,
|
|
1740
|
+
);
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
function removeEmptyDirectoryTree(target) {
|
|
1744
|
+
if (!exists(target)) return true;
|
|
1745
|
+
const stat = fs.lstatSync(target);
|
|
1746
|
+
if (!stat.isDirectory()) return false;
|
|
1747
|
+
for (const name of fs.readdirSync(target)) {
|
|
1748
|
+
const child = path.join(target, name);
|
|
1749
|
+
if (fs.lstatSync(child).isDirectory()) removeEmptyDirectoryTree(child);
|
|
1750
|
+
}
|
|
1751
|
+
if (fs.readdirSync(target).length !== 0) return false;
|
|
1752
|
+
fs.rmdirSync(target);
|
|
1753
|
+
syncDirectory(path.dirname(target));
|
|
1754
|
+
return true;
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
function retainedConflictPath(target, token) {
|
|
1758
|
+
const directory = path.dirname(target);
|
|
1759
|
+
const extension = path.extname(target);
|
|
1760
|
+
const stem = extension ? path.basename(target, extension) : path.basename(target);
|
|
1761
|
+
let candidate = path.join(directory, `${stem}.amalgm-retained-${token}${extension}`);
|
|
1762
|
+
let suffix = 2;
|
|
1763
|
+
while (exists(candidate)) {
|
|
1764
|
+
candidate = path.join(
|
|
1765
|
+
directory,
|
|
1766
|
+
`${stem}.amalgm-retained-${token}-${suffix}${extension}`,
|
|
1767
|
+
);
|
|
1768
|
+
suffix += 1;
|
|
1769
|
+
}
|
|
1770
|
+
return candidate;
|
|
1771
|
+
}
|
|
1772
|
+
|
|
1773
|
+
function retainedRetry(message, reason) {
|
|
1774
|
+
return Object.assign(new Error(message), {
|
|
1775
|
+
code: 'WORKSPACE_TREE_RETRY_PENDING',
|
|
1776
|
+
reason,
|
|
1777
|
+
statusCode: 409,
|
|
1778
|
+
});
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
function retainedOutputId(plan, item, lane, relativePath, role) {
|
|
1782
|
+
return crypto.createHash('sha256')
|
|
1783
|
+
.update(
|
|
1784
|
+
`${plan.contract}\0retained-output\0${plan.resourceId}\0`
|
|
1785
|
+
+ `${plan.snapshotVersion ?? plan.version}\0`
|
|
1786
|
+
+ `${plan.snapshotChecksum || plan.mutationId}\0${item.nodeId}\0`
|
|
1787
|
+
+ `${lane}\0${relativePath}\0${role}`,
|
|
1788
|
+
)
|
|
1789
|
+
.digest('hex');
|
|
1790
|
+
}
|
|
1791
|
+
|
|
1792
|
+
function sealRetainedOutputs(plan, outputs, options = {}) {
|
|
1793
|
+
const current = Array.isArray(plan.retainedOutputs)
|
|
1794
|
+
? [...plan.retainedOutputs]
|
|
1795
|
+
: [];
|
|
1796
|
+
const byId = new Map(current.map((output) => [output.outputId, output]));
|
|
1797
|
+
let changed = false;
|
|
1798
|
+
const sealed = outputs.map((output) => {
|
|
1799
|
+
const existing = byId.get(output.outputId);
|
|
1800
|
+
if (existing) {
|
|
1801
|
+
if (JSON.stringify(existing) !== JSON.stringify(output)) {
|
|
1802
|
+
throw Object.assign(
|
|
1803
|
+
retainedRetry(
|
|
1804
|
+
`workspace tree retained output ${output.outputId} changed its ownership receipt`,
|
|
1805
|
+
'retained-output-changed',
|
|
1806
|
+
),
|
|
1807
|
+
{ paths: [existing.path, output.path].filter(Boolean) },
|
|
1808
|
+
);
|
|
1809
|
+
}
|
|
1810
|
+
return existing;
|
|
1811
|
+
}
|
|
1812
|
+
current.push(output);
|
|
1813
|
+
byId.set(output.outputId, output);
|
|
1814
|
+
changed = true;
|
|
1815
|
+
return output;
|
|
1816
|
+
});
|
|
1817
|
+
if (!changed) return sealed;
|
|
1818
|
+
current.sort((left, right) => left.outputId.localeCompare(right.outputId));
|
|
1819
|
+
plan.retainedOutputs = current;
|
|
1820
|
+
if (typeof options.preparePlan === 'function') {
|
|
1821
|
+
options.preparePlan(plan, sealed);
|
|
1822
|
+
}
|
|
1823
|
+
if (options.durable === true && typeof options.persistPlan !== 'function') {
|
|
1824
|
+
throw new Error('workspace tree retained output has no durable plan writer');
|
|
1825
|
+
}
|
|
1826
|
+
if (typeof options.persistPlan === 'function') options.persistPlan(plan);
|
|
1827
|
+
if (typeof options.afterOutputReceipt === 'function') {
|
|
1828
|
+
options.afterOutputReceipt(sealed, plan);
|
|
1829
|
+
}
|
|
1830
|
+
return sealed;
|
|
1831
|
+
}
|
|
1832
|
+
|
|
1833
|
+
function retainedOutput(
|
|
1834
|
+
plan,
|
|
1835
|
+
item,
|
|
1836
|
+
lane,
|
|
1837
|
+
relativePath,
|
|
1838
|
+
role,
|
|
1839
|
+
outputPath,
|
|
1840
|
+
physicalIdentity,
|
|
1841
|
+
sourceRelativePath,
|
|
1842
|
+
parentIdentity = null,
|
|
1843
|
+
ownerNodeId = null,
|
|
1844
|
+
) {
|
|
1845
|
+
return {
|
|
1846
|
+
outputId: retainedOutputId(plan, item, lane, relativePath, role),
|
|
1847
|
+
removalNodeId: item.nodeId,
|
|
1848
|
+
lane,
|
|
1849
|
+
relativePath,
|
|
1850
|
+
path: outputPath,
|
|
1851
|
+
identity: physicalIdentity,
|
|
1852
|
+
provenance: role === 'carried' ? 'carried-source' : 'independent-occupant',
|
|
1853
|
+
ownerNodeId: role === 'carried' ? ownerNodeId : null,
|
|
1854
|
+
sourceRelativePath: role === 'carried' ? sourceRelativePath : null,
|
|
1855
|
+
parentIdentity: parentIdentity || identity(path.dirname(outputPath), { deep: false }),
|
|
1856
|
+
};
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
function retainedTreeOutputs(
|
|
1860
|
+
plan,
|
|
1861
|
+
item,
|
|
1862
|
+
lane,
|
|
1863
|
+
source,
|
|
1864
|
+
target,
|
|
1865
|
+
relativePath,
|
|
1866
|
+
role,
|
|
1867
|
+
sourceRelativePath,
|
|
1868
|
+
) {
|
|
1869
|
+
const outputs = [];
|
|
1870
|
+
const ownerBySourcePath = new Map(
|
|
1871
|
+
(plan.rollbackBindings || [])
|
|
1872
|
+
.filter((binding) => binding?.from != null && binding?.nodeId)
|
|
1873
|
+
.map((binding) => [binding.from, binding.nodeId]),
|
|
1874
|
+
);
|
|
1875
|
+
const carriedOwner = (outputSourceRelativePath) => {
|
|
1876
|
+
if (role !== 'carried') return null;
|
|
1877
|
+
const sourcePath = item.from == null
|
|
1878
|
+
? null
|
|
1879
|
+
: (
|
|
1880
|
+
outputSourceRelativePath
|
|
1881
|
+
? `${item.from}/${outputSourceRelativePath}`
|
|
1882
|
+
: item.from
|
|
1883
|
+
);
|
|
1884
|
+
if (sourcePath && ownerBySourcePath.has(sourcePath)) {
|
|
1885
|
+
return ownerBySourcePath.get(sourcePath);
|
|
1886
|
+
}
|
|
1887
|
+
if (!outputSourceRelativePath && (item.ownerNodeId || item.nodeId)) {
|
|
1888
|
+
return item.ownerNodeId || item.nodeId;
|
|
1889
|
+
}
|
|
1890
|
+
return null;
|
|
1891
|
+
};
|
|
1892
|
+
const visit = (
|
|
1893
|
+
sourcePath,
|
|
1894
|
+
targetPath,
|
|
1895
|
+
outputRelativePath,
|
|
1896
|
+
outputSourceRelativePath,
|
|
1897
|
+
) => {
|
|
1898
|
+
const sourceIdentity = identity(sourcePath, { deep: false });
|
|
1899
|
+
const outputParentIdentity = exists(path.dirname(targetPath))
|
|
1900
|
+
? identity(path.dirname(targetPath), { deep: false })
|
|
1901
|
+
: identity(path.dirname(sourcePath), { deep: false });
|
|
1902
|
+
outputs.push(retainedOutput(
|
|
1903
|
+
plan,
|
|
1904
|
+
item,
|
|
1905
|
+
lane,
|
|
1906
|
+
outputRelativePath,
|
|
1907
|
+
role,
|
|
1908
|
+
targetPath,
|
|
1909
|
+
sourceIdentity,
|
|
1910
|
+
role === 'carried' ? outputSourceRelativePath : null,
|
|
1911
|
+
outputParentIdentity,
|
|
1912
|
+
carriedOwner(outputSourceRelativePath),
|
|
1913
|
+
));
|
|
1914
|
+
if (sourceIdentity.kind !== 'folder') return;
|
|
1915
|
+
for (const name of fs.readdirSync(sourcePath).sort()) {
|
|
1916
|
+
visit(
|
|
1917
|
+
path.join(sourcePath, name),
|
|
1918
|
+
path.join(targetPath, name),
|
|
1919
|
+
outputRelativePath ? `${outputRelativePath}/${name}` : name,
|
|
1920
|
+
outputSourceRelativePath ? `${outputSourceRelativePath}/${name}` : name,
|
|
1921
|
+
);
|
|
1922
|
+
}
|
|
1923
|
+
};
|
|
1924
|
+
visit(source, target, relativePath, sourceRelativePath);
|
|
1925
|
+
return outputs;
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
function mergeRetained(
|
|
1929
|
+
plan,
|
|
1930
|
+
item,
|
|
1931
|
+
source,
|
|
1932
|
+
target,
|
|
1933
|
+
token,
|
|
1934
|
+
options = {},
|
|
1935
|
+
relativePath = '',
|
|
1936
|
+
) {
|
|
1937
|
+
if (!exists(source)) return [];
|
|
1938
|
+
const sourceIdentity = identity(source, { deep: false });
|
|
1939
|
+
const sourceRelativePath = options.sourceRelativePath == null
|
|
1940
|
+
? relativePath
|
|
1941
|
+
: (
|
|
1942
|
+
relativePath
|
|
1943
|
+
? `${options.sourceRelativePath}/${relativePath}`
|
|
1944
|
+
: options.sourceRelativePath
|
|
1945
|
+
);
|
|
1946
|
+
if (!exists(target)) {
|
|
1947
|
+
if (options.targetParentIdentity) {
|
|
1948
|
+
requireOwnedDirectory(
|
|
1949
|
+
path.dirname(target),
|
|
1950
|
+
options.targetParentIdentity,
|
|
1951
|
+
'retained output',
|
|
1952
|
+
);
|
|
1953
|
+
} else {
|
|
1954
|
+
ensureOperationDirectory(path.dirname(target));
|
|
1955
|
+
}
|
|
1956
|
+
const role = options.provenance === 'independent-occupant'
|
|
1957
|
+
? 'occupant'
|
|
1958
|
+
: 'carried';
|
|
1959
|
+
const sealed = sealRetainedOutputs(
|
|
1960
|
+
plan,
|
|
1961
|
+
retainedTreeOutputs(
|
|
1962
|
+
plan,
|
|
1963
|
+
item,
|
|
1964
|
+
options.lane,
|
|
1965
|
+
source,
|
|
1966
|
+
target,
|
|
1967
|
+
relativePath,
|
|
1968
|
+
role,
|
|
1969
|
+
sourceRelativePath,
|
|
1970
|
+
),
|
|
1971
|
+
options,
|
|
1972
|
+
);
|
|
1973
|
+
const carried = sealed.find((output) => output.relativePath === relativePath);
|
|
1974
|
+
try {
|
|
1975
|
+
exclusiveRename(
|
|
1976
|
+
plan,
|
|
1977
|
+
source,
|
|
1978
|
+
carried.path,
|
|
1979
|
+
`retain-install-${token}`,
|
|
1980
|
+
sourceIdentity,
|
|
1981
|
+
options.targetParentIdentity
|
|
1982
|
+
? { targetParentIdentity: options.targetParentIdentity }
|
|
1983
|
+
: {},
|
|
1984
|
+
);
|
|
1985
|
+
} catch (error) {
|
|
1986
|
+
if (options.exclusive === true && exists(target)) {
|
|
1987
|
+
throw retainedRetry(
|
|
1988
|
+
`workspace tree retained target became occupied: ${target}`,
|
|
1989
|
+
'retained-target-occupied',
|
|
1990
|
+
);
|
|
1991
|
+
}
|
|
1992
|
+
throw error;
|
|
1993
|
+
}
|
|
1994
|
+
if (typeof options.afterOutputMove === 'function') {
|
|
1995
|
+
options.afterOutputMove([carried], plan);
|
|
1996
|
+
}
|
|
1997
|
+
return [carried];
|
|
1998
|
+
}
|
|
1999
|
+
if (options.exclusive === true) {
|
|
2000
|
+
throw retainedRetry(
|
|
2001
|
+
`workspace tree retained target is occupied: ${target}`,
|
|
2002
|
+
'retained-target-occupied',
|
|
2003
|
+
);
|
|
2004
|
+
}
|
|
2005
|
+
const sourceStat = fs.lstatSync(source);
|
|
2006
|
+
const targetStat = fs.lstatSync(target);
|
|
2007
|
+
if (sourceStat.isDirectory() && targetStat.isDirectory()) {
|
|
2008
|
+
const retained = sealRetainedOutputs(
|
|
2009
|
+
plan,
|
|
2010
|
+
retainedTreeOutputs(
|
|
2011
|
+
plan,
|
|
2012
|
+
item,
|
|
2013
|
+
options.lane,
|
|
2014
|
+
target,
|
|
2015
|
+
target,
|
|
2016
|
+
relativePath,
|
|
2017
|
+
'occupant',
|
|
2018
|
+
'',
|
|
2019
|
+
),
|
|
2020
|
+
options,
|
|
2021
|
+
);
|
|
2022
|
+
for (const name of fs.readdirSync(source).sort()) {
|
|
2023
|
+
retained.push(...mergeRetained(
|
|
2024
|
+
plan,
|
|
2025
|
+
item,
|
|
2026
|
+
path.join(source, name),
|
|
2027
|
+
path.join(target, name),
|
|
2028
|
+
token,
|
|
2029
|
+
options,
|
|
2030
|
+
relativePath ? `${relativePath}/${name}` : name,
|
|
2031
|
+
));
|
|
2032
|
+
}
|
|
2033
|
+
removeEmptyDirectoryTree(source);
|
|
2034
|
+
return retained;
|
|
2035
|
+
}
|
|
2036
|
+
const outputId = retainedOutputId(
|
|
2037
|
+
plan,
|
|
2038
|
+
item,
|
|
2039
|
+
options.lane,
|
|
2040
|
+
relativePath,
|
|
2041
|
+
'carried',
|
|
2042
|
+
);
|
|
2043
|
+
const existing = (plan.retainedOutputs || []).find(
|
|
2044
|
+
(output) => output.outputId === outputId,
|
|
2045
|
+
);
|
|
2046
|
+
const conflict = existing?.path || retainedConflictPath(target, token);
|
|
2047
|
+
const sourceRole = options.provenance === 'independent-occupant'
|
|
2048
|
+
? 'occupant'
|
|
2049
|
+
: 'carried';
|
|
2050
|
+
const outputs = sealRetainedOutputs(plan, [
|
|
2051
|
+
...retainedTreeOutputs(
|
|
2052
|
+
plan,
|
|
2053
|
+
item,
|
|
2054
|
+
options.lane,
|
|
2055
|
+
target,
|
|
2056
|
+
target,
|
|
2057
|
+
relativePath,
|
|
2058
|
+
'occupant',
|
|
2059
|
+
'',
|
|
2060
|
+
),
|
|
2061
|
+
...retainedTreeOutputs(
|
|
2062
|
+
plan,
|
|
2063
|
+
item,
|
|
2064
|
+
options.lane,
|
|
2065
|
+
source,
|
|
2066
|
+
conflict,
|
|
2067
|
+
relativePath,
|
|
2068
|
+
sourceRole,
|
|
2069
|
+
sourceRelativePath,
|
|
2070
|
+
),
|
|
2071
|
+
], options);
|
|
2072
|
+
const carriedRoot = outputs.find((output) => (
|
|
2073
|
+
output.path === conflict
|
|
2074
|
+
&& output.provenance === (
|
|
2075
|
+
sourceRole === 'carried' ? 'carried-source' : 'independent-occupant'
|
|
2076
|
+
)
|
|
2077
|
+
));
|
|
2078
|
+
exclusiveRename(
|
|
2079
|
+
plan,
|
|
2080
|
+
source,
|
|
2081
|
+
carriedRoot.path,
|
|
2082
|
+
`retain-conflict-${token}`,
|
|
2083
|
+
sourceIdentity,
|
|
2084
|
+
);
|
|
2085
|
+
if (typeof options.afterOutputMove === 'function') {
|
|
2086
|
+
options.afterOutputMove(outputs, plan);
|
|
2087
|
+
}
|
|
2088
|
+
return outputs;
|
|
2089
|
+
}
|
|
2090
|
+
|
|
2091
|
+
function retireRemoval(plan, item, options = {}) {
|
|
2092
|
+
const staged = stagedPath(plan, 'removed', item.nodeId);
|
|
2093
|
+
const source = item.source || absolute(plan, item.from);
|
|
2094
|
+
const target = item.retainTarget || source;
|
|
2095
|
+
if (
|
|
2096
|
+
item.retainTargetExclusive === true
|
|
2097
|
+
&& exists(target)
|
|
2098
|
+
&& (exists(staged) || (source !== target && exists(source)))
|
|
2099
|
+
) {
|
|
2100
|
+
throw retainedRetry(
|
|
2101
|
+
`workspace tree retained target is occupied: ${target}`,
|
|
2102
|
+
'retained-target-occupied',
|
|
2103
|
+
);
|
|
2104
|
+
}
|
|
2105
|
+
if (!item.retirement || !Array.isArray(item.retirement.entries)) {
|
|
2106
|
+
throw new Error(`workspace tree removal ${item.nodeId} has no leaf retirement proof`);
|
|
2107
|
+
}
|
|
2108
|
+
for (const entry of [...item.retirement.entries].sort((left, right) => (
|
|
2109
|
+
right.relativePath.split('/').length - left.relativePath.split('/').length
|
|
2110
|
+
))) {
|
|
2111
|
+
retireLeaf(plan, item, entry);
|
|
2112
|
+
}
|
|
2113
|
+
removeEmptyDirectoryTree(staged);
|
|
2114
|
+
const token = crypto.createHash('sha256')
|
|
2115
|
+
.update(
|
|
2116
|
+
`${plan.snapshotChecksum || plan.mutationId}\0${item.nodeId}`,
|
|
2117
|
+
)
|
|
2118
|
+
.digest('hex')
|
|
2119
|
+
.slice(0, 12);
|
|
2120
|
+
const retainedOutputs = [
|
|
2121
|
+
...mergeRetained(plan, item, staged, target, token, {
|
|
2122
|
+
...options,
|
|
2123
|
+
exclusive: item.retainTargetExclusive === true,
|
|
2124
|
+
targetParentIdentity: item.retainTargetParentIdentity || null,
|
|
2125
|
+
lane: 'staged',
|
|
2126
|
+
provenance: 'carried-source',
|
|
2127
|
+
}),
|
|
2128
|
+
...(source === target ? [] : mergeRetained(plan, item, source, target, token, {
|
|
2129
|
+
...options,
|
|
2130
|
+
exclusive: item.retainTargetExclusive === true,
|
|
2131
|
+
targetParentIdentity: item.retainTargetParentIdentity || null,
|
|
2132
|
+
lane: 'visible',
|
|
2133
|
+
provenance: 'independent-occupant',
|
|
2134
|
+
})),
|
|
2135
|
+
];
|
|
2136
|
+
const existingOutputs = (plan.retainedOutputs || []).filter(
|
|
2137
|
+
(output) => output.removalNodeId === item.nodeId,
|
|
2138
|
+
);
|
|
2139
|
+
if (
|
|
2140
|
+
source === target
|
|
2141
|
+
&& exists(target)
|
|
2142
|
+
&& ![...retainedOutputs, ...existingOutputs].some(
|
|
2143
|
+
(output) => output.path === target,
|
|
2144
|
+
)
|
|
2145
|
+
) {
|
|
2146
|
+
retainedOutputs.push(...sealRetainedOutputs(
|
|
2147
|
+
plan,
|
|
2148
|
+
retainedTreeOutputs(
|
|
2149
|
+
plan,
|
|
2150
|
+
item,
|
|
2151
|
+
'visible',
|
|
2152
|
+
target,
|
|
2153
|
+
target,
|
|
2154
|
+
'',
|
|
2155
|
+
'occupant',
|
|
2156
|
+
),
|
|
2157
|
+
options,
|
|
2158
|
+
));
|
|
2159
|
+
}
|
|
2160
|
+
const allOutputs = Array.from(new Map(
|
|
2161
|
+
[...existingOutputs, ...retainedOutputs]
|
|
2162
|
+
.map((output) => [output.outputId, output]),
|
|
2163
|
+
).values());
|
|
2164
|
+
return {
|
|
2165
|
+
nodeId: item.nodeId,
|
|
2166
|
+
target,
|
|
2167
|
+
retainedPaths: Array.from(new Set(allOutputs.map((output) => output.path))),
|
|
2168
|
+
retainedOutputs: allOutputs,
|
|
2169
|
+
retained: allOutputs.length > 0,
|
|
2170
|
+
};
|
|
2171
|
+
}
|
|
2172
|
+
|
|
2173
|
+
function cleanupStagingRoot(plan) {
|
|
2174
|
+
const root = stagingRoot(plan);
|
|
2175
|
+
if (!exists(root)) return;
|
|
2176
|
+
for (const lane of ['creates', 'moves', 'move-successors', 'removed']) {
|
|
2177
|
+
const target = path.join(root, lane);
|
|
2178
|
+
if (!removeEmptyDirectoryTree(target)) {
|
|
2179
|
+
throw new Error(`workspace tree staging lane still contains bytes: ${target}`);
|
|
2180
|
+
}
|
|
2181
|
+
}
|
|
2182
|
+
// Receipts remain until every byte lane is durably empty. A crash during
|
|
2183
|
+
// cleanup can therefore rerun classification; it can never strand staged
|
|
2184
|
+
// bytes after deleting the evidence that owns them.
|
|
2185
|
+
const receipts = path.join(root, 'receipts');
|
|
2186
|
+
if (exists(receipts)) fs.rmSync(receipts, { recursive: true, force: true });
|
|
2187
|
+
if (!removeEmptyDirectoryTree(root)) {
|
|
2188
|
+
throw new Error(`workspace tree staging still contains unclassified bytes: ${root}`);
|
|
2189
|
+
}
|
|
2190
|
+
syncDirectory(path.dirname(plan.rootPath));
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
function initialCreateRetirement(action, receipt) {
|
|
2194
|
+
const content = action.kind === 'symlink'
|
|
2195
|
+
? Buffer.from(action.symlinkTarget)
|
|
2196
|
+
: Buffer.alloc(0);
|
|
2197
|
+
return {
|
|
2198
|
+
entries: [{
|
|
2199
|
+
relativePath: '',
|
|
2200
|
+
identity: {
|
|
2201
|
+
kind: action.kind,
|
|
2202
|
+
exec: false,
|
|
2203
|
+
bytes: content.length,
|
|
2204
|
+
sha256: crypto.createHash('sha256').update(content).digest('hex'),
|
|
2205
|
+
...((action.kind === 'file' || action.kind === 'symlink') ? {
|
|
2206
|
+
dev: receipt.dev,
|
|
2207
|
+
ino: receipt.ino,
|
|
2208
|
+
} : {}),
|
|
2209
|
+
},
|
|
2210
|
+
}],
|
|
2211
|
+
directories: [],
|
|
2212
|
+
};
|
|
2213
|
+
}
|
|
2214
|
+
|
|
2215
|
+
function rollbackCreatedNode(plan, action, testHooks = {}, outputOptions = {}) {
|
|
2216
|
+
const receipt = readReceipt(plan, 'creates', action.nodeId);
|
|
2217
|
+
if (!receipt) return;
|
|
2218
|
+
const prepared = stagedPath(plan, 'creates', action.nodeId);
|
|
2219
|
+
const target = absolute(plan, action.to);
|
|
2220
|
+
const cancelId = `cancel-${action.nodeId}`;
|
|
2221
|
+
const cancelled = stagedPath(plan, 'removed', cancelId);
|
|
2222
|
+
if (exists(cancelled) && !ownedIdentity(cancelled, receipt)) {
|
|
2223
|
+
retainPrivatePath(
|
|
2224
|
+
plan,
|
|
2225
|
+
cancelled,
|
|
2226
|
+
target,
|
|
2227
|
+
`rollback-create-unexpected-${action.nodeId}`,
|
|
2228
|
+
{
|
|
2229
|
+
item: { nodeId: `rollback-create-unexpected-${action.nodeId}` },
|
|
2230
|
+
lane: 'rollback-create-unexpected',
|
|
2231
|
+
outputOptions,
|
|
2232
|
+
},
|
|
2233
|
+
);
|
|
2234
|
+
}
|
|
2235
|
+
if (exists(prepared) && !ownedIdentity(prepared, receipt)) {
|
|
2236
|
+
retainPrivatePath(
|
|
2237
|
+
plan,
|
|
2238
|
+
prepared,
|
|
2239
|
+
target,
|
|
2240
|
+
`rollback-create-prepared-${action.nodeId}`,
|
|
2241
|
+
{
|
|
2242
|
+
item: { nodeId: `rollback-create-prepared-${action.nodeId}` },
|
|
2243
|
+
lane: 'rollback-create-prepared',
|
|
2244
|
+
outputOptions,
|
|
2245
|
+
},
|
|
2246
|
+
);
|
|
2247
|
+
}
|
|
2248
|
+
if (ownedIdentity(prepared, receipt) && !ownedIdentity(cancelled, receipt)) {
|
|
2249
|
+
ensureDirectory(plan, path.dirname(cancelled));
|
|
2250
|
+
writeReceipt(plan, 'rollback-create', action.nodeId, receipt);
|
|
2251
|
+
exclusiveRename(
|
|
2252
|
+
plan,
|
|
2253
|
+
prepared,
|
|
2254
|
+
cancelled,
|
|
2255
|
+
`rollback-create-prepared-${action.nodeId}`,
|
|
2256
|
+
receipt,
|
|
2257
|
+
);
|
|
2258
|
+
}
|
|
2259
|
+
if (
|
|
2260
|
+
!ownedIdentity(cancelled, receipt)
|
|
2261
|
+
&& readReceipt(plan, 'create-installing', action.nodeId)
|
|
2262
|
+
&& ownedIdentity(target, receipt)
|
|
2263
|
+
) {
|
|
2264
|
+
if (typeof testHooks.beforeRollbackCreatedFolderRetire === 'function') {
|
|
2265
|
+
testHooks.beforeRollbackCreatedFolderRetire(target, action);
|
|
2266
|
+
}
|
|
2267
|
+
ensureDirectory(plan, path.dirname(cancelled));
|
|
2268
|
+
writeReceipt(plan, 'rollback-create', action.nodeId, receipt);
|
|
2269
|
+
exclusiveRename(
|
|
2270
|
+
plan,
|
|
2271
|
+
target,
|
|
2272
|
+
cancelled,
|
|
2273
|
+
`rollback-create-installed-${action.nodeId}`,
|
|
2274
|
+
receipt,
|
|
2275
|
+
{
|
|
2276
|
+
sourceParentIdentity: plannedParentIdentity(
|
|
2277
|
+
plan,
|
|
2278
|
+
action.targetParentNodeId,
|
|
2279
|
+
'target',
|
|
2280
|
+
),
|
|
2281
|
+
allowChanged: true,
|
|
2282
|
+
testHooks: testHooks.rollbackCreatedRename || {},
|
|
2283
|
+
},
|
|
2284
|
+
);
|
|
2285
|
+
}
|
|
2286
|
+
if (exists(cancelled) && !ownedIdentity(cancelled, receipt)) {
|
|
2287
|
+
retainPrivatePath(
|
|
2288
|
+
plan,
|
|
2289
|
+
cancelled,
|
|
2290
|
+
target,
|
|
2291
|
+
`rollback-create-raced-${action.nodeId}`,
|
|
2292
|
+
{
|
|
2293
|
+
item: { nodeId: `rollback-create-raced-${action.nodeId}` },
|
|
2294
|
+
lane: 'rollback-create-raced',
|
|
2295
|
+
outputOptions,
|
|
2296
|
+
},
|
|
2297
|
+
);
|
|
2298
|
+
}
|
|
2299
|
+
if (!ownedIdentity(cancelled, receipt)) return;
|
|
2300
|
+
retireRemoval(plan, {
|
|
2301
|
+
nodeId: cancelId,
|
|
2302
|
+
source: target,
|
|
2303
|
+
retainTarget: target,
|
|
2304
|
+
retirement: initialCreateRetirement(action, receipt),
|
|
2305
|
+
}, outputOptions);
|
|
2306
|
+
}
|
|
2307
|
+
|
|
2308
|
+
function moveAside(plan, target, token, options = {}) {
|
|
2309
|
+
if (!options.item || !options.lane) {
|
|
2310
|
+
if (!exists(target)) return null;
|
|
2311
|
+
const conflict = retainedConflictPath(target, token);
|
|
2312
|
+
exclusiveRename(plan, target, conflict, `move-aside-${token}`);
|
|
2313
|
+
return conflict;
|
|
2314
|
+
}
|
|
2315
|
+
const existingOutputs = (plan.retainedOutputs || [])
|
|
2316
|
+
.filter((output) => (
|
|
2317
|
+
output.removalNodeId === options.item.nodeId
|
|
2318
|
+
&& output.lane === options.lane
|
|
2319
|
+
&& output.provenance === 'independent-occupant'
|
|
2320
|
+
))
|
|
2321
|
+
.sort((left, right) => left.outputId.localeCompare(right.outputId));
|
|
2322
|
+
if (!exists(target)) {
|
|
2323
|
+
const retained = existingOutputs.filter((output) => (
|
|
2324
|
+
ownedIdentity(output.path, output.identity)
|
|
2325
|
+
));
|
|
2326
|
+
return retained.length === 1 ? retained[0].path : null;
|
|
2327
|
+
}
|
|
2328
|
+
const targetIdentity = identity(target, { deep: false });
|
|
2329
|
+
const relativePath = (
|
|
2330
|
+
`entry-${targetIdentity.kind}-${targetIdentity.dev}-${targetIdentity.ino}`
|
|
2331
|
+
);
|
|
2332
|
+
const outputId = retainedOutputId(
|
|
2333
|
+
plan,
|
|
2334
|
+
options.item,
|
|
2335
|
+
options.lane,
|
|
2336
|
+
relativePath,
|
|
2337
|
+
'occupant',
|
|
2338
|
+
);
|
|
2339
|
+
const existing = existingOutputs.find((output) => output.outputId === outputId);
|
|
2340
|
+
const conflict = existing?.path || retainedConflictPath(target, token);
|
|
2341
|
+
const retained = mergeRetained(
|
|
2342
|
+
plan,
|
|
2343
|
+
options.item,
|
|
2344
|
+
target,
|
|
2345
|
+
conflict,
|
|
2346
|
+
token,
|
|
2347
|
+
{
|
|
2348
|
+
...(options.outputOptions || {}),
|
|
2349
|
+
lane: options.lane,
|
|
2350
|
+
provenance: 'independent-occupant',
|
|
2351
|
+
exclusive: true,
|
|
2352
|
+
},
|
|
2353
|
+
relativePath,
|
|
2354
|
+
);
|
|
2355
|
+
return retained.find((output) => output.outputId === outputId)?.path || conflict;
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
function moveHeldAside(plan, held, basisTarget, token) {
|
|
2359
|
+
if (!exists(held)) return null;
|
|
2360
|
+
if (held === basisTarget) return moveAside(plan, held, token);
|
|
2361
|
+
const conflict = retainedConflictPath(basisTarget, token);
|
|
2362
|
+
requireExistingDirectory(plan, path.dirname(conflict), token);
|
|
2363
|
+
exclusiveRename(plan, held, conflict, `move-held-aside-${token}`);
|
|
2364
|
+
return conflict;
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
function duplicateRetirementId(plan, token) {
|
|
2368
|
+
return crypto.createHash('sha256')
|
|
2369
|
+
.update(
|
|
2370
|
+
`${plan.contract}\0duplicate-entry\0${plan.resourceId}\0`
|
|
2371
|
+
+ `${plan.snapshotVersion ?? plan.version}\0`
|
|
2372
|
+
+ `${plan.snapshotChecksum || plan.mutationId}\0${token}`,
|
|
2373
|
+
)
|
|
2374
|
+
.digest('hex');
|
|
2375
|
+
}
|
|
2376
|
+
|
|
2377
|
+
function duplicateScratchPath(plan, held, token) {
|
|
2378
|
+
return path.join(
|
|
2379
|
+
path.dirname(held),
|
|
2380
|
+
`.amalgm-apply-${duplicateRetirementId(plan, token)}`,
|
|
2381
|
+
);
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
function retireDuplicateEntry(plan, held, linkedTarget, receipt, token) {
|
|
2385
|
+
const id = duplicateRetirementId(plan, token);
|
|
2386
|
+
const scratch = duplicateScratchPath(plan, held, token);
|
|
2387
|
+
const scratchSource = path.join(scratch, 'source');
|
|
2388
|
+
const identitySource = ownedIdentity(held, receipt)
|
|
2389
|
+
? held
|
|
2390
|
+
: ownedIdentity(linkedTarget, receipt)
|
|
2391
|
+
? linkedTarget
|
|
2392
|
+
: ownedIdentity(scratchSource, receipt)
|
|
2393
|
+
? scratchSource
|
|
2394
|
+
: null;
|
|
2395
|
+
let expected = readReceipt(plan, 'duplicate', id);
|
|
2396
|
+
if (!expected) {
|
|
2397
|
+
if (!identitySource) return false;
|
|
2398
|
+
expected = writeReceipt(
|
|
2399
|
+
plan,
|
|
2400
|
+
'duplicate',
|
|
2401
|
+
id,
|
|
2402
|
+
nativeByteIdentity(identitySource),
|
|
2403
|
+
);
|
|
2404
|
+
}
|
|
2405
|
+
const root = fs.realpathSync(path.dirname(held));
|
|
2406
|
+
const rootStat = fs.lstatSync(root, { bigint: true });
|
|
2407
|
+
const transaction = {
|
|
2408
|
+
root,
|
|
2409
|
+
rootDev: rootStat.dev.toString(),
|
|
2410
|
+
rootIno: rootStat.ino.toString(),
|
|
2411
|
+
path: path.basename(held),
|
|
2412
|
+
id,
|
|
2413
|
+
operation: `workspace-tree-duplicate-entry:${plan.resourceId}:${token}`,
|
|
2414
|
+
expected,
|
|
2415
|
+
target: null,
|
|
2416
|
+
};
|
|
2417
|
+
if (!exists(held) && !exists(scratch)) return true;
|
|
2418
|
+
const result = nativeFileTransaction.run(
|
|
2419
|
+
transaction,
|
|
2420
|
+
exists(scratch) ? 'recover' : 'apply',
|
|
2421
|
+
);
|
|
2422
|
+
if (result.status === 'changed') return !exists(held) && !exists(scratch);
|
|
2423
|
+
if (result.status !== 'applied') {
|
|
2424
|
+
throw new Error(
|
|
2425
|
+
`workspace tree duplicate entry stayed ${result.status}: `
|
|
2426
|
+
+ `${result.reason || 'no reason'}`,
|
|
2427
|
+
);
|
|
2428
|
+
}
|
|
2429
|
+
const released = nativeFileTransaction.run(transaction, 'release');
|
|
2430
|
+
if (released.status !== 'released') {
|
|
2431
|
+
throw new Error(
|
|
2432
|
+
`workspace tree duplicate entry receipt stayed ${released.status}: `
|
|
2433
|
+
+ `${released.reason || 'no reason'}`,
|
|
2434
|
+
);
|
|
2435
|
+
}
|
|
2436
|
+
return true;
|
|
2437
|
+
}
|
|
2438
|
+
|
|
2439
|
+
function installOwnedPath(plan, held, target, receipt, token, options = {}) {
|
|
2440
|
+
if (options.targetParentIdentity) {
|
|
2441
|
+
requireOwnedDirectory(
|
|
2442
|
+
path.dirname(target),
|
|
2443
|
+
options.targetParentIdentity,
|
|
2444
|
+
'install',
|
|
2445
|
+
);
|
|
2446
|
+
}
|
|
2447
|
+
if (exists(duplicateScratchPath(plan, held, token))) {
|
|
2448
|
+
if (!retireDuplicateEntry(plan, held, target, receipt, token)) {
|
|
2449
|
+
throw new Error(`workspace tree duplicate recovery changed for ${token}`);
|
|
2450
|
+
}
|
|
2451
|
+
if (!ownedIdentity(target, receipt) && !ownedIdentity(held, receipt)) {
|
|
2452
|
+
const error = sourceRace(
|
|
2453
|
+
`workspace tree target changed after duplicate extraction for ${token}`,
|
|
2454
|
+
);
|
|
2455
|
+
if (exists(target)) {
|
|
2456
|
+
error.duplicateSuccessor = {
|
|
2457
|
+
path: target,
|
|
2458
|
+
identity: identity(target, { deep: false }),
|
|
2459
|
+
};
|
|
2460
|
+
}
|
|
2461
|
+
throw error;
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
if (ownedIdentity(target, receipt)) {
|
|
2465
|
+
// File identity names an inode, not one directory entry. A local hardlink
|
|
2466
|
+
// may have restored the source while the operation-owned staging name
|
|
2467
|
+
// still references the same inode. Consume only that staging entry; the
|
|
2468
|
+
// destination keeps the bytes and UUID, and cleanup cannot wedge.
|
|
2469
|
+
if (
|
|
2470
|
+
held !== target
|
|
2471
|
+
&& ownedIdentity(held, receipt)
|
|
2472
|
+
) {
|
|
2473
|
+
const stat = fs.lstatSync(held);
|
|
2474
|
+
if (stat.isDirectory()) {
|
|
2475
|
+
throw new Error(`workspace tree receipt has duplicate directory entries: ${held}`);
|
|
2476
|
+
}
|
|
2477
|
+
if (!retireDuplicateEntry(plan, held, target, receipt, token)) {
|
|
2478
|
+
throw new Error(`workspace tree duplicate entry changed for ${token}`);
|
|
2479
|
+
}
|
|
2480
|
+
}
|
|
2481
|
+
return true;
|
|
2482
|
+
}
|
|
2483
|
+
if (!ownedIdentity(held, receipt)) return false;
|
|
2484
|
+
if (exists(target)) {
|
|
2485
|
+
moveAside(
|
|
2486
|
+
plan,
|
|
2487
|
+
target,
|
|
2488
|
+
`${token}-collision`,
|
|
2489
|
+
options.retainedCollision || {},
|
|
2490
|
+
);
|
|
2491
|
+
}
|
|
2492
|
+
if (options.operationParent === true) {
|
|
2493
|
+
requireOwnedDirectory(
|
|
2494
|
+
path.dirname(target),
|
|
2495
|
+
options.targetParentIdentity,
|
|
2496
|
+
'operation install',
|
|
2497
|
+
);
|
|
2498
|
+
} else {
|
|
2499
|
+
requireExistingDirectory(plan, path.dirname(target), token);
|
|
2500
|
+
}
|
|
2501
|
+
exclusiveRename(
|
|
2502
|
+
plan,
|
|
2503
|
+
held,
|
|
2504
|
+
target,
|
|
2505
|
+
`install-owned-${token}`,
|
|
2506
|
+
receipt,
|
|
2507
|
+
{
|
|
2508
|
+
...(options.sourceParentIdentity
|
|
2509
|
+
? { sourceParentIdentity: options.sourceParentIdentity }
|
|
2510
|
+
: {}),
|
|
2511
|
+
...(options.targetParentIdentity
|
|
2512
|
+
? { targetParentIdentity: options.targetParentIdentity }
|
|
2513
|
+
: {}),
|
|
2514
|
+
...(options.testHooks ? { testHooks: options.testHooks } : {}),
|
|
2515
|
+
},
|
|
2516
|
+
);
|
|
2517
|
+
return true;
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
function receiptIdentityKey(value) {
|
|
2521
|
+
return value ? `${value.kind}\0${value.dev}\0${value.ino}` : null;
|
|
2522
|
+
}
|
|
2523
|
+
|
|
2524
|
+
function indexProjectedBindingLocations(plan, options = {}) {
|
|
2525
|
+
const bindings = [
|
|
2526
|
+
...(plan.rollbackBindings || []),
|
|
2527
|
+
...(plan.projectedBindings || []),
|
|
2528
|
+
];
|
|
2529
|
+
const wanted = new Set(
|
|
2530
|
+
[
|
|
2531
|
+
...bindings.map((item) => item.identity),
|
|
2532
|
+
...(options.identities || []),
|
|
2533
|
+
]
|
|
2534
|
+
.map(receiptIdentityKey)
|
|
2535
|
+
.filter(Boolean),
|
|
2536
|
+
);
|
|
2537
|
+
const locations = new Map();
|
|
2538
|
+
const visit = (directory, parentRelativePath) => {
|
|
2539
|
+
let names;
|
|
2540
|
+
try {
|
|
2541
|
+
names = fs.readdirSync(directory).sort();
|
|
2542
|
+
} catch (error) {
|
|
2543
|
+
if (isPathAbsentError(error)) return;
|
|
2544
|
+
throw error;
|
|
2545
|
+
}
|
|
2546
|
+
for (const name of names) {
|
|
2547
|
+
const relativePath = parentRelativePath
|
|
2548
|
+
? `${parentRelativePath}/${name}`
|
|
2549
|
+
: name;
|
|
2550
|
+
if (!shouldEnroll(relativePath)) continue;
|
|
2551
|
+
const candidate = path.join(directory, name);
|
|
2552
|
+
if (typeof options.onVisit === 'function') {
|
|
2553
|
+
options.onVisit(candidate, relativePath);
|
|
2554
|
+
}
|
|
2555
|
+
let stat;
|
|
2556
|
+
try {
|
|
2557
|
+
stat = fs.lstatSync(candidate, { bigint: true });
|
|
2558
|
+
} catch (error) {
|
|
2559
|
+
if (isPathAbsentError(error)) continue;
|
|
2560
|
+
throw error;
|
|
2561
|
+
}
|
|
2562
|
+
const kind = stat.isSymbolicLink()
|
|
2563
|
+
? 'symlink'
|
|
2564
|
+
: stat.isDirectory()
|
|
2565
|
+
? 'folder'
|
|
2566
|
+
: stat.isFile()
|
|
2567
|
+
? 'file'
|
|
2568
|
+
: 'other';
|
|
2569
|
+
const key = `${kind}\0${stat.dev}\0${stat.ino}`;
|
|
2570
|
+
if (wanted.has(key)) {
|
|
2571
|
+
const held = locations.get(key) || [];
|
|
2572
|
+
held.push(candidate);
|
|
2573
|
+
locations.set(key, held);
|
|
2574
|
+
}
|
|
2575
|
+
if (
|
|
2576
|
+
kind === 'folder'
|
|
2577
|
+
&& !exists(path.join(candidate, '.git'))
|
|
2578
|
+
) {
|
|
2579
|
+
visit(candidate, relativePath);
|
|
2580
|
+
}
|
|
2581
|
+
}
|
|
2582
|
+
};
|
|
2583
|
+
visit(plan.rootPath, '');
|
|
2584
|
+
return locations;
|
|
2585
|
+
}
|
|
2586
|
+
|
|
2587
|
+
function locateOperationBindings(plan, bindings) {
|
|
2588
|
+
const values = Array.isArray(bindings) ? bindings : [];
|
|
2589
|
+
const indexed = indexProjectedBindingLocations(plan, {
|
|
2590
|
+
identities: values.map((binding) => binding.identity).filter(Boolean),
|
|
2591
|
+
});
|
|
2592
|
+
return new Map(values.map((binding) => {
|
|
2593
|
+
const candidates = Array.from(new Set([
|
|
2594
|
+
binding.path,
|
|
2595
|
+
...(indexed.get(receiptIdentityKey(binding.identity)) || []),
|
|
2596
|
+
].filter(Boolean))).filter((candidate) => (
|
|
2597
|
+
ownedIdentity(candidate, binding.identity)
|
|
2598
|
+
));
|
|
2599
|
+
return [binding.nodeId, candidates];
|
|
2600
|
+
}));
|
|
2601
|
+
}
|
|
2602
|
+
|
|
2603
|
+
function moveRecoveryContext(plan, options = {}) {
|
|
2604
|
+
const bindingsById = new Map(
|
|
2605
|
+
[
|
|
2606
|
+
...(plan.rollbackBindings || []),
|
|
2607
|
+
...(plan.projectedBindings || []),
|
|
2608
|
+
].map((item) => [item.nodeId, item]),
|
|
2609
|
+
);
|
|
2610
|
+
const movesById = new Map(
|
|
2611
|
+
(plan.moves || []).map((item) => [item.nodeId, item]),
|
|
2612
|
+
);
|
|
2613
|
+
let indexed = indexProjectedBindingLocations(plan, options);
|
|
2614
|
+
let refreshed = false;
|
|
2615
|
+
const indexedMatches = (expected) => {
|
|
2616
|
+
const candidates = () => indexed.get(receiptIdentityKey(expected)) || [];
|
|
2617
|
+
const find = () => candidates()
|
|
2618
|
+
.filter((candidate) => ownedIdentity(candidate, expected));
|
|
2619
|
+
let matches = find();
|
|
2620
|
+
if (matches.length) return matches;
|
|
2621
|
+
// The first unresolved identity seals one fresh, shared absence scan for
|
|
2622
|
+
// the whole classification phase. Every later absence is answered from
|
|
2623
|
+
// that same observation. If an entry named by the sealed scan moves
|
|
2624
|
+
// again, abort this bounded attempt rather than treating stale cache as
|
|
2625
|
+
// permission to adopt an unrelated endpoint.
|
|
2626
|
+
if (!refreshed) {
|
|
2627
|
+
indexed = indexProjectedBindingLocations(plan, options);
|
|
2628
|
+
refreshed = true;
|
|
2629
|
+
matches = find();
|
|
2630
|
+
}
|
|
2631
|
+
if (!matches.length && refreshed && candidates().length > 0) {
|
|
2632
|
+
throw sourceRace('workspace tree recovery changed after its sealed scan');
|
|
2633
|
+
}
|
|
2634
|
+
return matches;
|
|
2635
|
+
};
|
|
2636
|
+
const location = (binding) => {
|
|
2637
|
+
if (!binding) return null;
|
|
2638
|
+
const move = movesById.get(binding.nodeId);
|
|
2639
|
+
const expected = move
|
|
2640
|
+
? readReceipt(plan, 'moves', binding.nodeId) || binding.identity
|
|
2641
|
+
: binding.identity;
|
|
2642
|
+
const exactCandidates = move
|
|
2643
|
+
? [
|
|
2644
|
+
stagedPath(plan, 'moves', move.nodeId),
|
|
2645
|
+
absolute(plan, move.to),
|
|
2646
|
+
absolute(plan, move.from),
|
|
2647
|
+
absolute(plan, binding.to),
|
|
2648
|
+
absolute(plan, binding.from),
|
|
2649
|
+
]
|
|
2650
|
+
: [
|
|
2651
|
+
absolute(plan, binding.to),
|
|
2652
|
+
absolute(plan, binding.from),
|
|
2653
|
+
];
|
|
2654
|
+
const exact = exactCandidates.find(
|
|
2655
|
+
(candidate) => ownedIdentity(candidate, expected),
|
|
2656
|
+
);
|
|
2657
|
+
if (exact) return exact;
|
|
2658
|
+
const matches = indexedMatches(expected);
|
|
2659
|
+
// Multiple hardlink names may make the final UUID path ambiguous, but
|
|
2660
|
+
// they still prove the original inode exists. Never turn ambiguity into
|
|
2661
|
+
// permission to adopt an unrelated stale target as the successor.
|
|
2662
|
+
return matches[0] || null;
|
|
2663
|
+
};
|
|
2664
|
+
return {
|
|
2665
|
+
ownedLocation(item) {
|
|
2666
|
+
return location(bindingsById.get(item.nodeId));
|
|
2667
|
+
},
|
|
2668
|
+
targetCarriedLocation(item) {
|
|
2669
|
+
const carrier = bindingsById.get(item.targetCarrierNodeId);
|
|
2670
|
+
const carrierLocation = location(carrier);
|
|
2671
|
+
if (!carrierLocation || !item.targetCarrierSuffix) return null;
|
|
2672
|
+
const candidate = path.join(
|
|
2673
|
+
carrierLocation,
|
|
2674
|
+
...item.targetCarrierSuffix.split('/'),
|
|
2675
|
+
);
|
|
2676
|
+
const receipt = readReceipt(plan, 'moves', item.nodeId);
|
|
2677
|
+
return ownedIdentity(candidate, receipt) ? candidate : null;
|
|
2678
|
+
},
|
|
2679
|
+
sourcePath(item) {
|
|
2680
|
+
if (
|
|
2681
|
+
!item.sourceParentNodeId
|
|
2682
|
+
|| item.sourceParentNodeId === plan.resourceId
|
|
2683
|
+
) {
|
|
2684
|
+
return path.join(plan.rootPath, path.basename(item.from));
|
|
2685
|
+
}
|
|
2686
|
+
const directParent = bindingsById.get(item.sourceParentNodeId);
|
|
2687
|
+
const directParentLocation = location(directParent);
|
|
2688
|
+
if (directParentLocation) {
|
|
2689
|
+
return path.join(directParentLocation, path.basename(item.from));
|
|
2690
|
+
}
|
|
2691
|
+
const carrier = bindingsById.get(item.sourceCarrierNodeId);
|
|
2692
|
+
const carrierLocation = location(carrier);
|
|
2693
|
+
if (!carrierLocation || !item.sourceCarrierSuffix) {
|
|
2694
|
+
return absolute(plan, item.from);
|
|
2695
|
+
}
|
|
2696
|
+
return path.join(
|
|
2697
|
+
carrierLocation,
|
|
2698
|
+
...item.sourceCarrierSuffix.split('/'),
|
|
2699
|
+
);
|
|
2700
|
+
},
|
|
2701
|
+
sourceParentIdentity(item) {
|
|
2702
|
+
if (
|
|
2703
|
+
!item.sourceParentNodeId
|
|
2704
|
+
|| item.sourceParentNodeId === plan.resourceId
|
|
2705
|
+
) return plan.rootIdentity;
|
|
2706
|
+
return bindingsById.get(item.sourceParentNodeId)?.identity || null;
|
|
2707
|
+
},
|
|
2708
|
+
};
|
|
2709
|
+
}
|
|
2710
|
+
|
|
2711
|
+
function reclassifyUnexpectedMoveStage(plan, item, staged, receipt) {
|
|
2712
|
+
if (!exists(staged) || ownedIdentity(staged, receipt)) return false;
|
|
2713
|
+
const successorStaged = stagedPath(plan, 'move-successors', item.nodeId);
|
|
2714
|
+
if (exists(successorStaged)) {
|
|
2715
|
+
throw new Error(`workspace tree move has two unclassified successors for ${item.nodeId}`);
|
|
2716
|
+
}
|
|
2717
|
+
exclusiveRename(
|
|
2718
|
+
plan,
|
|
2719
|
+
staged,
|
|
2720
|
+
successorStaged,
|
|
2721
|
+
`rollback-raced-successor-${item.nodeId}`,
|
|
2722
|
+
);
|
|
2723
|
+
writeReceipt(
|
|
2724
|
+
plan,
|
|
2725
|
+
'move-successor',
|
|
2726
|
+
item.nodeId,
|
|
2727
|
+
identity(successorStaged, { deep: false }),
|
|
2728
|
+
);
|
|
2729
|
+
return true;
|
|
2730
|
+
}
|
|
2731
|
+
|
|
2732
|
+
function extractRollbackMovedNode(plan, item, context) {
|
|
2733
|
+
const receipt = readReceipt(plan, 'moves', item.nodeId);
|
|
2734
|
+
if (!receipt) return false;
|
|
2735
|
+
const staged = stagedPath(plan, 'moves', item.nodeId);
|
|
2736
|
+
const target = absolute(plan, item.to);
|
|
2737
|
+
const source = absolute(plan, item.from);
|
|
2738
|
+
if (exists(staged)) {
|
|
2739
|
+
if (ownedIdentity(staged, receipt)) return false;
|
|
2740
|
+
reclassifyUnexpectedMoveStage(plan, item, staged, receipt);
|
|
2741
|
+
}
|
|
2742
|
+
const crossedLocation = ownedIdentity(target, receipt)
|
|
2743
|
+
? target
|
|
2744
|
+
: context.targetCarriedLocation(item);
|
|
2745
|
+
if (crossedLocation) {
|
|
2746
|
+
ensureDirectory(plan, path.dirname(staged));
|
|
2747
|
+
exclusiveRename(
|
|
2748
|
+
plan,
|
|
2749
|
+
crossedLocation,
|
|
2750
|
+
staged,
|
|
2751
|
+
`rollback-move-extract-${item.nodeId}`,
|
|
2752
|
+
receipt,
|
|
2753
|
+
);
|
|
2754
|
+
if (ownedIdentity(staged, receipt)) return false;
|
|
2755
|
+
reclassifyUnexpectedMoveStage(plan, item, staged, receipt);
|
|
2756
|
+
}
|
|
2757
|
+
// The official inode may already have returned to its old path. Anything
|
|
2758
|
+
// now at the stale target is then an independent local node, not a
|
|
2759
|
+
// successor of this UUID, and must remain for ordinary tree observation.
|
|
2760
|
+
if (ownedIdentity(source, receipt)) return false;
|
|
2761
|
+
// The original receipt-owned node may itself have been moved to a third
|
|
2762
|
+
// local path. That is local overlay, and it proves any unrelated value at
|
|
2763
|
+
// the stale target cannot be this UUID's successor.
|
|
2764
|
+
if (context.ownedLocation(item)) return false;
|
|
2765
|
+
|
|
2766
|
+
// The receipt-owned node crossed and was then replaced at its new path.
|
|
2767
|
+
// That third value is local work whose UUID ownership still needs
|
|
2768
|
+
// classification. Move it to operation-owned staging before extracting any
|
|
2769
|
+
// ancestor; restore/capture later only if its kind and receipt history prove
|
|
2770
|
+
// it is the original UUID's successor.
|
|
2771
|
+
const successorStaged = stagedPath(plan, 'move-successors', item.nodeId);
|
|
2772
|
+
let successorReceipt = readReceipt(plan, 'move-successor', item.nodeId);
|
|
2773
|
+
if (exists(successorStaged)) {
|
|
2774
|
+
if (!successorReceipt) {
|
|
2775
|
+
successorReceipt = writeReceipt(
|
|
2776
|
+
plan,
|
|
2777
|
+
'move-successor',
|
|
2778
|
+
item.nodeId,
|
|
2779
|
+
identity(successorStaged, { deep: false }),
|
|
2780
|
+
);
|
|
2781
|
+
}
|
|
2782
|
+
if (!ownedIdentity(successorStaged, successorReceipt)) {
|
|
2783
|
+
throw new Error(`workspace tree move successor changed for ${item.nodeId}`);
|
|
2784
|
+
}
|
|
2785
|
+
return true;
|
|
2786
|
+
}
|
|
2787
|
+
if (successorReceipt && ownedIdentity(source, successorReceipt)) return true;
|
|
2788
|
+
if (successorReceipt) return false;
|
|
2789
|
+
if (!exists(target)) return false;
|
|
2790
|
+
ensureDirectory(plan, path.dirname(successorStaged));
|
|
2791
|
+
exclusiveRename(
|
|
2792
|
+
plan,
|
|
2793
|
+
target,
|
|
2794
|
+
successorStaged,
|
|
2795
|
+
`rollback-successor-stage-${item.nodeId}`,
|
|
2796
|
+
);
|
|
2797
|
+
writeReceipt(
|
|
2798
|
+
plan,
|
|
2799
|
+
'move-successor',
|
|
2800
|
+
item.nodeId,
|
|
2801
|
+
identity(successorStaged, { deep: false }),
|
|
2802
|
+
);
|
|
2803
|
+
return true;
|
|
2804
|
+
}
|
|
2805
|
+
|
|
2806
|
+
function sameMoveSuccessorKind(item, receipt, held) {
|
|
2807
|
+
const expectedKind = item.kind || item.identity?.kind;
|
|
2808
|
+
if (!receipt || receipt.kind !== expectedKind) return false;
|
|
2809
|
+
if (expectedKind !== 'symlink') return true;
|
|
2810
|
+
try {
|
|
2811
|
+
return fs.readlinkSync(held) === item.symlinkTarget;
|
|
2812
|
+
} catch {
|
|
2813
|
+
return false;
|
|
2814
|
+
}
|
|
2815
|
+
}
|
|
2816
|
+
|
|
2817
|
+
function restoreRollbackMovedNode(plan, item, context) {
|
|
2818
|
+
const receipt = readReceipt(plan, 'moves', item.nodeId);
|
|
2819
|
+
const staged = stagedPath(plan, 'moves', item.nodeId);
|
|
2820
|
+
const source = context.sourcePath(item);
|
|
2821
|
+
const token = crypto.createHash('sha256')
|
|
2822
|
+
.update(`${plan.snapshotChecksum}\0rollback-move\0${item.nodeId}`)
|
|
2823
|
+
.digest('hex')
|
|
2824
|
+
.slice(0, 12);
|
|
2825
|
+
const sourceParentIdentity = context.sourceParentIdentity(item);
|
|
2826
|
+
let officialAtSource = false;
|
|
2827
|
+
if (
|
|
2828
|
+
sourceParentIdentity
|
|
2829
|
+
&& ownedIdentity(path.dirname(source), sourceParentIdentity)
|
|
2830
|
+
) {
|
|
2831
|
+
try {
|
|
2832
|
+
officialAtSource = installOwnedPath(
|
|
2833
|
+
plan,
|
|
2834
|
+
staged,
|
|
2835
|
+
source,
|
|
2836
|
+
receipt,
|
|
2837
|
+
`${token}-official`,
|
|
2838
|
+
{ targetParentIdentity: sourceParentIdentity },
|
|
2839
|
+
);
|
|
2840
|
+
} catch (error) {
|
|
2841
|
+
if (!error?.duplicateSuccessor || error.duplicateSuccessor.path !== source) {
|
|
2842
|
+
throw error;
|
|
2843
|
+
}
|
|
2844
|
+
writeReceipt(
|
|
2845
|
+
plan,
|
|
2846
|
+
'move-successor',
|
|
2847
|
+
item.nodeId,
|
|
2848
|
+
error.duplicateSuccessor.identity,
|
|
2849
|
+
);
|
|
2850
|
+
return true;
|
|
2851
|
+
}
|
|
2852
|
+
}
|
|
2853
|
+
|
|
2854
|
+
const successorReceipt = readReceipt(plan, 'move-successor', item.nodeId);
|
|
2855
|
+
const successorStaged = stagedPath(plan, 'move-successors', item.nodeId);
|
|
2856
|
+
const successorHeld = ownedIdentity(successorStaged, successorReceipt)
|
|
2857
|
+
? successorStaged
|
|
2858
|
+
: ownedIdentity(source, successorReceipt)
|
|
2859
|
+
? source
|
|
2860
|
+
: null;
|
|
2861
|
+
if (!successorHeld) return false;
|
|
2862
|
+
|
|
2863
|
+
if (!officialAtSource && context.ownedLocation(item)) {
|
|
2864
|
+
// The original receipt inode survived at another local path while a
|
|
2865
|
+
// checked source raced into successor staging. Put that independent value
|
|
2866
|
+
// back at its stale-target address (or retain it beside the old source if
|
|
2867
|
+
// its parent disappeared); it cannot inherit the original UUID.
|
|
2868
|
+
const independentTarget = absolute(plan, item.to);
|
|
2869
|
+
if (exists(path.dirname(independentTarget))) {
|
|
2870
|
+
installOwnedPath(
|
|
2871
|
+
plan,
|
|
2872
|
+
successorHeld,
|
|
2873
|
+
independentTarget,
|
|
2874
|
+
successorReceipt,
|
|
2875
|
+
`${token}-independent-target`,
|
|
2876
|
+
);
|
|
2877
|
+
} else {
|
|
2878
|
+
moveHeldAside(
|
|
2879
|
+
plan,
|
|
2880
|
+
successorHeld,
|
|
2881
|
+
source,
|
|
2882
|
+
`${token}-independent-target`,
|
|
2883
|
+
);
|
|
2884
|
+
}
|
|
2885
|
+
return false;
|
|
2886
|
+
}
|
|
2887
|
+
|
|
2888
|
+
if (
|
|
2889
|
+
!officialAtSource
|
|
2890
|
+
&& sameMoveSuccessorKind(item, successorReceipt, successorHeld)
|
|
2891
|
+
) {
|
|
2892
|
+
// The true same-kind successor wins the UUID path. An independently
|
|
2893
|
+
// recreated old source is moved aside and will receive a fresh UUID from
|
|
2894
|
+
// the observer; it must never steal the existing document channel.
|
|
2895
|
+
return installOwnedPath(
|
|
2896
|
+
plan,
|
|
2897
|
+
successorHeld,
|
|
2898
|
+
source,
|
|
2899
|
+
successorReceipt,
|
|
2900
|
+
`${token}-successor`,
|
|
2901
|
+
);
|
|
2902
|
+
}
|
|
2903
|
+
|
|
2904
|
+
// A kind-changing replacement cannot inherit the original UUID. Nor can a
|
|
2905
|
+
// stale target after the receipt-owned node already returned to its source.
|
|
2906
|
+
// Preserve either as independent local structure at a deterministic
|
|
2907
|
+
// conflict path. The observer will trash the absent old UUID when needed
|
|
2908
|
+
// and birth the independent node separately.
|
|
2909
|
+
if (!officialAtSource && exists(source) && source !== successorHeld) {
|
|
2910
|
+
moveAside(plan, source, `${token}-independent-source`);
|
|
2911
|
+
}
|
|
2912
|
+
moveHeldAside(
|
|
2913
|
+
plan,
|
|
2914
|
+
successorHeld,
|
|
2915
|
+
source,
|
|
2916
|
+
`${token}-independent-target`,
|
|
2917
|
+
);
|
|
2918
|
+
return false;
|
|
2919
|
+
}
|
|
2920
|
+
|
|
2921
|
+
function rollbackRemovedNode(plan, item, outputOptions = {}) {
|
|
2922
|
+
const receipt = readReceipt(plan, 'removed', item.nodeId);
|
|
2923
|
+
const staged = stagedPath(plan, 'removed', item.nodeId);
|
|
2924
|
+
if (!ownedIdentity(staged, receipt)) return;
|
|
2925
|
+
mergeRetained(
|
|
2926
|
+
plan,
|
|
2927
|
+
item,
|
|
2928
|
+
staged,
|
|
2929
|
+
absolute(plan, item.from),
|
|
2930
|
+
crypto.createHash('sha256')
|
|
2931
|
+
.update(`${plan.snapshotChecksum}\0rollback-remove\0${item.nodeId}`)
|
|
2932
|
+
.digest('hex')
|
|
2933
|
+
.slice(0, 12),
|
|
2934
|
+
{
|
|
2935
|
+
...outputOptions,
|
|
2936
|
+
lane: 'rollback',
|
|
2937
|
+
provenance: 'carried-source',
|
|
2938
|
+
},
|
|
2939
|
+
);
|
|
2940
|
+
}
|
|
2941
|
+
|
|
2942
|
+
function structuralActions(plan, reverse = false) {
|
|
2943
|
+
const actions = [
|
|
2944
|
+
...plan.creates
|
|
2945
|
+
.filter((candidate) => candidate.kind === 'folder')
|
|
2946
|
+
.map((item) => ({ type: 'create', item, target: item.to })),
|
|
2947
|
+
...plan.moves.map((item) => ({ type: 'move', item, target: item.to })),
|
|
2948
|
+
].sort((left, right) => (
|
|
2949
|
+
left.target.split('/').length - right.target.split('/').length
|
|
2950
|
+
|| left.target.localeCompare(right.target)
|
|
2951
|
+
|| left.item.nodeId.localeCompare(right.item.nodeId)
|
|
2952
|
+
));
|
|
2953
|
+
return reverse ? actions.reverse() : actions;
|
|
2954
|
+
}
|
|
2955
|
+
|
|
2956
|
+
function sourceRestorations(plan) {
|
|
2957
|
+
return [
|
|
2958
|
+
...plan.removals.map((item) => ({ type: 'remove', item, source: item.from })),
|
|
2959
|
+
...plan.moves.map((item) => ({ type: 'move', item, source: item.from })),
|
|
2960
|
+
].sort((left, right) => (
|
|
2961
|
+
left.source.split('/').length - right.source.split('/').length
|
|
2962
|
+
|| left.source.localeCompare(right.source)
|
|
2963
|
+
|| left.item.nodeId.localeCompare(right.item.nodeId)
|
|
2964
|
+
));
|
|
2965
|
+
}
|
|
2966
|
+
|
|
2967
|
+
/**
|
|
2968
|
+
* A newer permission epoch may supersede a snapshot after the older plan has
|
|
2969
|
+
* staged bytes but before it earned a database projection. Restore the exact
|
|
2970
|
+
* prior floor from operation receipts, preserve any newer writer, then remove
|
|
2971
|
+
* the obsolete plan. No stale authority is ever projected merely to make
|
|
2972
|
+
* recovery convenient.
|
|
2973
|
+
*/
|
|
2974
|
+
function supersedeUncommitted(held, database = openLocalDb(), options = {}) {
|
|
2975
|
+
let current = get(held.resourceId, held.snapshotVersion, database);
|
|
2976
|
+
if (!current) return true;
|
|
2977
|
+
if (current.state === 'committed') return false;
|
|
2978
|
+
let plan = current.plan;
|
|
2979
|
+
const outputOptions = {
|
|
2980
|
+
durable: true,
|
|
2981
|
+
persistPlan(nextPlan) {
|
|
2982
|
+
current = updateUncommittedPlan(current, nextPlan, database);
|
|
2983
|
+
plan = current.plan;
|
|
2984
|
+
},
|
|
2985
|
+
afterOutputReceipt: options.testHooks?.afterRetainedOutputReceipt,
|
|
2986
|
+
afterOutputMove: options.testHooks?.afterRetainedOutputMove,
|
|
2987
|
+
};
|
|
2988
|
+
const moveContext = moveRecoveryContext(plan, {
|
|
2989
|
+
onVisit: options.testHooks?.onRecoveryIndexVisit,
|
|
2990
|
+
});
|
|
2991
|
+
const creates = [...plan.creates].sort((left, right) => (
|
|
2992
|
+
right.to.split('/').length - left.to.split('/').length
|
|
2993
|
+
));
|
|
2994
|
+
const moveSuccessors = new Set();
|
|
2995
|
+
// Leaves cross after every structural action, so they retire first. Folders
|
|
2996
|
+
// and moves share the after-tree dependency graph. Reverse that one graph
|
|
2997
|
+
// deepest-first so every created or moved descendant leaves before its
|
|
2998
|
+
// parent, then rebuild the before tree parent-first from the same receipts.
|
|
2999
|
+
// This remains valid when both a parent and descendant moved independently.
|
|
3000
|
+
for (const action of creates.filter((candidate) => candidate.kind !== 'folder')) {
|
|
3001
|
+
rollbackCreatedNode(plan, action, options.testHooks, outputOptions);
|
|
3002
|
+
}
|
|
3003
|
+
for (const action of structuralActions(plan, true)) {
|
|
3004
|
+
if (action.type === 'create') {
|
|
3005
|
+
rollbackCreatedNode(plan, action.item, options.testHooks, outputOptions);
|
|
3006
|
+
}
|
|
3007
|
+
else extractRollbackMovedNode(plan, action.item, moveContext);
|
|
3008
|
+
}
|
|
3009
|
+
if (options.testHooks?.crashAfterRollbackExtraction === true) {
|
|
3010
|
+
throw new Error('simulated workspace tree crash after rollback extraction');
|
|
3011
|
+
}
|
|
3012
|
+
let restorationCount = 0;
|
|
3013
|
+
for (const action of sourceRestorations(plan)) {
|
|
3014
|
+
if (action.type === 'move') {
|
|
3015
|
+
if (restoreRollbackMovedNode(plan, action.item, moveContext)) {
|
|
3016
|
+
moveSuccessors.add(action.item.nodeId);
|
|
3017
|
+
}
|
|
3018
|
+
}
|
|
3019
|
+
else rollbackRemovedNode(plan, action.item, outputOptions);
|
|
3020
|
+
restorationCount += 1;
|
|
3021
|
+
if (
|
|
3022
|
+
restorationCount === Number(
|
|
3023
|
+
options.testHooks?.crashAfterRollbackRestorationCount,
|
|
3024
|
+
)
|
|
3025
|
+
) {
|
|
3026
|
+
throw new Error('simulated workspace tree crash during rollback restoration');
|
|
3027
|
+
}
|
|
3028
|
+
}
|
|
3029
|
+
if (options.testHooks?.crashAfterRollbackRestore === true) {
|
|
3030
|
+
throw new Error('simulated workspace tree crash after rollback restore');
|
|
3031
|
+
}
|
|
3032
|
+
const capturedBindings = new Map();
|
|
3033
|
+
for (const item of plan.moves) {
|
|
3034
|
+
const receipt = readReceipt(plan, 'moves', item.nodeId);
|
|
3035
|
+
const location = moveContext.ownedLocation(item);
|
|
3036
|
+
if (receipt && location && ownedIdentity(location, receipt)) {
|
|
3037
|
+
capturedBindings.set(item.nodeId, {
|
|
3038
|
+
nodeId: item.nodeId,
|
|
3039
|
+
identity: receipt,
|
|
3040
|
+
});
|
|
3041
|
+
}
|
|
3042
|
+
if (!moveSuccessors.has(item.nodeId)) continue;
|
|
3043
|
+
const successorReceipt = readReceipt(plan, 'move-successor', item.nodeId);
|
|
3044
|
+
const source = moveContext.sourcePath(item);
|
|
3045
|
+
if (
|
|
3046
|
+
successorReceipt
|
|
3047
|
+
&& source
|
|
3048
|
+
&& ownedIdentity(source, successorReceipt)
|
|
3049
|
+
) {
|
|
3050
|
+
capturedBindings.set(item.nodeId, {
|
|
3051
|
+
nodeId: item.nodeId,
|
|
3052
|
+
identity: successorReceipt,
|
|
3053
|
+
});
|
|
3054
|
+
}
|
|
3055
|
+
}
|
|
3056
|
+
if (capturedBindings.size) {
|
|
3057
|
+
const values = Array.from(capturedBindings.values());
|
|
3058
|
+
const now = new Date().toISOString();
|
|
3059
|
+
database.transaction(() => {
|
|
3060
|
+
for (const binding of values) {
|
|
3061
|
+
database.prepare(`
|
|
3062
|
+
UPDATE workspace_nodes
|
|
3063
|
+
SET device_id = ?, inode = ?, missing_at = NULL, updated_at = ?
|
|
3064
|
+
WHERE workspace_id = ? AND id = ?
|
|
3065
|
+
`).run(
|
|
3066
|
+
String(binding.identity.dev),
|
|
3067
|
+
String(binding.identity.ino),
|
|
3068
|
+
now,
|
|
3069
|
+
plan.resourceId,
|
|
3070
|
+
binding.nodeId,
|
|
3071
|
+
);
|
|
3072
|
+
}
|
|
3073
|
+
})();
|
|
3074
|
+
require('../state/docs').captureDocsByNodeIds(
|
|
3075
|
+
new Set(values.map((binding) => binding.nodeId)),
|
|
3076
|
+
{ database },
|
|
3077
|
+
);
|
|
3078
|
+
if (typeof options.captureSuccessors === 'function') {
|
|
3079
|
+
options.captureSuccessors(values, plan);
|
|
3080
|
+
}
|
|
3081
|
+
}
|
|
3082
|
+
if (options.testHooks?.crashAfterRollbackSuccessorCapture === true) {
|
|
3083
|
+
throw new Error('simulated workspace tree crash after rollback successor capture');
|
|
3084
|
+
}
|
|
3085
|
+
if (
|
|
3086
|
+
Array.isArray(plan.retainedOutputs)
|
|
3087
|
+
&& plan.retainedOutputs.length
|
|
3088
|
+
&& typeof options.captureRetainedOutputs === 'function'
|
|
3089
|
+
) {
|
|
3090
|
+
options.captureRetainedOutputs(plan.retainedOutputs, plan);
|
|
3091
|
+
}
|
|
3092
|
+
cleanupStagingRoot(plan);
|
|
3093
|
+
const result = database.prepare(`
|
|
3094
|
+
DELETE FROM workspace_tree_materializations
|
|
3095
|
+
WHERE resource_id = ?
|
|
3096
|
+
AND snapshot_version = ?
|
|
3097
|
+
AND materialization_state != 'committed'
|
|
3098
|
+
`).run(current.resourceId, current.snapshotVersion);
|
|
3099
|
+
return result.changes === 1;
|
|
3100
|
+
}
|
|
3101
|
+
|
|
3102
|
+
function retireWithIncrementalCapture(plan, items, options, retire) {
|
|
3103
|
+
const retained = [];
|
|
3104
|
+
for (const item of items) {
|
|
3105
|
+
const result = retire(item);
|
|
3106
|
+
if (!result.retained) continue;
|
|
3107
|
+
retained.push(result);
|
|
3108
|
+
if (typeof options.retain === 'function') {
|
|
3109
|
+
options.retain([result], plan);
|
|
3110
|
+
}
|
|
3111
|
+
if (typeof options.afterRetain === 'function') {
|
|
3112
|
+
options.afterRetain([result], plan);
|
|
3113
|
+
}
|
|
3114
|
+
}
|
|
3115
|
+
return retained;
|
|
3116
|
+
}
|
|
3117
|
+
|
|
3118
|
+
function finishOperation(held, database = openLocalDb(), options = {}) {
|
|
3119
|
+
let current = getOperation(held.resourceId, held.mutationId, database);
|
|
3120
|
+
if (!current) return;
|
|
3121
|
+
if (current.state !== 'committed') {
|
|
3122
|
+
throw new Error(`workspace tree operation ${held.mutationId} has not committed`);
|
|
3123
|
+
}
|
|
3124
|
+
const retained = retireWithIncrementalCapture(
|
|
3125
|
+
current.plan,
|
|
3126
|
+
current.plan.actions.filter((action) => action.type === 'remove'),
|
|
3127
|
+
options,
|
|
3128
|
+
(action) => retireRemoval(current.plan, action, {
|
|
3129
|
+
durable: true,
|
|
3130
|
+
persistPlan(plan) {
|
|
3131
|
+
current = updateCommittedPlan(current, plan, database);
|
|
3132
|
+
},
|
|
3133
|
+
afterOutputReceipt: options.afterOutputReceipt,
|
|
3134
|
+
afterOutputMove: options.afterOutputMove,
|
|
3135
|
+
}),
|
|
3136
|
+
);
|
|
3137
|
+
cleanupStagingRoot(current.plan);
|
|
3138
|
+
database.transaction(() => {
|
|
3139
|
+
const deleted = database.prepare(`
|
|
3140
|
+
DELETE FROM workspace_tree_operation_materializations
|
|
3141
|
+
WHERE resource_id = ? AND mutation_id = ?
|
|
3142
|
+
AND materialization_state = 'committed'
|
|
3143
|
+
`).run(held.resourceId, held.mutationId);
|
|
3144
|
+
if (deleted.changes !== 1) {
|
|
3145
|
+
throw new Error(
|
|
3146
|
+
`workspace tree operation ${held.mutationId} lost its control record`,
|
|
3147
|
+
);
|
|
3148
|
+
}
|
|
3149
|
+
if (typeof options.finalizeControlState === 'function') {
|
|
3150
|
+
options.finalizeControlState(current.plan);
|
|
3151
|
+
}
|
|
3152
|
+
})();
|
|
3153
|
+
return { retained };
|
|
3154
|
+
}
|
|
3155
|
+
|
|
3156
|
+
function stagedPath(plan, lane, nodeId) {
|
|
3157
|
+
return path.join(stagingRoot(plan), lane, nodeId);
|
|
3158
|
+
}
|
|
3159
|
+
|
|
3160
|
+
function absolute(plan, relative) {
|
|
3161
|
+
return path.join(plan.rootPath, ...String(relative || '').split('/').filter(Boolean));
|
|
3162
|
+
}
|
|
3163
|
+
|
|
3164
|
+
function pathInside(candidate, root) {
|
|
3165
|
+
const held = path.resolve(candidate);
|
|
3166
|
+
const boundary = path.resolve(root);
|
|
3167
|
+
return held === boundary || held.startsWith(`${boundary}${path.sep}`);
|
|
3168
|
+
}
|
|
3169
|
+
|
|
3170
|
+
function approvedRenameRoot(plan, candidate) {
|
|
3171
|
+
const roots = [
|
|
3172
|
+
plan.rootPath,
|
|
3173
|
+
stagingRoot(plan),
|
|
3174
|
+
...(Array.isArray(plan.controlRoots) ? plan.controlRoots : []),
|
|
3175
|
+
].map((root) => path.resolve(root));
|
|
3176
|
+
return roots
|
|
3177
|
+
.filter((root) => pathInside(candidate, root))
|
|
3178
|
+
.sort((left, right) => right.length - left.length)[0] || null;
|
|
3179
|
+
}
|
|
3180
|
+
|
|
3181
|
+
function exclusiveRename(
|
|
3182
|
+
plan,
|
|
3183
|
+
source,
|
|
3184
|
+
target,
|
|
3185
|
+
operation,
|
|
3186
|
+
expectedSource = null,
|
|
3187
|
+
options = {},
|
|
3188
|
+
) {
|
|
3189
|
+
const canonicalEntry = (candidate) => path.join(
|
|
3190
|
+
fs.realpathSync(path.dirname(candidate)),
|
|
3191
|
+
path.basename(candidate),
|
|
3192
|
+
);
|
|
3193
|
+
const sourceRoleRoot = approvedRenameRoot(plan, source);
|
|
3194
|
+
const targetRoleRoot = approvedRenameRoot(plan, target);
|
|
3195
|
+
if (!sourceRoleRoot || !targetRoleRoot) {
|
|
3196
|
+
throw parentRace(
|
|
3197
|
+
`workspace tree rename has an unapproved endpoint: ${source} -> ${target}`,
|
|
3198
|
+
);
|
|
3199
|
+
}
|
|
3200
|
+
const sourceCanonical = canonicalEntry(source);
|
|
3201
|
+
const targetCanonical = canonicalEntry(target);
|
|
3202
|
+
const canonicalRoleRoot = (root) => (
|
|
3203
|
+
exists(root)
|
|
3204
|
+
? fs.realpathSync(root)
|
|
3205
|
+
: path.join(fs.realpathSync(path.dirname(root)), path.basename(root))
|
|
3206
|
+
);
|
|
3207
|
+
if (
|
|
3208
|
+
!pathInside(sourceCanonical, canonicalRoleRoot(sourceRoleRoot))
|
|
3209
|
+
|| !pathInside(targetCanonical, canonicalRoleRoot(targetRoleRoot))
|
|
3210
|
+
) {
|
|
3211
|
+
throw parentRace(
|
|
3212
|
+
`workspace tree rename endpoint escaped its physical owner: ${source} -> ${target}`,
|
|
3213
|
+
);
|
|
3214
|
+
}
|
|
3215
|
+
let boundary = path.dirname(sourceCanonical);
|
|
3216
|
+
const targetParent = path.dirname(targetCanonical);
|
|
3217
|
+
while (
|
|
3218
|
+
targetParent !== boundary
|
|
3219
|
+
&& !targetParent.startsWith(`${boundary}${path.sep}`)
|
|
3220
|
+
) {
|
|
3221
|
+
const parent = path.dirname(boundary);
|
|
3222
|
+
if (parent === boundary) {
|
|
3223
|
+
throw new Error(
|
|
3224
|
+
`workspace tree rename has no safe shared boundary: ${source} -> ${target}`,
|
|
3225
|
+
);
|
|
3226
|
+
}
|
|
3227
|
+
boundary = parent;
|
|
3228
|
+
}
|
|
3229
|
+
const boundaryStat = fs.lstatSync(boundary, { bigint: true });
|
|
3230
|
+
if (!boundaryStat.isDirectory()) {
|
|
3231
|
+
throw new Error(`workspace tree rename boundary is not a directory: ${boundary}`);
|
|
3232
|
+
}
|
|
3233
|
+
const relative = (candidate) => {
|
|
3234
|
+
const value = path.relative(boundary, candidate);
|
|
3235
|
+
if (!value || value.startsWith('..') || path.isAbsolute(value)) {
|
|
3236
|
+
throw new Error(`workspace tree rename escapes its boundary: ${candidate}`);
|
|
3237
|
+
}
|
|
3238
|
+
return value.split(path.sep).join('/');
|
|
3239
|
+
};
|
|
3240
|
+
const sourceRelative = relative(sourceCanonical);
|
|
3241
|
+
const targetRelative = relative(targetCanonical);
|
|
3242
|
+
const expected = expectedSource || identity(sourceCanonical, { deep: false });
|
|
3243
|
+
if (!expected?.kind || !expected?.dev || !expected?.ino) {
|
|
3244
|
+
throw new Error(`workspace tree rename has no source identity: ${source}`);
|
|
3245
|
+
}
|
|
3246
|
+
const sourceParentIdentity = options.sourceParentIdentity
|
|
3247
|
+
|| identity(path.dirname(sourceCanonical), { deep: false });
|
|
3248
|
+
const targetParentIdentity = options.targetParentIdentity
|
|
3249
|
+
|| identity(path.dirname(targetCanonical), { deep: false });
|
|
3250
|
+
if (
|
|
3251
|
+
sourceParentIdentity.kind !== 'folder'
|
|
3252
|
+
|| targetParentIdentity.kind !== 'folder'
|
|
3253
|
+
) {
|
|
3254
|
+
throw new Error(`workspace tree rename has a non-folder parent: ${source} -> ${target}`);
|
|
3255
|
+
}
|
|
3256
|
+
const renameReceiptId = crypto.createHash('sha256')
|
|
3257
|
+
.update(`${operation}\0${path.resolve(source)}\0${path.resolve(target)}`)
|
|
3258
|
+
.digest('hex');
|
|
3259
|
+
writeReceipt(plan, 'rename', renameReceiptId, {
|
|
3260
|
+
source: path.resolve(source),
|
|
3261
|
+
target: path.resolve(target),
|
|
3262
|
+
sourceIdentity: expected,
|
|
3263
|
+
sourceParentIdentity,
|
|
3264
|
+
targetParentIdentity,
|
|
3265
|
+
});
|
|
3266
|
+
if (ownedIdentity(targetCanonical, expected)) {
|
|
3267
|
+
return { status: 'applied', recovered: true };
|
|
3268
|
+
}
|
|
3269
|
+
if (!ownedIdentity(sourceCanonical, expected)) {
|
|
3270
|
+
const result = {
|
|
3271
|
+
status: 'changed',
|
|
3272
|
+
reason: 'source changed before exclusive move',
|
|
3273
|
+
};
|
|
3274
|
+
if (options.allowChanged === true) return result;
|
|
3275
|
+
throw new Error(
|
|
3276
|
+
`workspace tree changed before exclusive rename ${operation}: ${result.reason}`,
|
|
3277
|
+
);
|
|
3278
|
+
}
|
|
3279
|
+
const id = crypto.createHash('sha256')
|
|
3280
|
+
.update(
|
|
3281
|
+
`${plan.contract || CONTRACT}\0${plan.resourceId}\0`
|
|
3282
|
+
+ `${plan.snapshotVersion ?? plan.version}\0`
|
|
3283
|
+
+ `${plan.snapshotChecksum || plan.mutationId}\0`
|
|
3284
|
+
+ `${operation}\0${sourceRelative}\0${targetRelative}`,
|
|
3285
|
+
)
|
|
3286
|
+
.digest('hex');
|
|
3287
|
+
const result = nativeFileTransaction.moveNoReplace({
|
|
3288
|
+
root: boundary,
|
|
3289
|
+
rootDev: boundaryStat.dev.toString(),
|
|
3290
|
+
rootIno: boundaryStat.ino.toString(),
|
|
3291
|
+
source: sourceRelative,
|
|
3292
|
+
sourceIdentity: {
|
|
3293
|
+
kind: expected.kind,
|
|
3294
|
+
dev: expected.dev,
|
|
3295
|
+
ino: expected.ino,
|
|
3296
|
+
},
|
|
3297
|
+
sourceParentIdentity,
|
|
3298
|
+
targetParentIdentity,
|
|
3299
|
+
path: targetRelative,
|
|
3300
|
+
id,
|
|
3301
|
+
operation,
|
|
3302
|
+
}, options.testHooks || {});
|
|
3303
|
+
if (result.status !== 'applied') {
|
|
3304
|
+
if (options.allowChanged === true) return result;
|
|
3305
|
+
if (String(result.reason || '').includes('parent changed')) {
|
|
3306
|
+
throw parentRace(
|
|
3307
|
+
`workspace tree changed before exclusive rename ${operation}: ${result.reason}`,
|
|
3308
|
+
);
|
|
3309
|
+
}
|
|
3310
|
+
throw new Error(
|
|
3311
|
+
`workspace tree changed before exclusive rename ${operation}: ${result.reason}`,
|
|
3312
|
+
);
|
|
3313
|
+
}
|
|
3314
|
+
return result;
|
|
3315
|
+
}
|
|
3316
|
+
|
|
3317
|
+
function syncDirectory(directory) {
|
|
3318
|
+
const descriptor = fs.openSync(directory, fs.constants.O_RDONLY);
|
|
3319
|
+
try {
|
|
3320
|
+
fs.fsyncSync(descriptor);
|
|
3321
|
+
} finally {
|
|
3322
|
+
fs.closeSync(descriptor);
|
|
3323
|
+
}
|
|
3324
|
+
}
|
|
3325
|
+
|
|
3326
|
+
function syncDirectoryChain(plan, directory) {
|
|
3327
|
+
const boundary = path.dirname(plan.rootPath);
|
|
3328
|
+
let current = directory;
|
|
3329
|
+
for (;;) {
|
|
3330
|
+
syncDirectory(current);
|
|
3331
|
+
if (current === boundary) break;
|
|
3332
|
+
const parent = path.dirname(current);
|
|
3333
|
+
if (
|
|
3334
|
+
parent === current
|
|
3335
|
+
|| (current !== plan.rootPath
|
|
3336
|
+
&& !current.startsWith(`${plan.rootPath}${path.sep}`)
|
|
3337
|
+
&& !current.startsWith(`${boundary}${path.sep}`))
|
|
3338
|
+
) {
|
|
3339
|
+
throw new Error(`workspace tree directory escaped its root: ${directory}`);
|
|
3340
|
+
}
|
|
3341
|
+
current = parent;
|
|
3342
|
+
}
|
|
3343
|
+
}
|
|
3344
|
+
|
|
3345
|
+
function ensureDirectory(plan, directory) {
|
|
3346
|
+
ensureDurableDirectory(directory);
|
|
3347
|
+
syncDirectoryChain(plan, directory);
|
|
3348
|
+
}
|
|
3349
|
+
|
|
3350
|
+
function requireExistingDirectory(plan, directory, nodeId) {
|
|
3351
|
+
if (!exists(directory) || !fs.lstatSync(directory).isDirectory()) {
|
|
3352
|
+
throw new Error(`workspace tree target parent is not installed for ${nodeId}`);
|
|
3353
|
+
}
|
|
3354
|
+
syncDirectoryChain(plan, directory);
|
|
3355
|
+
}
|
|
3356
|
+
|
|
3357
|
+
function requireRoot(plan, database = openLocalDb()) {
|
|
3358
|
+
let observed;
|
|
3359
|
+
try {
|
|
3360
|
+
observed = identity(plan.rootPath);
|
|
3361
|
+
} catch (error) {
|
|
3362
|
+
if (!isPathAbsentError(error)) throw error;
|
|
3363
|
+
throw Object.assign(
|
|
3364
|
+
retainedRetry(
|
|
3365
|
+
`workspace tree root is missing: ${plan.rootPath}`,
|
|
3366
|
+
'workspace-root-missing',
|
|
3367
|
+
),
|
|
3368
|
+
{ paths: [plan.rootPath] },
|
|
3369
|
+
);
|
|
3370
|
+
}
|
|
3371
|
+
const bound = database.prepare(`
|
|
3372
|
+
SELECT device_id, inode
|
|
3373
|
+
FROM workspace_nodes
|
|
3374
|
+
WHERE workspace_id = ? AND id = ?
|
|
3375
|
+
`).get(plan.resourceId, plan.resourceId);
|
|
3376
|
+
const bindingMayBeBornByThisPlan = (
|
|
3377
|
+
plan.initial === true
|
|
3378
|
+
&& (!bound || (!bound.device_id && !bound.inode))
|
|
3379
|
+
);
|
|
3380
|
+
if (
|
|
3381
|
+
observed.kind !== 'folder'
|
|
3382
|
+
|| observed.dev !== plan.rootIdentity.dev
|
|
3383
|
+
|| observed.ino !== plan.rootIdentity.ino
|
|
3384
|
+
|| (
|
|
3385
|
+
!bindingMayBeBornByThisPlan
|
|
3386
|
+
&& (
|
|
3387
|
+
!bound?.device_id
|
|
3388
|
+
|| !bound?.inode
|
|
3389
|
+
|| String(bound.device_id) !== String(plan.rootIdentity.dev)
|
|
3390
|
+
|| String(bound.inode) !== String(plan.rootIdentity.ino)
|
|
3391
|
+
)
|
|
3392
|
+
)
|
|
3393
|
+
) {
|
|
3394
|
+
throw Object.assign(
|
|
3395
|
+
retainedRetry(
|
|
3396
|
+
`workspace tree root changed during materialization: ${plan.rootPath}`,
|
|
3397
|
+
'workspace-root-replaced',
|
|
3398
|
+
),
|
|
3399
|
+
{ paths: [plan.rootPath] },
|
|
3400
|
+
);
|
|
3401
|
+
}
|
|
3402
|
+
}
|
|
3403
|
+
|
|
3404
|
+
function stageOne(plan, item, lane, testHooks = {}) {
|
|
3405
|
+
const source = absolute(plan, item.from);
|
|
3406
|
+
const staged = stagedPath(plan, lane, item.nodeId);
|
|
3407
|
+
const target = item.to == null ? null : absolute(plan, item.to);
|
|
3408
|
+
const receipt = readReceipt(plan, lane, item.nodeId);
|
|
3409
|
+
if (target && receipt && ownedIdentity(target, receipt)) return;
|
|
3410
|
+
stageOwnedPath({
|
|
3411
|
+
plan,
|
|
3412
|
+
source,
|
|
3413
|
+
staged,
|
|
3414
|
+
nodeId: item.nodeId,
|
|
3415
|
+
receiptLane: lane,
|
|
3416
|
+
expected: item.identity,
|
|
3417
|
+
sourceParentIdentity: plannedParentIdentity(
|
|
3418
|
+
plan,
|
|
3419
|
+
item.sourceParentNodeId,
|
|
3420
|
+
'source',
|
|
3421
|
+
),
|
|
3422
|
+
operation: `snapshot-stage-${lane}-${item.nodeId}`,
|
|
3423
|
+
renameTestHooks: testHooks.snapshotStageRename || {},
|
|
3424
|
+
});
|
|
3425
|
+
}
|
|
3426
|
+
|
|
3427
|
+
function installMove(plan, item, testHooks = {}) {
|
|
3428
|
+
const staged = stagedPath(plan, 'moves', item.nodeId);
|
|
3429
|
+
const target = absolute(plan, item.to);
|
|
3430
|
+
const receipt = readReceipt(plan, 'moves', item.nodeId);
|
|
3431
|
+
if (!receipt) {
|
|
3432
|
+
throw new Error(`workspace tree move has no operation receipt for ${item.nodeId}`);
|
|
3433
|
+
}
|
|
3434
|
+
if (!exists(staged)) {
|
|
3435
|
+
if (
|
|
3436
|
+
readReceipt(plan, 'move-installing', item.nodeId)
|
|
3437
|
+
&& ownedIdentity(target, receipt)
|
|
3438
|
+
) return;
|
|
3439
|
+
throw new Error(`workspace tree staged move disappeared for ${item.nodeId}`);
|
|
3440
|
+
}
|
|
3441
|
+
if (!ownedIdentity(staged, receipt)) {
|
|
3442
|
+
throw new Error(`workspace tree staged move changed for ${item.nodeId}`);
|
|
3443
|
+
}
|
|
3444
|
+
writeReceipt(plan, 'move-installing', item.nodeId, receipt);
|
|
3445
|
+
if (!installOwnedPath(
|
|
3446
|
+
plan,
|
|
3447
|
+
staged,
|
|
3448
|
+
target,
|
|
3449
|
+
receipt,
|
|
3450
|
+
`snapshot-move-install-${item.nodeId}`,
|
|
3451
|
+
{
|
|
3452
|
+
targetParentIdentity: plannedParentIdentity(
|
|
3453
|
+
plan,
|
|
3454
|
+
item.targetParentNodeId,
|
|
3455
|
+
'target',
|
|
3456
|
+
),
|
|
3457
|
+
testHooks: testHooks.snapshotInstallRename || {},
|
|
3458
|
+
},
|
|
3459
|
+
)) {
|
|
3460
|
+
throw new Error(`workspace tree move lost its receipt for ${item.nodeId}`);
|
|
3461
|
+
}
|
|
3462
|
+
}
|
|
3463
|
+
|
|
3464
|
+
function createProjectedNode(plan, item) {
|
|
3465
|
+
const target = absolute(plan, item.to);
|
|
3466
|
+
const prepared = stagedPath(plan, 'creates', item.nodeId);
|
|
3467
|
+
let receipt = readReceipt(plan, 'creates', item.nodeId);
|
|
3468
|
+
if (!receipt) {
|
|
3469
|
+
if (!exists(prepared)) {
|
|
3470
|
+
createOperationTargetAt(prepared, {
|
|
3471
|
+
nodeId: item.nodeId,
|
|
3472
|
+
kind: item.kind,
|
|
3473
|
+
symlinkTarget: item.symlinkTarget,
|
|
3474
|
+
});
|
|
3475
|
+
}
|
|
3476
|
+
receipt = writeReceipt(
|
|
3477
|
+
plan,
|
|
3478
|
+
'creates',
|
|
3479
|
+
item.nodeId,
|
|
3480
|
+
identity(prepared, { deep: false }),
|
|
3481
|
+
);
|
|
3482
|
+
}
|
|
3483
|
+
if (exists(prepared)) {
|
|
3484
|
+
if (!ownedIdentity(prepared, receipt)) {
|
|
3485
|
+
throw new Error(`workspace tree prepared create changed for ${item.nodeId}`);
|
|
3486
|
+
}
|
|
3487
|
+
writeReceipt(plan, 'create-installing', item.nodeId, receipt);
|
|
3488
|
+
if (!installOwnedPath(
|
|
3489
|
+
plan,
|
|
3490
|
+
prepared,
|
|
3491
|
+
target,
|
|
3492
|
+
receipt,
|
|
3493
|
+
`snapshot-create-install-${item.nodeId}`,
|
|
3494
|
+
{
|
|
3495
|
+
targetParentIdentity: plannedParentIdentity(
|
|
3496
|
+
plan,
|
|
3497
|
+
item.targetParentNodeId,
|
|
3498
|
+
'target',
|
|
3499
|
+
),
|
|
3500
|
+
},
|
|
3501
|
+
)) {
|
|
3502
|
+
throw new Error(`workspace tree create lost its receipt for ${item.nodeId}`);
|
|
3503
|
+
}
|
|
3504
|
+
return true;
|
|
3505
|
+
}
|
|
3506
|
+
if (
|
|
3507
|
+
!readReceipt(plan, 'create-installing', item.nodeId)
|
|
3508
|
+
|| !ownedIdentity(target, receipt)
|
|
3509
|
+
) {
|
|
3510
|
+
throw new Error(`workspace tree create has no crossing receipt for ${item.nodeId}`);
|
|
3511
|
+
}
|
|
3512
|
+
return false;
|
|
3513
|
+
}
|
|
3514
|
+
|
|
3515
|
+
function verify(plan, database = openLocalDb()) {
|
|
3516
|
+
requireRoot(plan, database);
|
|
3517
|
+
const plannedTargets = new Set([
|
|
3518
|
+
...plan.moves.map((item) => item.to),
|
|
3519
|
+
...plan.creates.map((item) => item.to),
|
|
3520
|
+
]);
|
|
3521
|
+
for (const item of plan.moves) {
|
|
3522
|
+
if (!ownedIdentity(
|
|
3523
|
+
absolute(plan, item.to),
|
|
3524
|
+
readReceipt(plan, 'moves', item.nodeId),
|
|
3525
|
+
)) {
|
|
3526
|
+
throw new Error(`workspace tree move did not verify for ${item.nodeId}`);
|
|
3527
|
+
}
|
|
3528
|
+
}
|
|
3529
|
+
for (const item of plan.removals) {
|
|
3530
|
+
const source = absolute(plan, item.from);
|
|
3531
|
+
if (exists(source) && !plannedTargets.has(item.from)) {
|
|
3532
|
+
throw new Error(`workspace tree removal did not verify for ${item.nodeId}`);
|
|
3533
|
+
}
|
|
3534
|
+
if (!ownedIdentity(
|
|
3535
|
+
stagedPath(plan, 'removed', item.nodeId),
|
|
3536
|
+
readReceipt(plan, 'removed', item.nodeId),
|
|
3537
|
+
)) {
|
|
3538
|
+
throw new Error(`workspace tree removal receipt did not verify for ${item.nodeId}`);
|
|
3539
|
+
}
|
|
3540
|
+
}
|
|
3541
|
+
for (const item of plan.creates) {
|
|
3542
|
+
const target = absolute(plan, item.to);
|
|
3543
|
+
if (!ownedIdentity(
|
|
3544
|
+
target,
|
|
3545
|
+
readReceipt(plan, 'creates', item.nodeId),
|
|
3546
|
+
)) {
|
|
3547
|
+
throw new Error(`workspace tree create did not verify for ${item.nodeId}`);
|
|
3548
|
+
}
|
|
3549
|
+
if (item.kind === 'symlink' && fs.readlinkSync(target) !== item.symlinkTarget) {
|
|
3550
|
+
throw new Error(`workspace tree symlink did not verify for ${item.nodeId}`);
|
|
3551
|
+
}
|
|
3552
|
+
}
|
|
3553
|
+
if (!Array.isArray(plan.projectedBindings)) {
|
|
3554
|
+
throw new Error('workspace tree snapshot has no complete projected-node proof');
|
|
3555
|
+
}
|
|
3556
|
+
for (const item of plan.projectedBindings) {
|
|
3557
|
+
const target = absolute(plan, item.to);
|
|
3558
|
+
if (!ownedIdentity(target, item.identity)) {
|
|
3559
|
+
throw new Error(`workspace tree projected node did not verify for ${item.nodeId}`);
|
|
3560
|
+
}
|
|
3561
|
+
if (item.kind === 'symlink' && fs.readlinkSync(target) !== item.symlinkTarget) {
|
|
3562
|
+
throw new Error(`workspace tree projected symlink did not verify for ${item.nodeId}`);
|
|
3563
|
+
}
|
|
3564
|
+
}
|
|
3565
|
+
}
|
|
3566
|
+
|
|
3567
|
+
function cross(held, database = openLocalDb(), testHooks = {}) {
|
|
3568
|
+
if (!held) throw new Error('workspace tree materialization plan is missing');
|
|
3569
|
+
let plan = held.plan;
|
|
3570
|
+
if (held.state === 'committed') return held;
|
|
3571
|
+
if (held.state === 'crossed') {
|
|
3572
|
+
verify(plan, database);
|
|
3573
|
+
return held;
|
|
3574
|
+
}
|
|
3575
|
+
requireRoot(plan, database);
|
|
3576
|
+
const extraction = [
|
|
3577
|
+
...plan.moves.map((item) => ({ item, lane: 'moves' })),
|
|
3578
|
+
...plan.removals.map((item) => ({ item, lane: 'removed' })),
|
|
3579
|
+
].sort((left, right) => (
|
|
3580
|
+
right.item.from.split('/').length - left.item.from.split('/').length
|
|
3581
|
+
));
|
|
3582
|
+
// Prove the entire source set before the first rename. A changed file or a
|
|
3583
|
+
// new descendant is local work, not permission to delete the new bytes
|
|
3584
|
+
// under an old inode-only receipt.
|
|
3585
|
+
try {
|
|
3586
|
+
const resumingStagedPlan = exists(stagingRoot(plan));
|
|
3587
|
+
for (const { item, lane } of extraction) {
|
|
3588
|
+
const source = absolute(plan, item.from);
|
|
3589
|
+
const staged = stagedPath(plan, lane, item.nodeId);
|
|
3590
|
+
const target = item.to == null ? null : absolute(plan, item.to);
|
|
3591
|
+
const expected = (
|
|
3592
|
+
!resumingStagedPlan
|
|
3593
|
+
&& item.preflightIdentity
|
|
3594
|
+
) || item.identity;
|
|
3595
|
+
if (
|
|
3596
|
+
sameIdentity(source, expected)
|
|
3597
|
+
|| exists(staged)
|
|
3598
|
+
|| (target && sameIdentity(target, item.identity))
|
|
3599
|
+
) continue;
|
|
3600
|
+
throw new Error(`workspace tree source content changed before snapshot for ${item.nodeId}`);
|
|
3601
|
+
}
|
|
3602
|
+
} catch (error) {
|
|
3603
|
+
if (!exists(stagingRoot(plan))) {
|
|
3604
|
+
database.prepare(`
|
|
3605
|
+
DELETE FROM workspace_tree_materializations
|
|
3606
|
+
WHERE resource_id = ? AND snapshot_version = ? AND materialization_state = 'prepared'
|
|
3607
|
+
`).run(held.resourceId, held.snapshotVersion);
|
|
3608
|
+
}
|
|
3609
|
+
throw error;
|
|
3610
|
+
}
|
|
3611
|
+
try {
|
|
3612
|
+
for (const { item, lane } of extraction) {
|
|
3613
|
+
stageOne(plan, item, lane, testHooks);
|
|
3614
|
+
}
|
|
3615
|
+
} catch (error) {
|
|
3616
|
+
if (error?.code === 'WORKSPACE_TREE_SOURCE_RACE') {
|
|
3617
|
+
supersedeUncommitted(held, database);
|
|
3618
|
+
}
|
|
3619
|
+
throw error;
|
|
3620
|
+
}
|
|
3621
|
+
if (plan.removals.some((item) => item.preflightIdentity)) {
|
|
3622
|
+
plan = {
|
|
3623
|
+
...plan,
|
|
3624
|
+
removals: plan.removals.map((item) => ({
|
|
3625
|
+
...item,
|
|
3626
|
+
identity: identity(stagedPath(plan, 'removed', item.nodeId)),
|
|
3627
|
+
})),
|
|
3628
|
+
};
|
|
3629
|
+
database.prepare(`
|
|
3630
|
+
UPDATE workspace_tree_materializations
|
|
3631
|
+
SET plan_json = ?,
|
|
3632
|
+
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
3633
|
+
WHERE resource_id = ? AND snapshot_version = ? AND materialization_state = 'prepared'
|
|
3634
|
+
`).run(JSON.stringify(plan), held.resourceId, held.snapshotVersion);
|
|
3635
|
+
held = get(held.resourceId, held.snapshotVersion, database);
|
|
3636
|
+
plan = held.plan;
|
|
3637
|
+
}
|
|
3638
|
+
if (testHooks.crashAfterStage === true) {
|
|
3639
|
+
throw new Error('simulated workspace tree crash after staging');
|
|
3640
|
+
}
|
|
3641
|
+
try {
|
|
3642
|
+
let created = 0;
|
|
3643
|
+
const createOne = (item) => {
|
|
3644
|
+
if (createProjectedNode(plan, item)) created += 1;
|
|
3645
|
+
if (created > 0 && created === Number(testHooks.crashAfterCreates)) {
|
|
3646
|
+
throw new Error('simulated workspace tree crash after create');
|
|
3647
|
+
}
|
|
3648
|
+
};
|
|
3649
|
+
for (const action of structuralActions(plan)) {
|
|
3650
|
+
if (action.type === 'create') createOne(action.item);
|
|
3651
|
+
else installMove(plan, action.item, testHooks);
|
|
3652
|
+
}
|
|
3653
|
+
for (const item of plan.creates.filter((candidate) => candidate.kind !== 'folder')) {
|
|
3654
|
+
createOne(item);
|
|
3655
|
+
}
|
|
3656
|
+
if (typeof testHooks.beforeTreePlanVerify === 'function') {
|
|
3657
|
+
testHooks.beforeTreePlanVerify(plan);
|
|
3658
|
+
}
|
|
3659
|
+
verify(plan, database);
|
|
3660
|
+
} catch (error) {
|
|
3661
|
+
if (
|
|
3662
|
+
error?.code === 'WORKSPACE_TREE_SOURCE_RACE'
|
|
3663
|
+
|| error?.code === 'WORKSPACE_TREE_PARENT_RACE'
|
|
3664
|
+
) {
|
|
3665
|
+
supersedeUncommitted(held, database);
|
|
3666
|
+
}
|
|
3667
|
+
throw error;
|
|
3668
|
+
}
|
|
3669
|
+
database.prepare(`
|
|
3670
|
+
UPDATE workspace_tree_materializations
|
|
3671
|
+
SET materialization_state = 'crossed',
|
|
3672
|
+
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
3673
|
+
WHERE resource_id = ? AND snapshot_version = ?
|
|
3674
|
+
`).run(held.resourceId, held.snapshotVersion);
|
|
3675
|
+
return get(held.resourceId, held.snapshotVersion, database);
|
|
3676
|
+
}
|
|
3677
|
+
|
|
3678
|
+
function markCommitted(held, database = openLocalDb()) {
|
|
3679
|
+
const result = database.prepare(`
|
|
3680
|
+
UPDATE workspace_tree_materializations
|
|
3681
|
+
SET materialization_state = 'committed',
|
|
3682
|
+
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
3683
|
+
WHERE resource_id = ?
|
|
3684
|
+
AND snapshot_version = ?
|
|
3685
|
+
AND materialization_state = 'crossed'
|
|
3686
|
+
`).run(held.resourceId, held.snapshotVersion);
|
|
3687
|
+
if (result.changes !== 1) {
|
|
3688
|
+
const current = get(held.resourceId, held.snapshotVersion, database);
|
|
3689
|
+
if (!current || current.state !== 'committed') {
|
|
3690
|
+
throw new Error(
|
|
3691
|
+
`workspace tree snapshot ${held.resourceId}@${held.snapshotVersion} did not commit`,
|
|
3692
|
+
);
|
|
3693
|
+
}
|
|
3694
|
+
return current;
|
|
3695
|
+
}
|
|
3696
|
+
return get(held.resourceId, held.snapshotVersion, database);
|
|
3697
|
+
}
|
|
3698
|
+
|
|
3699
|
+
function finish(held, database = openLocalDb(), options = {}) {
|
|
3700
|
+
let current = get(held.resourceId, held.snapshotVersion, database);
|
|
3701
|
+
if (!current) return;
|
|
3702
|
+
if (current.state !== 'committed') {
|
|
3703
|
+
throw new Error(
|
|
3704
|
+
`workspace tree snapshot ${held.resourceId}@${held.snapshotVersion} has not committed`,
|
|
3705
|
+
);
|
|
3706
|
+
}
|
|
3707
|
+
const retained = retireWithIncrementalCapture(
|
|
3708
|
+
current.plan,
|
|
3709
|
+
current.plan.removals,
|
|
3710
|
+
options,
|
|
3711
|
+
(item) => retireRemoval(current.plan, item, {
|
|
3712
|
+
durable: true,
|
|
3713
|
+
persistPlan(plan) {
|
|
3714
|
+
current = updateCommittedPlan(current, plan, database);
|
|
3715
|
+
},
|
|
3716
|
+
afterOutputReceipt: options.afterOutputReceipt,
|
|
3717
|
+
afterOutputMove: options.afterOutputMove,
|
|
3718
|
+
}),
|
|
3719
|
+
);
|
|
3720
|
+
cleanupStagingRoot(current.plan);
|
|
3721
|
+
syncDirectory(current.plan.rootPath);
|
|
3722
|
+
syncDirectory(path.dirname(current.plan.rootPath));
|
|
3723
|
+
database.transaction(() => {
|
|
3724
|
+
const result = database.prepare(`
|
|
3725
|
+
DELETE FROM workspace_tree_materializations
|
|
3726
|
+
WHERE resource_id = ? AND snapshot_version = ?
|
|
3727
|
+
AND materialization_state = 'committed'
|
|
3728
|
+
`).run(held.resourceId, held.snapshotVersion);
|
|
3729
|
+
if (result.changes !== 1) {
|
|
3730
|
+
throw new Error(
|
|
3731
|
+
`workspace tree snapshot ${held.resourceId}@${held.snapshotVersion} lost its control record`,
|
|
3732
|
+
);
|
|
3733
|
+
}
|
|
3734
|
+
if (typeof options.finalizeControlState === 'function') {
|
|
3735
|
+
options.finalizeControlState(current.plan);
|
|
3736
|
+
}
|
|
3737
|
+
})();
|
|
3738
|
+
return { retained };
|
|
3739
|
+
}
|
|
3740
|
+
|
|
3741
|
+
module.exports = {
|
|
3742
|
+
appendOperationSuccessor,
|
|
3743
|
+
clearOperationSuccessors,
|
|
3744
|
+
cross,
|
|
3745
|
+
crossOperation,
|
|
3746
|
+
cancelPreparedOperation,
|
|
3747
|
+
finish,
|
|
3748
|
+
finishOperation,
|
|
3749
|
+
get,
|
|
3750
|
+
getOperation,
|
|
3751
|
+
identity,
|
|
3752
|
+
insert,
|
|
3753
|
+
insertOperation,
|
|
3754
|
+
ensureDurableDirectory,
|
|
3755
|
+
locateOperationBindings,
|
|
3756
|
+
list,
|
|
3757
|
+
listOperationSuccessors,
|
|
3758
|
+
listOperations,
|
|
3759
|
+
markCommitted,
|
|
3760
|
+
markOperationCommitted,
|
|
3761
|
+
replacePreparedOperation,
|
|
3762
|
+
updateCommittedPlan,
|
|
3763
|
+
updateUncommittedPlan,
|
|
3764
|
+
removalPrecondition,
|
|
3765
|
+
supersedeUncommitted,
|
|
3766
|
+
};
|