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
@@ -0,0 +1,100 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * The official workspace-tree floor.
5
+ *
6
+ * `workspace_nodes` is the physical view (official floor plus durable local
7
+ * overlay), so it cannot also answer what an incoming official operation is
8
+ * allowed to reduce against. This table keeps that one separate fact.
9
+ */
10
+
11
+ const { openLocalDb } = require('../../state/db');
12
+ const { normalizeTreeSnapshot } = require('./snapshot');
13
+
14
+ function ensureSchema(database = openLocalDb()) {
15
+ database.exec(`
16
+ CREATE TABLE IF NOT EXISTS workspace_tree_floors (
17
+ resource_id TEXT PRIMARY KEY,
18
+ rail_version INTEGER NOT NULL CHECK (rail_version >= 0),
19
+ authority_epoch INTEGER NOT NULL CHECK (authority_epoch > 0),
20
+ snapshot_json TEXT NOT NULL,
21
+ updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
22
+ );
23
+ `);
24
+ }
25
+
26
+ function snapshotRecord(snapshot) {
27
+ return {
28
+ schemaVersion: 1,
29
+ workspaceId: snapshot.workspaceId,
30
+ version: snapshot.version,
31
+ nodes: Array.from(snapshot.nodes.values()),
32
+ };
33
+ }
34
+
35
+ function normalize(row) {
36
+ if (!row) return null;
37
+ const snapshot = normalizeTreeSnapshot(JSON.parse(row.snapshot_json));
38
+ if (
39
+ snapshot.workspaceId !== row.resource_id
40
+ || snapshot.version !== Number(row.rail_version)
41
+ ) {
42
+ throw new Error(`workspace tree floor ${row.resource_id} is inconsistent`);
43
+ }
44
+ return {
45
+ resourceId: row.resource_id,
46
+ version: Number(row.rail_version),
47
+ authorityEpoch: Number(row.authority_epoch),
48
+ snapshot,
49
+ updatedAt: row.updated_at,
50
+ };
51
+ }
52
+
53
+ function read(resourceId, options = {}) {
54
+ const database = options.database || openLocalDb();
55
+ ensureSchema(database);
56
+ return normalize(database.prepare(`
57
+ SELECT * FROM workspace_tree_floors WHERE resource_id = ?
58
+ `).get(resourceId));
59
+ }
60
+
61
+ function write(snapshotInput, authorityEpochInput, options = {}) {
62
+ const database = options.database || openLocalDb();
63
+ ensureSchema(database);
64
+ const snapshot = normalizeTreeSnapshot(snapshotRecord(snapshotInput));
65
+ const authorityEpoch = Number(authorityEpochInput);
66
+ if (!Number.isSafeInteger(authorityEpoch) || authorityEpoch < 1) {
67
+ throw new Error('workspace tree floor authority epoch is invalid');
68
+ }
69
+ database.prepare(`
70
+ INSERT INTO workspace_tree_floors (
71
+ resource_id, rail_version, authority_epoch, snapshot_json, updated_at
72
+ ) VALUES (?, ?, ?, ?, ?)
73
+ ON CONFLICT(resource_id) DO UPDATE SET
74
+ rail_version = excluded.rail_version,
75
+ authority_epoch = excluded.authority_epoch,
76
+ snapshot_json = excluded.snapshot_json,
77
+ updated_at = excluded.updated_at
78
+ `).run(
79
+ snapshot.workspaceId,
80
+ snapshot.version,
81
+ authorityEpoch,
82
+ JSON.stringify(snapshotRecord(snapshot)),
83
+ new Date().toISOString(),
84
+ );
85
+ return read(snapshot.workspaceId, { database });
86
+ }
87
+
88
+ function ensure(snapshot, authorityEpoch, options = {}) {
89
+ const database = options.database || openLocalDb();
90
+ const existing = read(snapshot.workspaceId, { database });
91
+ if (existing) return existing;
92
+ return write(snapshot, authorityEpoch, { database });
93
+ }
94
+
95
+ module.exports = {
96
+ ensure,
97
+ read,
98
+ snapshotRecord,
99
+ write,
100
+ };
@@ -57,7 +57,13 @@ function isPrivateFile(name) {
57
57
  // enrolled as leaf nodes and are never followed by the scanner.
58
58
  function shouldEnroll(relativePath) {
59
59
  const parts = partsFor(relativePath);
60
- if (parts.length === 0 || parts.some((part) => EXCLUDED_DIRECTORIES.has(part))) return false;
60
+ if (
61
+ parts.length === 0
62
+ || parts.some((part) => (
63
+ EXCLUDED_DIRECTORIES.has(part)
64
+ || part.startsWith('.amalgm-tree-apply-')
65
+ ))
66
+ ) return false;
61
67
  const name = parts.at(-1);
62
68
  if (EXCLUDED_FILES.has(name) || isPrivateFile(name)) return false;
63
69
  return !name.startsWith('.#') && !name.endsWith('~') && !/\.(?:swp|swo)$/i.test(name);
@@ -4,7 +4,6 @@ const fs = require('fs');
4
4
  const path = require('path');
5
5
  const { AMALGM_DIR } = require('../../config');
6
6
  const { openLocalDb } = require('../../state/db');
7
- const { descendantsInclude } = require('./reducer');
8
7
 
9
8
  function invalid(message, statusCode = 400) {
10
9
  return Object.assign(new Error(message), { statusCode });
@@ -23,6 +22,7 @@ function rowToNode(row) {
23
22
  deviceId: row.device_id || null,
24
23
  inode: row.inode || null,
25
24
  trashedAt: row.trashed_at || null,
25
+ trashComponentId: row.trash_component_id || null,
26
26
  purgedAt: row.purged_at || null,
27
27
  missingAt: row.missing_at || null,
28
28
  createdAt: row.created_at,
@@ -53,56 +53,22 @@ function relativePathForNode(nodes, nodeId, memo = new Map()) {
53
53
  return relativePath;
54
54
  }
55
55
 
56
- function trashPath(workspaceId, nodeId) {
57
- return path.join(AMALGM_DIR, 'trash', 'workspaces', workspaceId, nodeId);
58
- }
59
-
60
- function requireMissing(targetPath, message) {
61
- try {
62
- fs.lstatSync(targetPath);
63
- throw invalid(message, 409);
64
- } catch (error) {
65
- if (error?.code !== 'ENOENT') throw error;
56
+ function trashPath(workspaceId, nodeId, workspacePath = null) {
57
+ if (workspacePath) {
58
+ // Tree trash participates in the same rename-only WAL as the visible
59
+ // workspace. Keep it beside that workspace so arbitrary registered
60
+ // folders (including external volumes) never turn trash/restore into an
61
+ // impossible cross-device rename.
62
+ return path.join(
63
+ path.dirname(path.resolve(workspacePath)),
64
+ `.amalgm-tree-trash-${workspaceId}`,
65
+ nodeId,
66
+ );
66
67
  }
67
- }
68
-
69
- function materializeOperation(workspace, before, after, operation) {
70
- const oldNode = before.get(operation.nodeId) || null;
71
- const newNode = after.get(operation.nodeId) || null;
72
- const oldRelative = oldNode ? relativePathForNode(before, oldNode.id) : null;
73
- const newRelative = newNode ? relativePathForNode(after, newNode.id) : null;
74
- const oldPath = oldRelative ? path.join(workspace.path, ...oldRelative.split('/')) : null;
75
- const newPath = newRelative ? path.join(workspace.path, ...newRelative.split('/')) : null;
76
-
77
- if (operation.kind === 'tree.create') {
78
- requireMissing(newPath, 'create destination already exists');
79
- fs.mkdirSync(path.dirname(newPath), { recursive: true });
80
- if (operation.nodeKind === 'folder') fs.mkdirSync(newPath);
81
- else if (operation.nodeKind === 'symlink') fs.symlinkSync(operation.symlinkTarget, newPath);
82
- else fs.writeFileSync(newPath, '');
83
- } else if (operation.kind === 'tree.rename' || operation.kind === 'tree.move') {
84
- requireMissing(newPath, 'move destination already exists');
85
- fs.mkdirSync(path.dirname(newPath), { recursive: true });
86
- fs.renameSync(oldPath, newPath);
87
- } else if (operation.kind === 'tree.trash') {
88
- const destination = trashPath(workspace.id, operation.nodeId);
89
- requireMissing(destination, 'trash destination already exists');
90
- fs.mkdirSync(path.dirname(destination), { recursive: true });
91
- fs.renameSync(oldPath, destination);
92
- } else if (operation.kind === 'tree.restore') {
93
- const source = trashPath(workspace.id, operation.nodeId);
94
- requireMissing(newPath, 'restore destination already exists');
95
- fs.mkdirSync(path.dirname(newPath), { recursive: true });
96
- fs.renameSync(source, newPath);
97
- } else if (operation.kind === 'tree.purge') {
98
- for (const node of before.values()) {
99
- if (node.id === operation.nodeId || descendantsInclude(before, operation.nodeId, node.id)) {
100
- fs.rmSync(trashPath(workspace.id, node.id), { recursive: true, force: true });
101
- }
102
- }
103
- }
104
-
105
- return { oldPath, newPath };
68
+ // Compatibility for callers that only need the historical locator. New
69
+ // materialization plans always pass workspacePath and persist the exact
70
+ // same-filesystem location in their action.
71
+ return path.join(AMALGM_DIR, 'trash', 'workspaces', workspaceId, nodeId);
106
72
  }
107
73
 
108
74
  function absolutePathForWorkspace(workspaceId, relativePath, database) {
@@ -115,32 +81,78 @@ function persistNodes(database, before, after, workspaceId) {
115
81
  const now = new Date().toISOString();
116
82
  const pathMemo = new Map();
117
83
  const changedPaths = [];
84
+ const persisted = new Map(
85
+ database.prepare(
86
+ 'SELECT * FROM workspace_nodes WHERE workspace_id = ?',
87
+ ).all(workspaceId)
88
+ .map(rowToNode)
89
+ .map((node) => [node.id, node]),
90
+ );
118
91
  for (const node of after.values()) {
119
92
  const relativePath = relativePathForNode(after, node.id, pathMemo);
120
- const previous = before.get(node.id);
121
- const previousRelative = previous ? relativePathForNode(before, previous.id) : null;
122
- if (!previous) {
93
+ const previousFromBefore = before.get(node.id);
94
+ const previous = previousFromBefore || persisted.get(node.id);
95
+ const previousRelative = previousFromBefore
96
+ ? relativePathForNode(before, previousFromBefore.id)
97
+ : previous?.relativePath || null;
98
+ if (!persisted.has(node.id)) {
123
99
  const absolutePath = relativePath ? absolutePathForWorkspace(workspaceId, relativePath, database) : null;
124
- const stats = absolutePath ? fs.lstatSync(absolutePath) : null;
100
+ const stats = absolutePath && !node.trashedAt && !node.purgedAt
101
+ ? fs.lstatSync(absolutePath)
102
+ : null;
125
103
  database.prepare(`
126
104
  INSERT INTO workspace_nodes (
127
105
  id, workspace_id, parent_id, kind, name, relative_path, symlink_target,
128
- device_id, inode, trashed_at, purged_at, missing_at, created_at, updated_at
129
- ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
106
+ device_id, inode, trashed_at, trash_component_id, purged_at,
107
+ missing_at, created_at, updated_at
108
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
109
+ ON CONFLICT(id) DO UPDATE SET
110
+ workspace_id = excluded.workspace_id,
111
+ parent_id = excluded.parent_id,
112
+ kind = excluded.kind,
113
+ name = excluded.name,
114
+ relative_path = excluded.relative_path,
115
+ symlink_target = excluded.symlink_target,
116
+ device_id = excluded.device_id,
117
+ inode = excluded.inode,
118
+ trashed_at = excluded.trashed_at,
119
+ trash_component_id = excluded.trash_component_id,
120
+ purged_at = excluded.purged_at,
121
+ missing_at = NULL,
122
+ updated_at = excluded.updated_at
130
123
  `).run(
131
124
  node.id, node.workspaceId, node.parentId, node.kind, node.name,
132
125
  relativePath, node.symlinkTarget, stats ? String(stats.dev) : null,
133
- stats ? String(stats.ino) : null, node.trashedAt, node.purgedAt,
126
+ stats ? String(stats.ino) : null, node.trashedAt,
127
+ node.trashComponentId, node.purgedAt,
134
128
  node.createdAt, now,
135
129
  );
136
130
  } else {
137
131
  database.prepare(`
138
132
  UPDATE workspace_nodes
139
133
  SET parent_id = ?, name = ?, relative_path = ?, trashed_at = ?,
140
- purged_at = ?, missing_at = NULL, updated_at = ?
134
+ trash_component_id = ?, purged_at = ?, missing_at = NULL,
135
+ updated_at = ?
141
136
  WHERE id = ?
142
- `).run(node.parentId, node.name, relativePath, node.trashedAt, node.purgedAt, now, node.id);
143
- if (previousRelative !== relativePath) {
137
+ `).run(
138
+ node.parentId,
139
+ node.name,
140
+ relativePath,
141
+ node.trashedAt,
142
+ node.trashComponentId,
143
+ node.purgedAt,
144
+ now,
145
+ node.id,
146
+ );
147
+ // Trashed and purged rows are UUID tombstones, not live document
148
+ // locations. Multiple tombstones may legally retain the same logical
149
+ // path, so only a resulting live node may participate in document path
150
+ // rebinding.
151
+ if (
152
+ previousRelative !== relativePath
153
+ && !node.trashedAt
154
+ && !node.purgedAt
155
+ ) {
144
156
  changedPaths.push({
145
157
  nodeId: node.id,
146
158
  oldPath: absolutePathForWorkspace(workspaceId, previousRelative, database),
@@ -165,9 +177,9 @@ function hasAncestorInSet(node, nodesById, ids) {
165
177
 
166
178
  module.exports = {
167
179
  hasAncestorInSet,
168
- materializeOperation,
169
180
  persistNodes,
170
181
  relativePathForNode,
171
182
  rowToNode,
183
+ trashPath,
172
184
  workspaceRow,
173
185
  };
@@ -0,0 +1,127 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+
5
+ function stableHold(input) {
6
+ const operation = input?.operation || null;
7
+ const operationKind = typeof operation?.kind === 'string' ? operation.kind : null;
8
+ const explicitNodeId = typeof input?.nodeId === 'string' ? input.nodeId : null;
9
+ const operationNodeId = (
10
+ operationKind !== 'tree.create'
11
+ && typeof operation?.nodeId === 'string'
12
+ ) ? operation.nodeId : null;
13
+ return {
14
+ reason: typeof input?.reason === 'string' && input.reason
15
+ ? input.reason
16
+ : 'observation-pending',
17
+ nodeId: explicitNodeId || operationNodeId,
18
+ operationKind,
19
+ paths: Array.from(new Set(
20
+ Array.isArray(input?.paths)
21
+ ? input.paths.filter((value) => typeof value === 'string' && value)
22
+ : [],
23
+ )).sort(),
24
+ };
25
+ }
26
+
27
+ function ownerScope(options = {}) {
28
+ return {
29
+ ownerKind: typeof options.ownerKind === 'string' && options.ownerKind
30
+ ? options.ownerKind
31
+ : 'observer',
32
+ ownerId: typeof options.ownerId === 'string' && options.ownerId
33
+ ? options.ownerId
34
+ : 'workspace-tree-observer',
35
+ };
36
+ }
37
+
38
+ function holdKey(owner, hold) {
39
+ return crypto.createHash('sha256')
40
+ .update(JSON.stringify({ ...owner, hold }))
41
+ .digest('hex');
42
+ }
43
+
44
+ function normalize(rows) {
45
+ return rows.map((row) => ({
46
+ id: row.hold_key,
47
+ ownerKind: row.owner_kind,
48
+ ownerId: row.owner_id,
49
+ reason: row.reason,
50
+ nodeId: row.node_id || null,
51
+ operationKind: row.operation_kind || null,
52
+ paths: JSON.parse(row.paths_json),
53
+ firstObservedAt: row.first_observed_at,
54
+ }));
55
+ }
56
+
57
+ function list(workspaceId, database) {
58
+ return normalize(database.prepare(`
59
+ SELECT *
60
+ FROM workspace_tree_observation_holds
61
+ WHERE workspace_id = ?
62
+ ORDER BY owner_kind, owner_id, reason, hold_key
63
+ `).all(workspaceId));
64
+ }
65
+
66
+ function replace(workspaceId, inputs, database, options = {}) {
67
+ const owner = ownerScope(options);
68
+ const next = (Array.isArray(inputs) ? inputs : [])
69
+ .map(stableHold)
70
+ .map((hold) => ({ ...hold, key: holdKey(owner, hold) }))
71
+ .sort((left, right) => left.key.localeCompare(right.key));
72
+ const current = database.prepare(`
73
+ SELECT *
74
+ FROM workspace_tree_observation_holds
75
+ WHERE workspace_id = ? AND owner_kind = ? AND owner_id = ?
76
+ ORDER BY hold_key
77
+ `).all(workspaceId, owner.ownerKind, owner.ownerId);
78
+ const currentShape = current.map((row) => ({
79
+ key: row.hold_key,
80
+ reason: row.reason,
81
+ nodeId: row.node_id || null,
82
+ operationKind: row.operation_kind || null,
83
+ paths: JSON.parse(row.paths_json),
84
+ }));
85
+ const nextShape = next.map((hold) => ({
86
+ key: hold.key,
87
+ reason: hold.reason,
88
+ nodeId: hold.nodeId,
89
+ operationKind: hold.operationKind,
90
+ paths: hold.paths,
91
+ }));
92
+ if (JSON.stringify(currentShape) === JSON.stringify(nextShape)) return false;
93
+
94
+ const firstObservedByKey = new Map(
95
+ current.map((row) => [row.hold_key, row.first_observed_at]),
96
+ );
97
+ const now = new Date().toISOString();
98
+ database.prepare(`
99
+ DELETE FROM workspace_tree_observation_holds
100
+ WHERE workspace_id = ? AND owner_kind = ? AND owner_id = ?
101
+ `).run(workspaceId, owner.ownerKind, owner.ownerId);
102
+ const insert = database.prepare(`
103
+ INSERT INTO workspace_tree_observation_holds (
104
+ workspace_id, hold_key, owner_kind, owner_id, reason, node_id,
105
+ operation_kind, paths_json, first_observed_at
106
+ ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
107
+ `);
108
+ for (const hold of next) {
109
+ insert.run(
110
+ workspaceId,
111
+ hold.key,
112
+ owner.ownerKind,
113
+ owner.ownerId,
114
+ hold.reason,
115
+ hold.nodeId,
116
+ hold.operationKind,
117
+ JSON.stringify(hold.paths),
118
+ firstObservedByKey.get(hold.key) || now,
119
+ );
120
+ }
121
+ return true;
122
+ }
123
+
124
+ module.exports = {
125
+ list,
126
+ replace,
127
+ };
@@ -0,0 +1,12 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * A pathname has no final entry when either that entry is missing or an
5
+ * ancestor stopped being a directory. Recovery must classify both as
6
+ * absence; the latter is not an I/O failure and must never wedge a WAL.
7
+ */
8
+ function isPathAbsentError(error) {
9
+ return error?.code === 'ENOENT' || error?.code === 'ENOTDIR';
10
+ }
11
+
12
+ module.exports = { isPathAbsentError };
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { normalizeTreeOperation } = require('./operations');
4
+ const { validateLiveSymlinkTargets } = require('./symlinks');
4
5
 
5
6
  function toMap(nodes) {
6
7
  if (nodes instanceof Map) return new Map(nodes);
@@ -19,6 +20,25 @@ function descendantsInclude(nodes, ancestorId, candidateId) {
19
20
  return false;
20
21
  }
21
22
 
23
+ function descendantIds(nodes, ancestorId) {
24
+ const children = new Map();
25
+ for (const node of nodes.values()) {
26
+ if (!node.parentId) continue;
27
+ const siblings = children.get(node.parentId) || [];
28
+ siblings.push(node.id);
29
+ children.set(node.parentId, siblings);
30
+ }
31
+ const descendants = [];
32
+ const queue = [ancestorId];
33
+ for (let cursor = 0; cursor < queue.length; cursor += 1) {
34
+ for (const childId of children.get(queue[cursor]) || []) {
35
+ descendants.push(childId);
36
+ queue.push(childId);
37
+ }
38
+ }
39
+ return descendants;
40
+ }
41
+
22
42
  function ensureAvailableName(nodes, workspaceId, parentId, name, exceptId = null) {
23
43
  const duplicate = Array.from(nodes.values()).find((node) => (
24
44
  node.workspaceId === workspaceId
@@ -51,10 +71,12 @@ function applyTreeOperation(inputNodes, inputOperation) {
51
71
  name: operation.name,
52
72
  symlinkTarget: operation.symlinkTarget || null,
53
73
  trashedAt: null,
74
+ trashComponentId: null,
54
75
  purgedAt: null,
55
76
  createdAt: operation.createdAt,
56
77
  updatedAt: operation.createdAt,
57
78
  });
79
+ validateLiveSymlinkTargets(nodes, operation.workspaceId);
58
80
  return nodes;
59
81
  }
60
82
 
@@ -72,6 +94,7 @@ function applyTreeOperation(inputNodes, inputOperation) {
72
94
  }
73
95
 
74
96
  const next = { ...existing, updatedAt: operation.changedAt };
97
+ const existingTrashComponentId = existing.trashComponentId || existing.id;
75
98
  if (operation.kind === 'tree.rename') {
76
99
  ensureAvailableName(nodes, operation.workspaceId, existing.parentId, operation.name, existing.id);
77
100
  next.name = operation.name;
@@ -92,6 +115,7 @@ function applyTreeOperation(inputNodes, inputOperation) {
92
115
  throw Object.assign(new Error('a node is already trashed'), { statusCode: 409 });
93
116
  }
94
117
  next.trashedAt = operation.trashedAt;
118
+ next.trashComponentId = operation.nodeId;
95
119
  } else if (operation.kind === 'tree.restore') {
96
120
  if (!existing.trashedAt) {
97
121
  throw Object.assign(new Error('a node must be trashed before it can be restored'), { statusCode: 409 });
@@ -109,26 +133,47 @@ function applyTreeOperation(inputNodes, inputOperation) {
109
133
  next.parentId = parentId;
110
134
  next.name = name;
111
135
  next.trashedAt = null;
136
+ next.trashComponentId = null;
112
137
  } else if (operation.kind === 'tree.purge') {
113
138
  if (!existing.trashedAt) {
114
139
  throw Object.assign(new Error('a node must be trashed before permanent removal'), { statusCode: 409 });
115
140
  }
116
141
  next.purgedAt = operation.purgedAt;
142
+ next.trashComponentId = null;
117
143
  }
118
144
 
119
145
  nodes.set(next.id, next);
120
146
  if (operation.kind === 'tree.trash' || operation.kind === 'tree.restore' || operation.kind === 'tree.purge') {
121
- for (const child of nodes.values()) {
122
- if (child.id === next.id || !descendantsInclude(nodes, next.id, child.id)) continue;
147
+ for (const childId of descendantIds(nodes, next.id)) {
148
+ const child = nodes.get(childId);
123
149
  if (operation.kind === 'tree.trash' && !child.trashedAt) {
124
- nodes.set(child.id, { ...child, trashedAt: operation.trashedAt, updatedAt: operation.changedAt });
125
- } else if (operation.kind === 'tree.restore' && child.trashedAt === existing.trashedAt) {
126
- nodes.set(child.id, { ...child, trashedAt: null, updatedAt: operation.changedAt });
150
+ nodes.set(child.id, {
151
+ ...child,
152
+ trashedAt: operation.trashedAt,
153
+ trashComponentId: operation.nodeId,
154
+ updatedAt: operation.changedAt,
155
+ });
156
+ } else if (
157
+ operation.kind === 'tree.restore'
158
+ && (child.trashComponentId || child.id) === existingTrashComponentId
159
+ ) {
160
+ nodes.set(child.id, {
161
+ ...child,
162
+ trashedAt: null,
163
+ trashComponentId: null,
164
+ updatedAt: operation.changedAt,
165
+ });
127
166
  } else if (operation.kind === 'tree.purge') {
128
- nodes.set(child.id, { ...child, purgedAt: operation.purgedAt, updatedAt: operation.changedAt });
167
+ nodes.set(child.id, {
168
+ ...child,
169
+ trashComponentId: null,
170
+ purgedAt: operation.purgedAt,
171
+ updatedAt: operation.changedAt,
172
+ });
129
173
  }
130
174
  }
131
175
  }
176
+ validateLiveSymlinkTargets(nodes, operation.workspaceId);
132
177
  return nodes;
133
178
  }
134
179
 
@@ -0,0 +1,125 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { isPathAbsentError } = require('./path-state');
6
+
7
+ const ROOT_HOLD_REASONS = new Set([
8
+ 'workspace-root-missing',
9
+ 'workspace-root-replaced',
10
+ 'workspace-root-binding-incomplete',
11
+ 'workspace-root-enrollment-pending',
12
+ ]);
13
+
14
+ function physicalDirectory(rootPath) {
15
+ try {
16
+ const stats = fs.lstatSync(rootPath);
17
+ if (!stats.isDirectory() || stats.isSymbolicLink()) return null;
18
+ return {
19
+ kind: 'folder',
20
+ deviceId: String(stats.dev),
21
+ inode: String(stats.ino),
22
+ dev: String(stats.dev),
23
+ ino: String(stats.ino),
24
+ };
25
+ } catch (error) {
26
+ if (isPathAbsentError(error)) return null;
27
+ throw error;
28
+ }
29
+ }
30
+
31
+ function inspectWorkspaceRoot(workspace, database) {
32
+ const rootPath = path.resolve(workspace?.path || '');
33
+ const row = workspace?.id
34
+ ? database.prepare(`
35
+ SELECT device_id, inode
36
+ FROM workspace_nodes
37
+ WHERE workspace_id = ? AND id = ?
38
+ `).get(workspace.id, workspace.id)
39
+ : null;
40
+ const expectedDeviceId = row?.device_id == null ? null : String(row.device_id);
41
+ const expectedInode = row?.inode == null ? null : String(row.inode);
42
+ const actual = physicalDirectory(rootPath);
43
+
44
+ if ((expectedDeviceId == null) !== (expectedInode == null)) {
45
+ return {
46
+ state: 'held',
47
+ reason: 'workspace-root-binding-incomplete',
48
+ workspaceId: workspace.id,
49
+ rootPath,
50
+ expected: { deviceId: expectedDeviceId, inode: expectedInode },
51
+ actual,
52
+ };
53
+ }
54
+ if (expectedDeviceId == null) {
55
+ return {
56
+ state: 'unbound',
57
+ workspaceId: workspace.id,
58
+ rootPath,
59
+ actual,
60
+ };
61
+ }
62
+ if (!actual) {
63
+ return {
64
+ state: 'held',
65
+ reason: fs.existsSync(rootPath)
66
+ ? 'workspace-root-replaced'
67
+ : 'workspace-root-missing',
68
+ workspaceId: workspace.id,
69
+ rootPath,
70
+ expected: { deviceId: expectedDeviceId, inode: expectedInode },
71
+ actual: null,
72
+ };
73
+ }
74
+ if (
75
+ actual.deviceId !== expectedDeviceId
76
+ || actual.inode !== expectedInode
77
+ ) {
78
+ return {
79
+ state: 'held',
80
+ reason: 'workspace-root-replaced',
81
+ workspaceId: workspace.id,
82
+ rootPath,
83
+ expected: { deviceId: expectedDeviceId, inode: expectedInode },
84
+ actual,
85
+ };
86
+ }
87
+ return {
88
+ state: 'verified',
89
+ workspaceId: workspace.id,
90
+ rootPath,
91
+ identity: actual,
92
+ };
93
+ }
94
+
95
+ function rootBindingError(proof) {
96
+ const reason = proof?.reason || (
97
+ proof?.state === 'unbound'
98
+ ? 'workspace-root-enrollment-pending'
99
+ : 'workspace-root-replaced'
100
+ );
101
+ return Object.assign(
102
+ new Error(`workspace tree root is not verified: ${reason}`),
103
+ {
104
+ code: 'WORKSPACE_TREE_ROOT_UNVERIFIED',
105
+ reason,
106
+ statusCode: 409,
107
+ paths: proof?.rootPath ? [proof.rootPath] : [],
108
+ proof,
109
+ },
110
+ );
111
+ }
112
+
113
+ function requireVerifiedWorkspaceRoot(workspace, database) {
114
+ const proof = inspectWorkspaceRoot(workspace, database);
115
+ if (proof.state !== 'verified') throw rootBindingError(proof);
116
+ return proof;
117
+ }
118
+
119
+ module.exports = {
120
+ ROOT_HOLD_REASONS,
121
+ inspectWorkspaceRoot,
122
+ physicalDirectory,
123
+ requireVerifiedWorkspaceRoot,
124
+ rootBindingError,
125
+ };