amalgm 0.1.239 → 0.1.241
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/shared-realtime-tunnel.js +18 -4
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/browser/cookie-jar.js +68 -20
- package/runtime/scripts/amalgm-mcp/project-context/store.js +87 -19
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +13 -0
- package/runtime/scripts/amalgm-mcp/state/attachments.js +40 -12
- package/runtime/scripts/amalgm-mcp/state/db.js +216 -17
- package/runtime/scripts/amalgm-mcp/state/doc-disk.js +550 -0
- package/runtime/scripts/amalgm-mcp/state/docs.js +1101 -301
- package/runtime/scripts/amalgm-mcp/state/mutation-contracts.js +99 -0
- package/runtime/scripts/amalgm-mcp/state/mutations.js +507 -130
- package/runtime/scripts/amalgm-mcp/state/promotions.js +21 -1
- package/runtime/scripts/amalgm-mcp/state/replicas.js +134 -13
- package/runtime/scripts/amalgm-mcp/state/shared-rest.js +14 -8
- package/runtime/scripts/amalgm-mcp/tests/browser-cookie-cloud.test.js +193 -0
- package/runtime/scripts/amalgm-mcp/tests/code-project-stream.test.js +313 -94
- package/runtime/scripts/amalgm-mcp/tests/fixtures/code-project-stream-v2.json +1 -0
- package/runtime/scripts/amalgm-mcp/tests/project-context.test.js +32 -17
- package/runtime/scripts/amalgm-mcp/tests/shared-tunnel-rehydration.test.js +84 -0
- package/runtime/scripts/amalgm-mcp/tests/state-docs.test.js +376 -13
- package/runtime/scripts/amalgm-mcp/tests/state-mutations.test.js +309 -0
- package/runtime/scripts/amalgm-mcp/tests/state-shared-replica.test.js +758 -30
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards-store.test.js +72 -3
- package/runtime/scripts/amalgm-mcp/tests/workspace-cards.test.js +33 -6
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint-reducer.test.js +27 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +334 -191
- package/runtime/scripts/amalgm-mcp/tests/workspace-durable.test.js +24 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-local-intent.test.js +1999 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-native-file-transaction.test.js +364 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-object-tunnel.test.js +98 -4
- package/runtime/scripts/amalgm-mcp/tests/workspace-objects.test.js +156 -8
- package/runtime/scripts/amalgm-mcp/tests/workspace-provenance.test.js +185 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-publication-invariants.test.js +174 -269
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer-liveness.test.js +26 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-reducer.test.js +2060 -407
- package/runtime/scripts/amalgm-mcp/tests/workspace-sync-stats.test.js +119 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-transition.test.js +1446 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +5319 -3
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +191 -1
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +3140 -17
- package/runtime/scripts/amalgm-mcp/tests/workspace-wall.test.js +135 -637
- package/runtime/scripts/amalgm-mcp/workspace/access-store.js +5 -1
- package/runtime/scripts/amalgm-mcp/workspace/card.js +7 -7
- package/runtime/scripts/amalgm-mcp/workspace/cards.js +36 -12
- package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +436 -173
- package/runtime/scripts/amalgm-mcp/workspace/content.js +32 -0
- package/runtime/scripts/amalgm-mcp/workspace/durable.js +45 -0
- package/runtime/scripts/amalgm-mcp/workspace/git.js +391 -37
- package/runtime/scripts/amalgm-mcp/workspace/intent.js +535 -0
- package/runtime/scripts/amalgm-mcp/workspace/merge.js +89 -6
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/BUILD.md +31 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/darwin-arm64/amalgm-file-transaction +0 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/bin/linux-x64/amalgm-file-transaction +0 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/build.mjs +72 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/client.js +165 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.mod +5 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/go.sum +2 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/main.go +2201 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/manifest.json +19 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_darwin.go +18 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/move_linux.go +18 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_darwin.go +33 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/process_linux.go +47 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/verify.mjs +43 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_darwin.go +114 -0
- package/runtime/scripts/amalgm-mcp/workspace/native-file-transaction/writers_linux.go +84 -0
- package/runtime/scripts/amalgm-mcp/workspace/objects.js +185 -55
- package/runtime/scripts/amalgm-mcp/workspace/project-stream.js +616 -168
- package/runtime/scripts/amalgm-mcp/workspace/provenance.js +240 -0
- package/runtime/scripts/amalgm-mcp/workspace/reducer.js +1979 -570
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +49 -28
- package/runtime/scripts/amalgm-mcp/workspace/store.js +77 -4
- package/runtime/scripts/amalgm-mcp/workspace/sync-stats.js +240 -0
- package/runtime/scripts/amalgm-mcp/workspace/transition.js +1729 -192
- package/runtime/scripts/amalgm-mcp/workspace/tree/floor.js +100 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +7 -1
- package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +73 -61
- package/runtime/scripts/amalgm-mcp/workspace/tree/observation-holds.js +127 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/path-state.js +12 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +51 -6
- package/runtime/scripts/amalgm-mcp/workspace/tree/root-binding.js +125 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +79 -8
- package/runtime/scripts/amalgm-mcp/workspace/tree/symlinks.js +76 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +3480 -165
- package/runtime/scripts/amalgm-mcp/workspace/tree-materialization.js +3766 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +899 -71
- package/runtime/scripts/amalgm-mcp/workspace/wall.js +352 -497
|
@@ -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 (
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
|
121
|
-
const
|
|
122
|
-
|
|
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
|
|
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,
|
|
129
|
-
|
|
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,
|
|
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,
|
|
134
|
+
trash_component_id = ?, purged_at = ?, missing_at = NULL,
|
|
135
|
+
updated_at = ?
|
|
141
136
|
WHERE id = ?
|
|
142
|
-
`).run(
|
|
143
|
-
|
|
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
|
|
122
|
-
|
|
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, {
|
|
125
|
-
|
|
126
|
-
|
|
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, {
|
|
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
|
+
};
|