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,391 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { AMALGM_DIR } = require('../config');
|
|
7
|
+
const { openLocalDb } = require('../state/db');
|
|
8
|
+
const { applyTreeOperation } = require('./tree/reducer');
|
|
9
|
+
const { relativeWorkspacePath, shouldSync } = require('./tree/inclusion');
|
|
10
|
+
const { normalizeTreeOperation } = require('./tree/operations');
|
|
11
|
+
const { normalizeTreeSnapshot } = require('./tree/snapshot');
|
|
12
|
+
const {
|
|
13
|
+
hasAncestorInSet,
|
|
14
|
+
materializeOperation,
|
|
15
|
+
persistNodes,
|
|
16
|
+
rowToNode,
|
|
17
|
+
workspaceRow,
|
|
18
|
+
} = require('./tree/local-state');
|
|
19
|
+
|
|
20
|
+
const TREE_RESOURCE_PREFIX = 'workspace-tree:';
|
|
21
|
+
|
|
22
|
+
function invalid(message, statusCode = 400) {
|
|
23
|
+
return Object.assign(new Error(message), { statusCode });
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function localTree() {
|
|
27
|
+
return require('./tree-store');
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function workspaceTreeSnapshot(workspaceId, options = {}) {
|
|
31
|
+
const database = options.database || openLocalDb();
|
|
32
|
+
const nodes = localTree().listWorkspaceNodes(workspaceId, {
|
|
33
|
+
database,
|
|
34
|
+
includeTrashed: true,
|
|
35
|
+
includePurged: true,
|
|
36
|
+
}).filter((node) => node.id === workspaceId || shouldSync(node.relativePath));
|
|
37
|
+
return { schemaVersion: 1, workspaceId, version: 0, nodes };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function decodeTreeSnapshot(input) {
|
|
41
|
+
if (typeof input?.snapshotBase64 !== 'string' || !input.snapshotBase64) {
|
|
42
|
+
throw invalid('snapshotBase64 is required');
|
|
43
|
+
}
|
|
44
|
+
const bytes = Buffer.from(input.snapshotBase64, 'base64');
|
|
45
|
+
const checksum = crypto.createHash('sha256').update(bytes).digest('hex');
|
|
46
|
+
if (checksum !== input.snapshotChecksum) throw invalid('snapshot checksum mismatch', 409);
|
|
47
|
+
let parsed;
|
|
48
|
+
try {
|
|
49
|
+
parsed = JSON.parse(bytes.toString('utf8'));
|
|
50
|
+
} catch {
|
|
51
|
+
throw invalid('workspace tree snapshot must be JSON');
|
|
52
|
+
}
|
|
53
|
+
return { bytes, checksum, snapshot: normalizeTreeSnapshot(parsed) };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function pathMapForSnapshot(snapshot) {
|
|
57
|
+
const paths = new Map([[snapshot.workspaceId, '']]);
|
|
58
|
+
const visit = (nodeId) => {
|
|
59
|
+
if (paths.has(nodeId)) return paths.get(nodeId);
|
|
60
|
+
const node = snapshot.nodes.get(nodeId);
|
|
61
|
+
if (!node) throw invalid('workspace tree node is missing', 409);
|
|
62
|
+
const parentPath = visit(node.parentId);
|
|
63
|
+
const relativePath = parentPath ? `${parentPath}/${node.name}` : node.name;
|
|
64
|
+
paths.set(nodeId, relativePath);
|
|
65
|
+
return relativePath;
|
|
66
|
+
};
|
|
67
|
+
for (const node of snapshot.nodes.values()) visit(node.id);
|
|
68
|
+
return paths;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function safeSymlinkTarget(rootPath, linkPath, target) {
|
|
72
|
+
if (path.isAbsolute(target)) return false;
|
|
73
|
+
const resolved = path.resolve(path.dirname(linkPath), target);
|
|
74
|
+
return relativeWorkspacePath(rootPath, resolved) != null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function activeNode(node) {
|
|
78
|
+
return Boolean(node && !node.trashedAt && !node.purgedAt);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function normalizeSnapshotPermissions(input, snapshot) {
|
|
82
|
+
const actorUserId = require('./identity').userId();
|
|
83
|
+
if (!actorUserId) return [];
|
|
84
|
+
const provided = Array.isArray(input?.permissions) ? input.permissions : null;
|
|
85
|
+
const values = provided || (
|
|
86
|
+
['owner', 'editor', 'viewer'].includes(input?.role)
|
|
87
|
+
? [{ node_id: snapshot.workspaceId, role: input.role }]
|
|
88
|
+
: []
|
|
89
|
+
);
|
|
90
|
+
const seen = new Set();
|
|
91
|
+
return values.map((value) => {
|
|
92
|
+
const nodeId = String(value?.node_id || value?.nodeId || '').toLowerCase();
|
|
93
|
+
const role = value?.role;
|
|
94
|
+
if (!snapshot.nodes.has(nodeId) || !['owner', 'editor', 'viewer', 'no_access'].includes(role)) {
|
|
95
|
+
throw invalid('workspace tree snapshot permission is invalid', 409);
|
|
96
|
+
}
|
|
97
|
+
if (seen.has(nodeId)) throw invalid('workspace tree snapshot permission is duplicated', 409);
|
|
98
|
+
seen.add(nodeId);
|
|
99
|
+
return { nodeId, userId: actorUserId, role };
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function replaceTreeProjection(database, workspaceId, snapshot, paths, permissions, authorityEpoch) {
|
|
104
|
+
const ordered = Array.from(snapshot.nodes.values()).sort((left, right) => {
|
|
105
|
+
const leftPath = paths.get(left.id);
|
|
106
|
+
const rightPath = paths.get(right.id);
|
|
107
|
+
return (leftPath ? leftPath.split('/').length : 0) - (rightPath ? rightPath.split('/').length : 0);
|
|
108
|
+
});
|
|
109
|
+
database.transaction(() => {
|
|
110
|
+
const removedIds = database.prepare('SELECT id FROM workspace_nodes WHERE workspace_id = ?').all(workspaceId)
|
|
111
|
+
.map((row) => row.id)
|
|
112
|
+
.filter((nodeId) => !snapshot.nodes.has(nodeId));
|
|
113
|
+
if (removedIds.length) {
|
|
114
|
+
const placeholders = removedIds.map(() => '?').join(',');
|
|
115
|
+
database.prepare(`DELETE FROM doc_states WHERE node_id IN (${placeholders})`).run(...removedIds);
|
|
116
|
+
database.prepare(`DELETE FROM shared_resource_replicas WHERE resource_id IN (${placeholders})`).run(...removedIds);
|
|
117
|
+
database.prepare(`DELETE FROM shared_resource_attachments WHERE resource_id IN (${placeholders})`).run(...removedIds);
|
|
118
|
+
}
|
|
119
|
+
database.prepare('DELETE FROM workspace_node_permissions WHERE workspace_id = ?').run(workspaceId);
|
|
120
|
+
database.prepare('DELETE FROM workspace_nodes WHERE workspace_id = ?').run(workspaceId);
|
|
121
|
+
const insert = database.prepare(`
|
|
122
|
+
INSERT INTO workspace_nodes (
|
|
123
|
+
id, workspace_id, parent_id, kind, name, relative_path, symlink_target,
|
|
124
|
+
trashed_at, purged_at, created_at, updated_at
|
|
125
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
126
|
+
`);
|
|
127
|
+
for (const node of ordered) {
|
|
128
|
+
insert.run(
|
|
129
|
+
node.id, node.workspaceId, node.parentId, node.kind, node.name,
|
|
130
|
+
paths.get(node.id), node.symlinkTarget, node.trashedAt, node.purgedAt,
|
|
131
|
+
node.createdAt, node.updatedAt,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
const insertPermission = database.prepare(`
|
|
135
|
+
INSERT INTO workspace_node_permissions(workspace_id, node_id, user_id, role)
|
|
136
|
+
VALUES (?, ?, ?, ?)
|
|
137
|
+
`);
|
|
138
|
+
for (const permission of permissions) {
|
|
139
|
+
insertPermission.run(workspaceId, permission.nodeId, permission.userId, permission.role);
|
|
140
|
+
}
|
|
141
|
+
database.prepare(`
|
|
142
|
+
UPDATE workspace_access_state SET authority_epoch = ?, updated_at = ? WHERE workspace_id = ?
|
|
143
|
+
`).run(Number(authorityEpoch), new Date().toISOString(), workspaceId);
|
|
144
|
+
})();
|
|
145
|
+
return ordered;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function createProjectedNode(rootPath, node, relativePath) {
|
|
149
|
+
const target = path.join(rootPath, ...relativePath.split('/'));
|
|
150
|
+
if (node.kind === 'folder') fs.mkdirSync(target, { recursive: true });
|
|
151
|
+
else if (node.kind === 'file') {
|
|
152
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
153
|
+
if (!fs.existsSync(target)) fs.writeFileSync(target, '');
|
|
154
|
+
} else if (!fs.existsSync(target) && safeSymlinkTarget(rootPath, target, node.symlinkTarget)) {
|
|
155
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
156
|
+
fs.symlinkSync(node.symlinkTarget, target);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function materializeTreeProjection(rootPath, before, afterSnapshot, beforePaths, afterPaths) {
|
|
161
|
+
const after = afterSnapshot.nodes;
|
|
162
|
+
const removedIds = new Set();
|
|
163
|
+
for (const node of before.values()) {
|
|
164
|
+
if (node.id === node.workspaceId || !activeNode(node)) continue;
|
|
165
|
+
if (!activeNode(after.get(node.id))) removedIds.add(node.id);
|
|
166
|
+
}
|
|
167
|
+
const removedRoots = Array.from(removedIds)
|
|
168
|
+
.map((id) => before.get(id))
|
|
169
|
+
.filter((node) => !hasAncestorInSet(node, before, removedIds));
|
|
170
|
+
require('../state/docs').closeDocsByNodeIds(removedIds);
|
|
171
|
+
for (const node of removedRoots) {
|
|
172
|
+
const relative = beforePaths.get(node.id);
|
|
173
|
+
if (relative) fs.rmSync(path.join(rootPath, ...relative.split('/')), { recursive: true, force: true });
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const changedIds = new Set();
|
|
177
|
+
for (const node of after.values()) {
|
|
178
|
+
if (node.id === afterSnapshot.workspaceId || !activeNode(node) || !activeNode(before.get(node.id))) continue;
|
|
179
|
+
if (beforePaths.get(node.id) !== afterPaths.get(node.id)) changedIds.add(node.id);
|
|
180
|
+
}
|
|
181
|
+
const changedRoots = Array.from(changedIds)
|
|
182
|
+
.map((id) => after.get(id))
|
|
183
|
+
.filter((node) => !hasAncestorInSet(node, after, changedIds));
|
|
184
|
+
const stagingRoot = path.join(AMALGM_DIR, 'staging', 'workspace-tree', crypto.randomBytes(12).toString('hex'));
|
|
185
|
+
const staged = [];
|
|
186
|
+
for (const node of changedRoots) {
|
|
187
|
+
const oldRelative = beforePaths.get(node.id);
|
|
188
|
+
const oldPath = path.join(rootPath, ...oldRelative.split('/'));
|
|
189
|
+
if (!fs.existsSync(oldPath)) continue;
|
|
190
|
+
const stagedPath = path.join(stagingRoot, node.id);
|
|
191
|
+
fs.mkdirSync(path.dirname(stagedPath), { recursive: true });
|
|
192
|
+
fs.renameSync(oldPath, stagedPath);
|
|
193
|
+
staged.push({ node, stagedPath });
|
|
194
|
+
}
|
|
195
|
+
for (const item of staged) {
|
|
196
|
+
const nextPath = path.join(rootPath, ...afterPaths.get(item.node.id).split('/'));
|
|
197
|
+
fs.mkdirSync(path.dirname(nextPath), { recursive: true });
|
|
198
|
+
fs.renameSync(item.stagedPath, nextPath);
|
|
199
|
+
}
|
|
200
|
+
fs.rmSync(stagingRoot, { recursive: true, force: true });
|
|
201
|
+
|
|
202
|
+
const ordered = Array.from(after.values()).sort((left, right) => {
|
|
203
|
+
const leftPath = afterPaths.get(left.id);
|
|
204
|
+
const rightPath = afterPaths.get(right.id);
|
|
205
|
+
return (leftPath ? leftPath.split('/').length : 0) - (rightPath ? rightPath.split('/').length : 0);
|
|
206
|
+
});
|
|
207
|
+
for (const node of ordered) {
|
|
208
|
+
if (node.id === afterSnapshot.workspaceId || !activeNode(node)) continue;
|
|
209
|
+
const target = path.join(rootPath, ...afterPaths.get(node.id).split('/'));
|
|
210
|
+
if (!fs.existsSync(target)) createProjectedNode(rootPath, node, afterPaths.get(node.id));
|
|
211
|
+
}
|
|
212
|
+
const changedPaths = Array.from(changedIds).map((nodeId) => ({
|
|
213
|
+
nodeId,
|
|
214
|
+
oldPath: path.join(rootPath, ...beforePaths.get(nodeId).split('/')),
|
|
215
|
+
newPath: path.join(rootPath, ...afterPaths.get(nodeId).split('/')),
|
|
216
|
+
}));
|
|
217
|
+
if (changedPaths.length) require('../state/docs').rebindNodePaths(changedPaths);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function enqueueProjectedContentAttachments(rootPath, snapshot, paths, input, database) {
|
|
221
|
+
const resources = Array.isArray(input?.contentResources) ? input.contentResources : [];
|
|
222
|
+
const queued = [];
|
|
223
|
+
for (const resource of resources) {
|
|
224
|
+
const nodeId = String(resource?.node_id || resource?.nodeId || '').toLowerCase();
|
|
225
|
+
const node = snapshot.nodes.get(nodeId);
|
|
226
|
+
if (!node || node.kind !== 'file' || !activeNode(node) || resource?.contract !== 'text-yjs@1') continue;
|
|
227
|
+
if (require('../state/replicas').getReplicaByResourceId(nodeId, { database })) continue;
|
|
228
|
+
if (require('../state/attachments').getAttachmentByResource(nodeId, { database })) continue;
|
|
229
|
+
const localPath = path.join(rootPath, ...paths.get(nodeId).split('/'));
|
|
230
|
+
queued.push(require('../state/attachments').enqueueAttachment({
|
|
231
|
+
attachmentId: `att_${crypto.randomBytes(16).toString('base64url')}`,
|
|
232
|
+
resourceId: nodeId,
|
|
233
|
+
localPath,
|
|
234
|
+
}, { database }).attachment);
|
|
235
|
+
}
|
|
236
|
+
return queued;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function enqueueWorkspaceContentAttachments(workspaceId, contentResources, options = {}) {
|
|
240
|
+
const database = options.database || openLocalDb();
|
|
241
|
+
const tree = localTree();
|
|
242
|
+
const workspace = workspaceRow(workspaceId, database);
|
|
243
|
+
if (!workspace) throw invalid('workspace tree has no local folder', 404);
|
|
244
|
+
const nodes = new Map(tree.listWorkspaceNodes(workspace.id, { database })
|
|
245
|
+
.map((node) => [node.id, node]));
|
|
246
|
+
const snapshot = { workspaceId: workspace.id, nodes };
|
|
247
|
+
return enqueueProjectedContentAttachments(
|
|
248
|
+
workspace.path,
|
|
249
|
+
snapshot,
|
|
250
|
+
pathMapForSnapshot(snapshot),
|
|
251
|
+
{ contentResources },
|
|
252
|
+
database,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
function installCloudTreeSnapshot(pathInput, input, options = {}) {
|
|
257
|
+
if (input?.contract !== 'workspace-tree@1' || Number(input?.schemaVersion) !== 1) {
|
|
258
|
+
throw invalid('unsupported workspace tree contract', 409);
|
|
259
|
+
}
|
|
260
|
+
const database = options.database || openLocalDb();
|
|
261
|
+
const { checksum, snapshot } = decodeTreeSnapshot(input);
|
|
262
|
+
if (snapshot.workspaceId !== input.resourceId) throw invalid('workspace tree identity mismatch', 409);
|
|
263
|
+
const rootPath = path.resolve(pathInput);
|
|
264
|
+
fs.mkdirSync(rootPath, { recursive: true });
|
|
265
|
+
const existingReplica = require('../state/replicas').getReplicaByResourceId(input.resourceId, { database });
|
|
266
|
+
const paths = pathMapForSnapshot(snapshot);
|
|
267
|
+
const permissions = normalizeSnapshotPermissions(input, snapshot);
|
|
268
|
+
if (existingReplica) {
|
|
269
|
+
if (existingReplica.localPath !== rootPath) throw invalid('workspace tree is bound to another folder', 409);
|
|
270
|
+
const before = new Map(
|
|
271
|
+
database.prepare('SELECT * FROM workspace_nodes WHERE workspace_id = ?').all(snapshot.workspaceId)
|
|
272
|
+
.map(rowToNode)
|
|
273
|
+
.map((node) => [node.id, node]),
|
|
274
|
+
);
|
|
275
|
+
const beforePaths = pathMapForSnapshot({ workspaceId: snapshot.workspaceId, nodes: before });
|
|
276
|
+
materializeTreeProjection(rootPath, before, snapshot, beforePaths, paths);
|
|
277
|
+
replaceTreeProjection(database, snapshot.workspaceId, snapshot, paths, permissions, input.authorityEpoch);
|
|
278
|
+
const replica = require('../state/replicas').advanceReplicaSnapshot({
|
|
279
|
+
resourceId: input.resourceId,
|
|
280
|
+
snapshotVersion: input.snapshotVersion,
|
|
281
|
+
snapshotChecksum: checksum,
|
|
282
|
+
authorityEpoch: input.authorityEpoch,
|
|
283
|
+
}, { database });
|
|
284
|
+
const contentAttachments = enqueueProjectedContentAttachments(rootPath, snapshot, paths, input, database);
|
|
285
|
+
require('../state/events').appendStateEvent({
|
|
286
|
+
resource: `${TREE_RESOURCE_PREFIX}${snapshot.workspaceId}`,
|
|
287
|
+
op: 'update', id: snapshot.workspaceId,
|
|
288
|
+
value: workspaceTreeSnapshot(snapshot.workspaceId, { database }),
|
|
289
|
+
sharedResourceId: snapshot.workspaceId,
|
|
290
|
+
source: 'workspace-tree:cloud-resnapshot',
|
|
291
|
+
resourceVersion: Number(input.snapshotVersion),
|
|
292
|
+
});
|
|
293
|
+
return { replica, contentAttachments, duplicate: false };
|
|
294
|
+
}
|
|
295
|
+
if (fs.readdirSync(rootPath).length > 0) {
|
|
296
|
+
throw invalid('cloud workspace attachment requires an empty folder', 409);
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const root = snapshot.nodes.get(snapshot.workspaceId);
|
|
300
|
+
const workspace = require('./store').upsertWorkspace({
|
|
301
|
+
id: snapshot.workspaceId,
|
|
302
|
+
path: rootPath,
|
|
303
|
+
name: root.name,
|
|
304
|
+
source: 'cloud',
|
|
305
|
+
}, { source: 'workspace:cloud-attach', seedOwner: false });
|
|
306
|
+
const ordered = replaceTreeProjection(
|
|
307
|
+
database, workspace.id, snapshot, paths, permissions, input.authorityEpoch,
|
|
308
|
+
);
|
|
309
|
+
for (const node of ordered) {
|
|
310
|
+
if (node.id === workspace.id || node.trashedAt || node.purgedAt) continue;
|
|
311
|
+
createProjectedNode(rootPath, node, paths.get(node.id));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const registered = require('../state/replicas').registerReplica({
|
|
315
|
+
resourceId: input.resourceId,
|
|
316
|
+
localResource: `${TREE_RESOURCE_PREFIX}${workspace.id}`,
|
|
317
|
+
localPath: rootPath,
|
|
318
|
+
contract: input.contract,
|
|
319
|
+
schemaVersion: input.schemaVersion,
|
|
320
|
+
authorityEpoch: input.authorityEpoch,
|
|
321
|
+
appliedVersion: input.snapshotVersion,
|
|
322
|
+
snapshotVersion: input.snapshotVersion,
|
|
323
|
+
snapshotChecksum: checksum,
|
|
324
|
+
}, { database });
|
|
325
|
+
const contentAttachments = enqueueProjectedContentAttachments(rootPath, snapshot, paths, input, database);
|
|
326
|
+
require('../state/events').appendStateEvent({
|
|
327
|
+
resource: `${TREE_RESOURCE_PREFIX}${workspace.id}`,
|
|
328
|
+
op: 'update',
|
|
329
|
+
id: workspace.id,
|
|
330
|
+
value: workspaceTreeSnapshot(workspace.id, { database }),
|
|
331
|
+
sharedResourceId: workspace.id,
|
|
332
|
+
source: 'workspace-tree:cloud-snapshot',
|
|
333
|
+
resourceVersion: Number(input.snapshotVersion),
|
|
334
|
+
});
|
|
335
|
+
return { workspace, replica: registered.replica, contentAttachments, duplicate: false };
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function applyCloudTreeMutation(input, options = {}) {
|
|
339
|
+
const envelope = input?.envelope || {};
|
|
340
|
+
if (envelope.contract !== 'workspace-tree@1') throw invalid('unsupported workspace tree mutation', 409);
|
|
341
|
+
const operation = normalizeTreeOperation(envelope.operation);
|
|
342
|
+
if (operation.workspaceId !== envelope.resourceId || operation.kind !== envelope.operationKind) {
|
|
343
|
+
throw invalid('workspace tree mutation identity mismatch', 409);
|
|
344
|
+
}
|
|
345
|
+
const database = options.database || openLocalDb();
|
|
346
|
+
const tree = localTree();
|
|
347
|
+
const workspace = workspaceRow(operation.workspaceId, database);
|
|
348
|
+
if (!workspace) throw invalid('workspace tree has no local folder', 404);
|
|
349
|
+
const before = new Map(
|
|
350
|
+
database.prepare('SELECT * FROM workspace_nodes WHERE workspace_id = ?').all(workspace.id)
|
|
351
|
+
.map(rowToNode)
|
|
352
|
+
.map((node) => [node.id, node]),
|
|
353
|
+
);
|
|
354
|
+
const after = applyTreeOperation(before, operation);
|
|
355
|
+
let paths;
|
|
356
|
+
return require('../state/mutations').ingestCloudMutation({
|
|
357
|
+
...input,
|
|
358
|
+
localResource: `${TREE_RESOURCE_PREFIX}${workspace.id}`,
|
|
359
|
+
}, {
|
|
360
|
+
database,
|
|
361
|
+
materialize() {
|
|
362
|
+
if (operation.kind === 'tree.trash' || operation.kind === 'tree.purge') {
|
|
363
|
+
const affected = new Set(
|
|
364
|
+
Array.from(before.values())
|
|
365
|
+
.filter((node) => node.id === operation.nodeId || hasAncestorInSet(node, before, new Set([operation.nodeId])))
|
|
366
|
+
.map((node) => node.id),
|
|
367
|
+
);
|
|
368
|
+
require('../state/docs').closeDocsByNodeIds(affected);
|
|
369
|
+
}
|
|
370
|
+
paths = materializeOperation(workspace, before, after, operation);
|
|
371
|
+
paths.changedPaths = persistNodes(database, before, after, workspace.id);
|
|
372
|
+
if (paths.changedPaths.length) require('../state/docs').rebindNodePaths(paths.changedPaths);
|
|
373
|
+
},
|
|
374
|
+
event: () => ({
|
|
375
|
+
resource: `${TREE_RESOURCE_PREFIX}${workspace.id}`,
|
|
376
|
+
op: 'update',
|
|
377
|
+
id: operation.nodeId,
|
|
378
|
+
patch: operation,
|
|
379
|
+
sharedResourceId: workspace.id,
|
|
380
|
+
source: 'workspace-tree:cloud',
|
|
381
|
+
resourceVersion: Number(input.version),
|
|
382
|
+
}),
|
|
383
|
+
});
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
module.exports = {
|
|
387
|
+
applyCloudTreeMutation,
|
|
388
|
+
enqueueWorkspaceContentAttachments,
|
|
389
|
+
installCloudTreeSnapshot,
|
|
390
|
+
workspaceTreeSnapshot,
|
|
391
|
+
};
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const { openLocalDb } = require('../state/db');
|
|
6
|
+
const { shouldSync } = require('./tree/inclusion');
|
|
7
|
+
const { workspaceRow } = require('./tree/local-state');
|
|
8
|
+
|
|
9
|
+
const TREE_RESOURCE_PREFIX = 'workspace-tree:';
|
|
10
|
+
const MAX_TREE_SNAPSHOT_BYTES = 4 * 1024 * 1024;
|
|
11
|
+
|
|
12
|
+
function invalid(message, statusCode = 400) {
|
|
13
|
+
return Object.assign(new Error(message), { statusCode });
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function ensureWorkspaceTreePromotion(workspaceId, options = {}) {
|
|
17
|
+
if (!require('./identity').userId()) return null;
|
|
18
|
+
const database = options.database || openLocalDb();
|
|
19
|
+
const localResource = `${TREE_RESOURCE_PREFIX}${workspaceId}`;
|
|
20
|
+
const replicas = require('../state/replicas');
|
|
21
|
+
const promotions = require('../state/promotions');
|
|
22
|
+
const tree = require('./tree-store');
|
|
23
|
+
const replica = replicas.getReplicaByLocalResource(localResource, { database });
|
|
24
|
+
if (replica) return { replica, duplicate: true };
|
|
25
|
+
const existing = promotions.getPromotionByResource(workspaceId, { database });
|
|
26
|
+
if (existing) return { promotion: existing, duplicate: true };
|
|
27
|
+
const workspace = workspaceRow(workspaceId, database);
|
|
28
|
+
if (!workspace) throw invalid('workspace not found', 404);
|
|
29
|
+
const bytes = Buffer.from(JSON.stringify(tree.workspaceTreeSnapshot(workspaceId, { database })), 'utf8');
|
|
30
|
+
if (bytes.length > MAX_TREE_SNAPSHOT_BYTES) {
|
|
31
|
+
throw invalid(`workspace tree snapshot exceeds ${MAX_TREE_SNAPSHOT_BYTES} bytes`, 413);
|
|
32
|
+
}
|
|
33
|
+
return promotions.enqueuePromotion({
|
|
34
|
+
promotionId: `prm_${crypto.randomBytes(16).toString('base64url')}`,
|
|
35
|
+
resourceId: workspaceId,
|
|
36
|
+
localResource,
|
|
37
|
+
localPath: workspace.path,
|
|
38
|
+
contract: 'workspace-tree@1',
|
|
39
|
+
schemaVersion: 1,
|
|
40
|
+
snapshotBase64: bytes.toString('base64'),
|
|
41
|
+
contentBytes: bytes.length,
|
|
42
|
+
name: workspace.name,
|
|
43
|
+
mediaType: 'application/vnd.amalgm.workspace-tree+json',
|
|
44
|
+
}, { database });
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function promoteEligibleWorkspaceTextFiles(options = {}) {
|
|
48
|
+
if (!require('./identity').userId()) return [];
|
|
49
|
+
const database = options.database || openLocalDb();
|
|
50
|
+
const tree = require('./tree-store');
|
|
51
|
+
const replicas = require('../state/replicas');
|
|
52
|
+
const promotions = require('../state/promotions');
|
|
53
|
+
const results = [];
|
|
54
|
+
for (const workspace of database.prepare('SELECT id FROM workspaces ORDER BY created_at').all()) {
|
|
55
|
+
const treeReplica = replicas.getReplicaByLocalResource(
|
|
56
|
+
`${TREE_RESOURCE_PREFIX}${workspace.id}`,
|
|
57
|
+
{ database },
|
|
58
|
+
);
|
|
59
|
+
if (!treeReplica) continue;
|
|
60
|
+
for (const node of tree.listWorkspaceNodes(workspace.id, { database })) {
|
|
61
|
+
if (node.kind !== 'file' || !shouldSync(node.relativePath)) continue;
|
|
62
|
+
if (promotions.getPromotionByResource(node.id, { database })) continue;
|
|
63
|
+
if (replicas.getReplicaByResourceId(node.id, { database })) continue;
|
|
64
|
+
const filePath = tree.absolutePathForNode(node.id, { database });
|
|
65
|
+
if (!filePath) continue;
|
|
66
|
+
try {
|
|
67
|
+
const classification = require('../fs/rest')._private.classifyFile(filePath);
|
|
68
|
+
if (!classification.isText) continue;
|
|
69
|
+
const opened = require('../state/docs').openDoc(filePath);
|
|
70
|
+
results.push(promotions.enqueuePromotion({
|
|
71
|
+
promotionId: `prm_${crypto.randomBytes(16).toString('base64url')}`,
|
|
72
|
+
resourceId: node.id,
|
|
73
|
+
localResource: opened.resource,
|
|
74
|
+
localPath: opened.path,
|
|
75
|
+
contract: 'text-yjs@1',
|
|
76
|
+
schemaVersion: 1,
|
|
77
|
+
snapshotBase64: opened.state,
|
|
78
|
+
contentBytes: fs.statSync(opened.path).size,
|
|
79
|
+
name: node.name,
|
|
80
|
+
mediaType: 'application/vnd.amalgm.text-yjs-snapshot',
|
|
81
|
+
}, { database }));
|
|
82
|
+
} catch (error) {
|
|
83
|
+
console.warn(`[WorkspaceTree] text promotion skipped for ${filePath}:`, error?.message || error);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return results;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
module.exports = {
|
|
91
|
+
MAX_TREE_SNAPSHOT_BYTES,
|
|
92
|
+
ensureWorkspaceTreePromotion,
|
|
93
|
+
promoteEligibleWorkspaceTextFiles,
|
|
94
|
+
};
|