amalgm 0.1.239 → 0.1.241

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