amalgm 0.1.161 → 0.1.162
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 +30 -1
- package/package.json +1 -1
- package/runtime/scripts/amalgm-mcp/fs/rest.js +26 -7
- package/runtime/scripts/amalgm-mcp/index.js +3 -0
- package/runtime/scripts/amalgm-mcp/server/routes/state.js +4 -0
- package/runtime/scripts/amalgm-mcp/server/routes/workspace.js +32 -0
- package/runtime/scripts/amalgm-mcp/state/attachments.js +7 -5
- package/runtime/scripts/amalgm-mcp/state/cloud-resource-id.js +16 -0
- package/runtime/scripts/amalgm-mcp/state/db.js +74 -0
- package/runtime/scripts/amalgm-mcp/state/docs.js +122 -4
- package/runtime/scripts/amalgm-mcp/state/migrations.js +62 -0
- package/runtime/scripts/amalgm-mcp/state/mutations.js +4 -4
- package/runtime/scripts/amalgm-mcp/state/promotions.js +2 -2
- package/runtime/scripts/amalgm-mcp/state/replicas.js +6 -5
- package/runtime/scripts/amalgm-mcp/state/shared-rest.js +27 -22
- package/runtime/scripts/amalgm-mcp/tests/state-promotion-ownership.test.js +5 -9
- package/runtime/scripts/amalgm-mcp/tests/workspace-access-store.test.js +120 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +137 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-reconcile.test.js +264 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +155 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +114 -0
- package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +227 -0
- package/runtime/scripts/amalgm-mcp/workspace/access-store.js +182 -0
- package/runtime/scripts/amalgm-mcp/workspace/binding.js +62 -0
- package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +237 -0
- package/runtime/scripts/amalgm-mcp/workspace/git.js +63 -12
- package/runtime/scripts/amalgm-mcp/workspace/ledger.js +96 -0
- package/runtime/scripts/amalgm-mcp/workspace/merge.js +290 -0
- package/runtime/scripts/amalgm-mcp/workspace/reconcile.js +347 -0
- package/runtime/scripts/amalgm-mcp/workspace/rest.js +182 -2
- package/runtime/scripts/amalgm-mcp/workspace/store.js +59 -12
- package/runtime/scripts/amalgm-mcp/workspace/transition.js +141 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/access.js +62 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/identity.js +53 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +68 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +174 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/operations.js +93 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +138 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +82 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +391 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree-promotion.js +94 -0
- package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +666 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
|
|
5
|
+
const EXCLUDED_DIRECTORIES = new Set([
|
|
6
|
+
'.amalgm',
|
|
7
|
+
'.cache',
|
|
8
|
+
'.git',
|
|
9
|
+
'.gradle',
|
|
10
|
+
'.hg',
|
|
11
|
+
'.mypy_cache',
|
|
12
|
+
'.next',
|
|
13
|
+
'.nyc_output',
|
|
14
|
+
'.parcel-cache',
|
|
15
|
+
'.pytest_cache',
|
|
16
|
+
'.ruff_cache',
|
|
17
|
+
'.ssh',
|
|
18
|
+
'.svn',
|
|
19
|
+
'.svelte-kit',
|
|
20
|
+
'.turbo',
|
|
21
|
+
'.venv',
|
|
22
|
+
'.vite',
|
|
23
|
+
'__pycache__',
|
|
24
|
+
'coverage',
|
|
25
|
+
'node_modules',
|
|
26
|
+
]);
|
|
27
|
+
|
|
28
|
+
const EXCLUDED_FILES = new Set([
|
|
29
|
+
'.DS_Store',
|
|
30
|
+
'Thumbs.db',
|
|
31
|
+
'desktop.ini',
|
|
32
|
+
'npm-debug.log',
|
|
33
|
+
'pnpm-debug.log',
|
|
34
|
+
'yarn-debug.log',
|
|
35
|
+
'yarn-error.log',
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
function partsFor(relativePath) {
|
|
39
|
+
return String(relativePath || '').split(/[\\/]/).filter(Boolean);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isPrivateFile(name) {
|
|
43
|
+
if (/^\.env(?:\..+)?$/i.test(name) && !/\.example$/i.test(name)) return true;
|
|
44
|
+
return /^(?:credentials?|secrets?)\.(?:json|ya?ml|toml)$/i.test(name);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Machine-local metadata, dependencies, generated caches, and secrets never
|
|
48
|
+
// receive workspace identities. The rule applies at every depth. Symlinks are
|
|
49
|
+
// enrolled as leaf nodes and are never followed by the scanner.
|
|
50
|
+
function shouldEnroll(relativePath) {
|
|
51
|
+
const parts = partsFor(relativePath);
|
|
52
|
+
if (parts.length === 0 || parts.some((part) => EXCLUDED_DIRECTORIES.has(part))) return false;
|
|
53
|
+
const name = parts.at(-1);
|
|
54
|
+
if (EXCLUDED_FILES.has(name) || isPrivateFile(name)) return false;
|
|
55
|
+
return !name.startsWith('.#') && !name.endsWith('~') && !/\.(?:swp|swo)$/i.test(name);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function shouldSync(relativePath) {
|
|
59
|
+
return shouldEnroll(relativePath);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function relativeWorkspacePath(workspacePath, absolutePath) {
|
|
63
|
+
const relative = path.relative(workspacePath, absolutePath);
|
|
64
|
+
if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) return null;
|
|
65
|
+
return relative.split(path.sep).join('/');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
module.exports = { relativeWorkspacePath, shouldEnroll, shouldSync };
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { AMALGM_DIR } = require('../../config');
|
|
6
|
+
const { openLocalDb } = require('../../state/db');
|
|
7
|
+
const { descendantsInclude } = require('./reducer');
|
|
8
|
+
|
|
9
|
+
function invalid(message, statusCode = 400) {
|
|
10
|
+
return Object.assign(new Error(message), { statusCode });
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function rowToNode(row) {
|
|
14
|
+
if (!row) return null;
|
|
15
|
+
return {
|
|
16
|
+
id: row.id,
|
|
17
|
+
workspaceId: row.workspace_id,
|
|
18
|
+
parentId: row.parent_id || null,
|
|
19
|
+
kind: row.kind,
|
|
20
|
+
name: row.name,
|
|
21
|
+
relativePath: row.relative_path,
|
|
22
|
+
symlinkTarget: row.symlink_target || null,
|
|
23
|
+
deviceId: row.device_id || null,
|
|
24
|
+
inode: row.inode || null,
|
|
25
|
+
trashedAt: row.trashed_at || null,
|
|
26
|
+
purgedAt: row.purged_at || null,
|
|
27
|
+
missingAt: row.missing_at || null,
|
|
28
|
+
createdAt: row.created_at,
|
|
29
|
+
updatedAt: row.updated_at,
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function workspaceRow(identifier, database = openLocalDb()) {
|
|
34
|
+
return database.prepare(`
|
|
35
|
+
SELECT workspaces.*
|
|
36
|
+
FROM workspaces
|
|
37
|
+
LEFT JOIN workspace_id_aliases ON workspace_id_aliases.workspace_id = workspaces.id
|
|
38
|
+
WHERE workspaces.id = ? OR workspaces.path = ? OR workspace_id_aliases.alias_id = ?
|
|
39
|
+
LIMIT 1
|
|
40
|
+
`).get(identifier, identifier, identifier) || null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function relativePathForNode(nodes, nodeId, memo = new Map()) {
|
|
44
|
+
if (memo.has(nodeId)) return memo.get(nodeId);
|
|
45
|
+
const node = nodes.get(nodeId);
|
|
46
|
+
if (!node) throw invalid(`node ${nodeId} does not exist`, 404);
|
|
47
|
+
if (node.id === node.workspaceId) {
|
|
48
|
+
memo.set(nodeId, '');
|
|
49
|
+
return '';
|
|
50
|
+
}
|
|
51
|
+
const parentPath = relativePathForNode(nodes, node.parentId, memo);
|
|
52
|
+
const relativePath = parentPath ? `${parentPath}/${node.name}` : node.name;
|
|
53
|
+
memo.set(nodeId, relativePath);
|
|
54
|
+
return relativePath;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function trashPath(workspaceId, nodeId) {
|
|
58
|
+
return path.join(AMALGM_DIR, 'trash', 'workspaces', workspaceId, nodeId);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function requireMissing(targetPath, message) {
|
|
62
|
+
try {
|
|
63
|
+
fs.lstatSync(targetPath);
|
|
64
|
+
throw invalid(message, 409);
|
|
65
|
+
} catch (error) {
|
|
66
|
+
if (error?.code !== 'ENOENT') throw error;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function materializeOperation(workspace, before, after, operation) {
|
|
71
|
+
const oldNode = before.get(operation.nodeId) || null;
|
|
72
|
+
const newNode = after.get(operation.nodeId) || null;
|
|
73
|
+
const oldRelative = oldNode ? relativePathForNode(before, oldNode.id) : null;
|
|
74
|
+
const newRelative = newNode ? relativePathForNode(after, newNode.id) : null;
|
|
75
|
+
const oldPath = oldRelative ? path.join(workspace.path, ...oldRelative.split('/')) : null;
|
|
76
|
+
const newPath = newRelative ? path.join(workspace.path, ...newRelative.split('/')) : null;
|
|
77
|
+
|
|
78
|
+
if (operation.kind === 'tree.create') {
|
|
79
|
+
requireMissing(newPath, 'create destination already exists');
|
|
80
|
+
fs.mkdirSync(path.dirname(newPath), { recursive: true });
|
|
81
|
+
if (operation.nodeKind === 'folder') fs.mkdirSync(newPath);
|
|
82
|
+
else if (operation.nodeKind === 'symlink') fs.symlinkSync(operation.symlinkTarget, newPath);
|
|
83
|
+
else fs.writeFileSync(newPath, '');
|
|
84
|
+
} else if (operation.kind === 'tree.rename' || operation.kind === 'tree.move') {
|
|
85
|
+
requireMissing(newPath, 'move destination already exists');
|
|
86
|
+
fs.mkdirSync(path.dirname(newPath), { recursive: true });
|
|
87
|
+
fs.renameSync(oldPath, newPath);
|
|
88
|
+
} else if (operation.kind === 'tree.trash') {
|
|
89
|
+
const destination = trashPath(workspace.id, operation.nodeId);
|
|
90
|
+
requireMissing(destination, 'trash destination already exists');
|
|
91
|
+
fs.mkdirSync(path.dirname(destination), { recursive: true });
|
|
92
|
+
fs.renameSync(oldPath, destination);
|
|
93
|
+
} else if (operation.kind === 'tree.restore') {
|
|
94
|
+
const source = trashPath(workspace.id, operation.nodeId);
|
|
95
|
+
requireMissing(newPath, 'restore destination already exists');
|
|
96
|
+
fs.mkdirSync(path.dirname(newPath), { recursive: true });
|
|
97
|
+
fs.renameSync(source, newPath);
|
|
98
|
+
} else if (operation.kind === 'tree.purge') {
|
|
99
|
+
for (const node of before.values()) {
|
|
100
|
+
if (node.id === operation.nodeId || descendantsInclude(before, operation.nodeId, node.id)) {
|
|
101
|
+
fs.rmSync(trashPath(workspace.id, node.id), { recursive: true, force: true });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return { oldPath, newPath };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function absolutePathForWorkspace(workspaceId, relativePath, database) {
|
|
110
|
+
const workspace = database.prepare('SELECT path FROM workspaces WHERE id = ?').get(workspaceId);
|
|
111
|
+
if (!workspace) throw invalid('workspace not found', 404);
|
|
112
|
+
return relativePath ? path.join(workspace.path, ...relativePath.split('/')) : workspace.path;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function persistNodes(database, before, after, workspaceId) {
|
|
116
|
+
const now = new Date().toISOString();
|
|
117
|
+
const pathMemo = new Map();
|
|
118
|
+
const changedPaths = [];
|
|
119
|
+
for (const node of after.values()) {
|
|
120
|
+
const relativePath = relativePathForNode(after, node.id, pathMemo);
|
|
121
|
+
const previous = before.get(node.id);
|
|
122
|
+
const previousRelative = previous ? relativePathForNode(before, previous.id) : null;
|
|
123
|
+
if (!previous) {
|
|
124
|
+
const absolutePath = relativePath ? absolutePathForWorkspace(workspaceId, relativePath, database) : null;
|
|
125
|
+
const stats = absolutePath ? fs.lstatSync(absolutePath) : null;
|
|
126
|
+
database.prepare(`
|
|
127
|
+
INSERT INTO workspace_nodes (
|
|
128
|
+
id, workspace_id, parent_id, kind, name, relative_path, symlink_target,
|
|
129
|
+
device_id, inode, trashed_at, purged_at, missing_at, created_at, updated_at
|
|
130
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?)
|
|
131
|
+
`).run(
|
|
132
|
+
node.id, node.workspaceId, node.parentId, node.kind, node.name,
|
|
133
|
+
relativePath, node.symlinkTarget, stats ? String(stats.dev) : null,
|
|
134
|
+
stats ? String(stats.ino) : null, node.trashedAt, node.purgedAt,
|
|
135
|
+
node.createdAt, now,
|
|
136
|
+
);
|
|
137
|
+
} else {
|
|
138
|
+
database.prepare(`
|
|
139
|
+
UPDATE workspace_nodes
|
|
140
|
+
SET parent_id = ?, name = ?, relative_path = ?, trashed_at = ?,
|
|
141
|
+
purged_at = ?, missing_at = NULL, updated_at = ?
|
|
142
|
+
WHERE id = ?
|
|
143
|
+
`).run(node.parentId, node.name, relativePath, node.trashedAt, node.purgedAt, now, node.id);
|
|
144
|
+
if (previousRelative !== relativePath) {
|
|
145
|
+
changedPaths.push({
|
|
146
|
+
nodeId: node.id,
|
|
147
|
+
oldPath: absolutePathForWorkspace(workspaceId, previousRelative, database),
|
|
148
|
+
newPath: absolutePathForWorkspace(workspaceId, relativePath, database),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
return changedPaths;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function hasAncestorInSet(node, nodesById, ids) {
|
|
157
|
+
let current = node.parentId;
|
|
158
|
+
const seen = new Set();
|
|
159
|
+
while (current && !seen.has(current)) {
|
|
160
|
+
if (ids.has(current)) return true;
|
|
161
|
+
seen.add(current);
|
|
162
|
+
current = nodesById.get(current)?.parentId || null;
|
|
163
|
+
}
|
|
164
|
+
return false;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
module.exports = {
|
|
168
|
+
hasAncestorInSet,
|
|
169
|
+
materializeOperation,
|
|
170
|
+
persistNodes,
|
|
171
|
+
relativePathForNode,
|
|
172
|
+
rowToNode,
|
|
173
|
+
workspaceRow,
|
|
174
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const { isUuid } = require('./identity');
|
|
5
|
+
|
|
6
|
+
const CONTRACT = 'workspace-tree@1';
|
|
7
|
+
const KINDS = Object.freeze(['folder', 'file', 'symlink']);
|
|
8
|
+
const OPERATION_KINDS = Object.freeze([
|
|
9
|
+
'tree.create',
|
|
10
|
+
'tree.rename',
|
|
11
|
+
'tree.move',
|
|
12
|
+
'tree.trash',
|
|
13
|
+
'tree.restore',
|
|
14
|
+
'tree.purge',
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
function invalid(message) {
|
|
18
|
+
const error = new Error(message);
|
|
19
|
+
error.statusCode = 400;
|
|
20
|
+
return error;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function nodeId(value, field) {
|
|
24
|
+
if (!isUuid(value)) throw invalid(`${field} must be a UUID`);
|
|
25
|
+
return value.toLowerCase();
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function nodeName(value) {
|
|
29
|
+
const name = typeof value === 'string' ? value.trim() : '';
|
|
30
|
+
if (!name || name === '.' || name === '..' || name.length > 255) {
|
|
31
|
+
throw invalid('name must be between 1 and 255 characters');
|
|
32
|
+
}
|
|
33
|
+
if (name.includes('/') || name.includes('\\') || name.includes('\0') || path.isAbsolute(name)) {
|
|
34
|
+
throw invalid('name must be one path segment');
|
|
35
|
+
}
|
|
36
|
+
return name;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function optionalTimestamp(value, field) {
|
|
40
|
+
if (value == null) return null;
|
|
41
|
+
const date = new Date(value);
|
|
42
|
+
if (Number.isNaN(date.getTime())) throw invalid(`${field} must be an ISO timestamp`);
|
|
43
|
+
return date.toISOString();
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalizeTreeOperation(input) {
|
|
47
|
+
if (!input || typeof input !== 'object') throw invalid('tree operation is required');
|
|
48
|
+
if (!OPERATION_KINDS.includes(input.kind)) throw invalid(`unsupported tree operation ${JSON.stringify(input.kind)}`);
|
|
49
|
+
|
|
50
|
+
const operation = {
|
|
51
|
+
kind: input.kind,
|
|
52
|
+
workspaceId: nodeId(input.workspaceId, 'workspaceId'),
|
|
53
|
+
nodeId: nodeId(input.nodeId, 'nodeId'),
|
|
54
|
+
changedAt: optionalTimestamp(input.changedAt, 'changedAt') || new Date().toISOString(),
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
if (input.kind === 'tree.create') {
|
|
58
|
+
if (!KINDS.includes(input.nodeKind)) throw invalid('nodeKind must be folder, file, or symlink');
|
|
59
|
+
operation.nodeKind = input.nodeKind;
|
|
60
|
+
operation.parentId = nodeId(input.parentId, 'parentId');
|
|
61
|
+
operation.name = nodeName(input.name);
|
|
62
|
+
operation.createdAt = optionalTimestamp(input.createdAt, 'createdAt') || new Date().toISOString();
|
|
63
|
+
if (input.nodeKind === 'symlink') {
|
|
64
|
+
if (typeof input.symlinkTarget !== 'string' || !input.symlinkTarget) {
|
|
65
|
+
throw invalid('symlinkTarget is required for a symlink');
|
|
66
|
+
}
|
|
67
|
+
operation.symlinkTarget = input.symlinkTarget;
|
|
68
|
+
}
|
|
69
|
+
} else if (input.kind === 'tree.rename') {
|
|
70
|
+
operation.name = nodeName(input.name);
|
|
71
|
+
} else if (input.kind === 'tree.move') {
|
|
72
|
+
operation.parentId = nodeId(input.parentId, 'parentId');
|
|
73
|
+
if (input.previousParentId != null) operation.previousParentId = nodeId(input.previousParentId, 'previousParentId');
|
|
74
|
+
if (input.name != null) operation.name = nodeName(input.name);
|
|
75
|
+
} else if (input.kind === 'tree.trash') {
|
|
76
|
+
operation.trashedAt = optionalTimestamp(input.trashedAt, 'trashedAt') || new Date().toISOString();
|
|
77
|
+
} else if (input.kind === 'tree.restore') {
|
|
78
|
+
if (input.parentId != null) operation.parentId = nodeId(input.parentId, 'parentId');
|
|
79
|
+
if (input.name != null) operation.name = nodeName(input.name);
|
|
80
|
+
} else if (input.kind === 'tree.purge') {
|
|
81
|
+
operation.purgedAt = optionalTimestamp(input.purgedAt, 'purgedAt') || new Date().toISOString();
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return operation;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
module.exports = {
|
|
88
|
+
CONTRACT,
|
|
89
|
+
KINDS,
|
|
90
|
+
OPERATION_KINDS,
|
|
91
|
+
normalizeNodeName: nodeName,
|
|
92
|
+
normalizeTreeOperation,
|
|
93
|
+
};
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { normalizeTreeOperation } = require('./operations');
|
|
4
|
+
|
|
5
|
+
function toMap(nodes) {
|
|
6
|
+
if (nodes instanceof Map) return new Map(nodes);
|
|
7
|
+
return new Map((nodes || []).map((node) => [node.id, { ...node }]));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function descendantsInclude(nodes, ancestorId, candidateId) {
|
|
11
|
+
const seen = new Set();
|
|
12
|
+
let current = candidateId;
|
|
13
|
+
while (current) {
|
|
14
|
+
if (current === ancestorId) return true;
|
|
15
|
+
if (seen.has(current)) return true;
|
|
16
|
+
seen.add(current);
|
|
17
|
+
current = nodes.get(current)?.parentId || null;
|
|
18
|
+
}
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function ensureAvailableName(nodes, workspaceId, parentId, name, exceptId = null) {
|
|
23
|
+
const duplicate = Array.from(nodes.values()).find((node) => (
|
|
24
|
+
node.workspaceId === workspaceId
|
|
25
|
+
&& node.parentId === parentId
|
|
26
|
+
&& node.name === name
|
|
27
|
+
&& node.id !== exceptId
|
|
28
|
+
&& !node.purgedAt
|
|
29
|
+
&& !node.trashedAt
|
|
30
|
+
));
|
|
31
|
+
if (duplicate) throw Object.assign(new Error(`a node named ${JSON.stringify(name)} already exists there`), { statusCode: 409 });
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function applyTreeOperation(inputNodes, inputOperation) {
|
|
35
|
+
const nodes = toMap(inputNodes);
|
|
36
|
+
const operation = normalizeTreeOperation(inputOperation);
|
|
37
|
+
const existing = nodes.get(operation.nodeId);
|
|
38
|
+
|
|
39
|
+
if (operation.kind === 'tree.create') {
|
|
40
|
+
if (existing) throw Object.assign(new Error(`node ${operation.nodeId} already exists`), { statusCode: 409 });
|
|
41
|
+
const parent = nodes.get(operation.parentId);
|
|
42
|
+
if (!parent || parent.workspaceId !== operation.workspaceId || parent.kind !== 'folder' || parent.purgedAt || parent.trashedAt) {
|
|
43
|
+
throw Object.assign(new Error('parent folder does not exist'), { statusCode: 409 });
|
|
44
|
+
}
|
|
45
|
+
ensureAvailableName(nodes, operation.workspaceId, operation.parentId, operation.name);
|
|
46
|
+
nodes.set(operation.nodeId, {
|
|
47
|
+
id: operation.nodeId,
|
|
48
|
+
workspaceId: operation.workspaceId,
|
|
49
|
+
parentId: operation.parentId,
|
|
50
|
+
kind: operation.nodeKind,
|
|
51
|
+
name: operation.name,
|
|
52
|
+
symlinkTarget: operation.symlinkTarget || null,
|
|
53
|
+
trashedAt: null,
|
|
54
|
+
purgedAt: null,
|
|
55
|
+
createdAt: operation.createdAt,
|
|
56
|
+
updatedAt: operation.createdAt,
|
|
57
|
+
});
|
|
58
|
+
return nodes;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (!existing || existing.workspaceId !== operation.workspaceId) {
|
|
62
|
+
throw Object.assign(new Error(`node ${operation.nodeId} does not exist`), { statusCode: 404 });
|
|
63
|
+
}
|
|
64
|
+
if (existing.id === existing.workspaceId) {
|
|
65
|
+
throw Object.assign(new Error('the workspace root cannot be moved, renamed, trashed, restored, or purged'), { statusCode: 409 });
|
|
66
|
+
}
|
|
67
|
+
if (existing.purgedAt) {
|
|
68
|
+
throw Object.assign(new Error('a permanently removed node cannot be changed'), { statusCode: 409 });
|
|
69
|
+
}
|
|
70
|
+
if (existing.trashedAt && operation.kind !== 'tree.restore' && operation.kind !== 'tree.purge') {
|
|
71
|
+
throw Object.assign(new Error('a trashed node must be restored before it can be changed'), { statusCode: 409 });
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const next = { ...existing, updatedAt: operation.changedAt };
|
|
75
|
+
if (operation.kind === 'tree.rename') {
|
|
76
|
+
ensureAvailableName(nodes, operation.workspaceId, existing.parentId, operation.name, existing.id);
|
|
77
|
+
next.name = operation.name;
|
|
78
|
+
} else if (operation.kind === 'tree.move') {
|
|
79
|
+
const parent = nodes.get(operation.parentId);
|
|
80
|
+
if (!parent || parent.workspaceId !== operation.workspaceId || parent.kind !== 'folder' || parent.purgedAt || parent.trashedAt) {
|
|
81
|
+
throw Object.assign(new Error('destination folder does not exist'), { statusCode: 409 });
|
|
82
|
+
}
|
|
83
|
+
if (descendantsInclude(nodes, existing.id, operation.parentId)) {
|
|
84
|
+
throw Object.assign(new Error('a folder cannot move inside itself'), { statusCode: 409 });
|
|
85
|
+
}
|
|
86
|
+
const name = operation.name || existing.name;
|
|
87
|
+
ensureAvailableName(nodes, operation.workspaceId, operation.parentId, name, existing.id);
|
|
88
|
+
next.parentId = operation.parentId;
|
|
89
|
+
next.name = name;
|
|
90
|
+
} else if (operation.kind === 'tree.trash') {
|
|
91
|
+
if (existing.trashedAt) {
|
|
92
|
+
throw Object.assign(new Error('a node is already trashed'), { statusCode: 409 });
|
|
93
|
+
}
|
|
94
|
+
next.trashedAt = operation.trashedAt;
|
|
95
|
+
} else if (operation.kind === 'tree.restore') {
|
|
96
|
+
if (!existing.trashedAt) {
|
|
97
|
+
throw Object.assign(new Error('a node must be trashed before it can be restored'), { statusCode: 409 });
|
|
98
|
+
}
|
|
99
|
+
const parentId = operation.parentId || existing.parentId;
|
|
100
|
+
const name = operation.name || existing.name;
|
|
101
|
+
const parent = nodes.get(parentId);
|
|
102
|
+
if (!parent || parent.workspaceId !== operation.workspaceId || parent.kind !== 'folder' || parent.purgedAt || parent.trashedAt) {
|
|
103
|
+
throw Object.assign(new Error('restore destination does not exist'), { statusCode: 409 });
|
|
104
|
+
}
|
|
105
|
+
if (descendantsInclude(nodes, existing.id, parentId)) {
|
|
106
|
+
throw Object.assign(new Error('a folder cannot restore inside itself'), { statusCode: 409 });
|
|
107
|
+
}
|
|
108
|
+
ensureAvailableName(nodes, operation.workspaceId, parentId, name, existing.id);
|
|
109
|
+
next.parentId = parentId;
|
|
110
|
+
next.name = name;
|
|
111
|
+
next.trashedAt = null;
|
|
112
|
+
} else if (operation.kind === 'tree.purge') {
|
|
113
|
+
if (!existing.trashedAt) {
|
|
114
|
+
throw Object.assign(new Error('a node must be trashed before permanent removal'), { statusCode: 409 });
|
|
115
|
+
}
|
|
116
|
+
next.purgedAt = operation.purgedAt;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
nodes.set(next.id, next);
|
|
120
|
+
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;
|
|
123
|
+
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 });
|
|
127
|
+
} else if (operation.kind === 'tree.purge') {
|
|
128
|
+
nodes.set(child.id, { ...child, purgedAt: operation.purgedAt, updatedAt: operation.changedAt });
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
return nodes;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
module.exports = {
|
|
136
|
+
applyTreeOperation,
|
|
137
|
+
descendantsInclude,
|
|
138
|
+
};
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { isUuid } = require('./identity');
|
|
4
|
+
const { KINDS, normalizeNodeName } = require('./operations');
|
|
5
|
+
|
|
6
|
+
function invalid(message) {
|
|
7
|
+
throw Object.assign(new Error(message), { statusCode: 400 });
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
function timestamp(value, fallback) {
|
|
11
|
+
const date = new Date(value || fallback);
|
|
12
|
+
if (Number.isNaN(date.getTime())) invalid('workspace tree contains an invalid timestamp');
|
|
13
|
+
return date.toISOString();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function normalizeTreeSnapshot(input) {
|
|
17
|
+
if (
|
|
18
|
+
!input
|
|
19
|
+
|| input.schemaVersion !== 1
|
|
20
|
+
|| !Number.isSafeInteger(input.version)
|
|
21
|
+
|| input.version < 0
|
|
22
|
+
|| !isUuid(input.workspaceId)
|
|
23
|
+
|| !Array.isArray(input.nodes)
|
|
24
|
+
|| input.nodes.length > 200_000
|
|
25
|
+
) invalid('workspace-tree@1 snapshot has an invalid shape');
|
|
26
|
+
|
|
27
|
+
const workspaceId = input.workspaceId.toLowerCase();
|
|
28
|
+
const nodes = new Map();
|
|
29
|
+
for (const value of input.nodes) {
|
|
30
|
+
if (!isUuid(value?.id) || !isUuid(value?.workspaceId)) invalid('workspace tree node identity is invalid');
|
|
31
|
+
const id = value.id.toLowerCase();
|
|
32
|
+
const nodeWorkspaceId = value.workspaceId.toLowerCase();
|
|
33
|
+
if (nodeWorkspaceId !== workspaceId || nodes.has(id) || !KINDS.includes(value.kind)) {
|
|
34
|
+
invalid('workspace tree node identity or kind is invalid');
|
|
35
|
+
}
|
|
36
|
+
const parentId = value.parentId == null ? null : String(value.parentId).toLowerCase();
|
|
37
|
+
if (parentId && !isUuid(parentId)) invalid('workspace tree parent identity is invalid');
|
|
38
|
+
const createdAt = timestamp(value.createdAt, new Date().toISOString());
|
|
39
|
+
const updatedAt = timestamp(value.updatedAt, createdAt);
|
|
40
|
+
if (value.kind === 'symlink' && (typeof value.symlinkTarget !== 'string' || !value.symlinkTarget)) {
|
|
41
|
+
invalid('workspace tree symlink target is invalid');
|
|
42
|
+
}
|
|
43
|
+
nodes.set(id, {
|
|
44
|
+
id,
|
|
45
|
+
workspaceId,
|
|
46
|
+
parentId,
|
|
47
|
+
kind: value.kind,
|
|
48
|
+
name: normalizeNodeName(value.name),
|
|
49
|
+
symlinkTarget: value.kind === 'symlink' ? value.symlinkTarget : null,
|
|
50
|
+
trashedAt: value.trashedAt ? timestamp(value.trashedAt) : null,
|
|
51
|
+
purgedAt: value.purgedAt ? timestamp(value.purgedAt) : null,
|
|
52
|
+
createdAt,
|
|
53
|
+
updatedAt,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const root = nodes.get(workspaceId);
|
|
58
|
+
if (!root || root.parentId !== null || root.kind !== 'folder') {
|
|
59
|
+
invalid('workspace tree requires one folder root whose id is the workspace id');
|
|
60
|
+
}
|
|
61
|
+
const siblingNames = new Set();
|
|
62
|
+
for (const node of nodes.values()) {
|
|
63
|
+
if (node.id === workspaceId) continue;
|
|
64
|
+
if (!node.parentId || !nodes.has(node.parentId)) invalid('workspace tree node parent is missing');
|
|
65
|
+
const seen = new Set([node.id]);
|
|
66
|
+
let current = node;
|
|
67
|
+
while (current.parentId) {
|
|
68
|
+
if (seen.has(current.parentId)) invalid('workspace tree contains a cycle');
|
|
69
|
+
seen.add(current.parentId);
|
|
70
|
+
current = nodes.get(current.parentId);
|
|
71
|
+
if (!current) invalid('workspace tree node parent is missing');
|
|
72
|
+
}
|
|
73
|
+
if (!node.trashedAt && !node.purgedAt) {
|
|
74
|
+
const key = `${node.parentId}\0${node.name}`;
|
|
75
|
+
if (siblingNames.has(key)) invalid('workspace tree contains duplicate live sibling names');
|
|
76
|
+
siblingNames.add(key);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { schemaVersion: 1, workspaceId, version: input.version, nodes };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
module.exports = { normalizeTreeSnapshot };
|