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
|
@@ -369,9 +369,13 @@ function createSharedRealtimeTunnel({
|
|
|
369
369
|
snapshotBase64: frame.snapshot_b64,
|
|
370
370
|
contract: frame.contract,
|
|
371
371
|
schemaVersion: frame.schema_version,
|
|
372
|
+
role: frame.role,
|
|
373
|
+
permissions: frame.permissions,
|
|
374
|
+
contentResources: frame.content_resources,
|
|
372
375
|
}).then(() => {
|
|
373
376
|
attachmentInflight.delete(frame.request_id);
|
|
374
377
|
void joinReplicas();
|
|
378
|
+
scheduleAttachmentDrain();
|
|
375
379
|
}).catch((error) => {
|
|
376
380
|
void localStateJson('POST', '/state/shared/attachments/fail', {
|
|
377
381
|
attachmentId: identity.attachmentId,
|
|
@@ -457,7 +461,11 @@ function createSharedRealtimeTunnel({
|
|
|
457
461
|
const joinedResource = joinInflight.get(frame.request_id);
|
|
458
462
|
if (joinedResource) {
|
|
459
463
|
joinInflight.delete(frame.request_id);
|
|
460
|
-
if (
|
|
464
|
+
if (
|
|
465
|
+
frame.code === 'shared_tail_pruned'
|
|
466
|
+
|| frame.code === 'shared_tail_too_large'
|
|
467
|
+
|| frame.code === 'workspace_tree_snapshot_required'
|
|
468
|
+
) {
|
|
461
469
|
// The official tail can no longer reach this replica's cursor —
|
|
462
470
|
// compaction pruned past it, or the gap outgrew incremental
|
|
463
471
|
// recovery. Recover through a fresh snapshot attachment; its ack
|
|
@@ -547,6 +555,13 @@ function createSharedRealtimeTunnel({
|
|
|
547
555
|
case 'shared.resource.attached':
|
|
548
556
|
handleAttached(frame);
|
|
549
557
|
return true;
|
|
558
|
+
case 'shared.resource.resync':
|
|
559
|
+
void localStateJson('POST', '/state/shared/replicas/rehydrate', {
|
|
560
|
+
resourceId: frame.resource_id,
|
|
561
|
+
}).then(() => scheduleAttachmentDrain()).catch((error) => {
|
|
562
|
+
warn(`shared tree resync request failed for ${frame.resource_id}: ${error?.message || error}`);
|
|
563
|
+
});
|
|
564
|
+
return true;
|
|
550
565
|
case 'shared.mutation.committed':
|
|
551
566
|
handleCommitted(frame);
|
|
552
567
|
return true;
|
|
@@ -561,6 +576,20 @@ function createSharedRealtimeTunnel({
|
|
|
561
576
|
headVersion: frame.head_version,
|
|
562
577
|
contract: frame.contract,
|
|
563
578
|
schemaVersion: frame.schema_version,
|
|
579
|
+
}).then(async (replica) => {
|
|
580
|
+
if (frame.contract === 'workspace-tree@1' && replica.authorityChanged) {
|
|
581
|
+
await localStateJson('POST', '/state/shared/replicas/rehydrate', {
|
|
582
|
+
resourceId: frame.resource_id,
|
|
583
|
+
});
|
|
584
|
+
scheduleAttachmentDrain();
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
if (frame.contract !== 'workspace-tree@1' || !Array.isArray(frame.content_resources)) return;
|
|
588
|
+
await localStateJson('POST', '/state/shared/workspace-content/discover', {
|
|
589
|
+
workspaceId: frame.resource_id,
|
|
590
|
+
contentResources: frame.content_resources,
|
|
591
|
+
});
|
|
592
|
+
scheduleAttachmentDrain();
|
|
564
593
|
}).catch(() => {});
|
|
565
594
|
return true;
|
|
566
595
|
case 'private.resource.ready':
|
package/package.json
CHANGED
|
@@ -704,6 +704,10 @@ async function handleWrite(body, sendJson) {
|
|
|
704
704
|
? Buffer.from(String(body.content), 'base64')
|
|
705
705
|
: Buffer.from(String(body.content), 'utf-8');
|
|
706
706
|
|
|
707
|
+
const tree = require('../workspace/tree-store');
|
|
708
|
+
const existingNode = tree.nodeForPath(targetPath);
|
|
709
|
+
if (existingNode) tree.requirePathCapability(targetPath, 'edit');
|
|
710
|
+
else tree.commitPathCreate(targetPath, 'file', { source: 'fs:write:create' });
|
|
707
711
|
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
|
|
708
712
|
await fs.promises.writeFile(targetPath, data);
|
|
709
713
|
notifyProjectContextPathChanged(targetPath, 'fs:write');
|
|
@@ -720,7 +724,10 @@ async function handleDelete(body, sendJson) {
|
|
|
720
724
|
try {
|
|
721
725
|
const targetPath = await resolveSafePath(body.path);
|
|
722
726
|
const stats = await fs.promises.lstat(targetPath).catch(() => null);
|
|
723
|
-
|
|
727
|
+
const treeResult = require('../workspace/tree-store').commitPathTrash(targetPath, {
|
|
728
|
+
source: 'fs:trash',
|
|
729
|
+
});
|
|
730
|
+
if (!treeResult) await fs.promises.rm(targetPath, { recursive: true, force: true });
|
|
724
731
|
notifyProjectContextPathChanged(targetPath, 'fs:delete');
|
|
725
732
|
await publishFilesystemChange([targetPath], 'fs:delete');
|
|
726
733
|
if (stats?.isDirectory()) {
|
|
@@ -732,7 +739,12 @@ async function handleDelete(body, sendJson) {
|
|
|
732
739
|
source: 'fs:delete',
|
|
733
740
|
});
|
|
734
741
|
}
|
|
735
|
-
sendJson(200, {
|
|
742
|
+
sendJson(200, {
|
|
743
|
+
success: true,
|
|
744
|
+
path: targetPath,
|
|
745
|
+
trashed: Boolean(treeResult),
|
|
746
|
+
nodeId: treeResult?.operation?.nodeId || null,
|
|
747
|
+
});
|
|
736
748
|
} catch (error) {
|
|
737
749
|
const message = error instanceof Error ? error.message : 'Failed to delete';
|
|
738
750
|
sendJson(statusForError(error), { error: message });
|
|
@@ -742,10 +754,12 @@ async function handleDelete(body, sendJson) {
|
|
|
742
754
|
async function handleMkdir(body, sendJson) {
|
|
743
755
|
try {
|
|
744
756
|
const targetPath = await resolveSafePath(body.path, { forCreate: true });
|
|
745
|
-
|
|
757
|
+
const treeResult = require('../workspace/tree-store')
|
|
758
|
+
.commitPathCreate(targetPath, 'folder', { source: 'fs:mkdir' });
|
|
759
|
+
if (!treeResult) await fs.promises.mkdir(targetPath, { recursive: true, mode: 0o755 });
|
|
746
760
|
await publishFilesystemChange([targetPath], 'fs:mkdir');
|
|
747
761
|
await publishFilesResourceForPath(targetPath, 'fs:mkdir');
|
|
748
|
-
sendJson(200, { success: true, path: targetPath });
|
|
762
|
+
sendJson(200, { success: true, path: targetPath, nodeId: treeResult?.operation?.nodeId || null });
|
|
749
763
|
} catch (error) {
|
|
750
764
|
const message = error instanceof Error ? error.message : 'Failed to create folder';
|
|
751
765
|
sendJson(statusForError(error), { error: message });
|
|
@@ -757,8 +771,13 @@ async function handleRename(body, sendJson) {
|
|
|
757
771
|
const oldPath = await resolveSafePath(body.oldPath);
|
|
758
772
|
const newPath = await resolveSafePath(body.newPath, { forCreate: true });
|
|
759
773
|
const stats = await fs.promises.lstat(oldPath).catch(() => null);
|
|
760
|
-
|
|
761
|
-
|
|
774
|
+
const treeResult = require('../workspace/tree-store').commitPathRename(oldPath, newPath, {
|
|
775
|
+
source: 'fs:rename',
|
|
776
|
+
});
|
|
777
|
+
if (!treeResult) {
|
|
778
|
+
await fs.promises.mkdir(path.dirname(newPath), { recursive: true });
|
|
779
|
+
await fs.promises.rename(oldPath, newPath);
|
|
780
|
+
}
|
|
762
781
|
notifyProjectContextPathChanged(oldPath, 'fs:rename');
|
|
763
782
|
notifyProjectContextPathChanged(newPath, 'fs:rename');
|
|
764
783
|
await publishFilesystemChange([oldPath, newPath], 'fs:rename');
|
|
@@ -772,7 +791,7 @@ async function handleRename(body, sendJson) {
|
|
|
772
791
|
});
|
|
773
792
|
await publishFilesResourceForPath(newPath, 'fs:rename');
|
|
774
793
|
}
|
|
775
|
-
sendJson(200, { success: true, oldPath, newPath });
|
|
794
|
+
sendJson(200, { success: true, oldPath, newPath, nodeId: treeResult?.operation?.nodeId || null });
|
|
776
795
|
} catch (error) {
|
|
777
796
|
const message = error instanceof Error ? error.message : 'Failed to rename';
|
|
778
797
|
sendJson(statusForError(error), { error: message });
|
|
@@ -128,7 +128,10 @@ async function boot() {
|
|
|
128
128
|
startHealthMonitor();
|
|
129
129
|
require('./state/ports').startPortsBridge();
|
|
130
130
|
require('./workspace/cards').startRepoCardService();
|
|
131
|
+
require('./workspace/tree-store').startTreeEnrollmentService();
|
|
131
132
|
require('./workspace/wall').startWallService();
|
|
133
|
+
require('./workspace/checkpoint').startCheckpointService();
|
|
134
|
+
require('./workspace/reconcile').startWorkspaceReconciler();
|
|
132
135
|
}
|
|
133
136
|
|
|
134
137
|
boot().catch((err) => {
|
|
@@ -103,6 +103,10 @@ async function handleStateRoutes(ctx) {
|
|
|
103
103
|
await sharedRest.handleReplicaRehydrate(await ctx.readJsonBody(), ctx.sendJson);
|
|
104
104
|
return true;
|
|
105
105
|
}
|
|
106
|
+
if (ctx.pathname === '/state/shared/workspace-content/discover') {
|
|
107
|
+
await sharedRest.handleWorkspaceContentDiscover(await ctx.readJsonBody(), ctx.sendJson);
|
|
108
|
+
return true;
|
|
109
|
+
}
|
|
106
110
|
if (ctx.pathname === '/state/shared/promote') {
|
|
107
111
|
await sharedRest.handlePromotionStart(await ctx.readJsonBody(), ctx.sendJson);
|
|
108
112
|
return true;
|
|
@@ -67,6 +67,38 @@ async function handleWorkspaceRoutes(ctx) {
|
|
|
67
67
|
workspaceRest.handleWorkspaceMachines(ctx.sendJson);
|
|
68
68
|
return true;
|
|
69
69
|
}
|
|
70
|
+
if (ctx.pathname === '/workspace/tree' && ctx.method === 'GET') {
|
|
71
|
+
workspaceRest.handleTreeList(ctx.getQuery(), ctx.sendJson);
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
if (ctx.pathname === '/workspace/tree/enroll' && ctx.method === 'POST') {
|
|
75
|
+
workspaceRest.handleTreeEnroll(await ctx.readJsonBody(), ctx.sendJson);
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
if (ctx.pathname === '/workspace/tree/mutations' && ctx.method === 'POST') {
|
|
79
|
+
workspaceRest.handleTreeMutation(await ctx.readJsonBody(), ctx.sendJson);
|
|
80
|
+
return true;
|
|
81
|
+
}
|
|
82
|
+
if (ctx.pathname === '/workspace/cloud-attachments' && ctx.method === 'POST') {
|
|
83
|
+
await workspaceRest.handleCloudWorkspaceAttachment(await ctx.readJsonBody(), ctx.sendJson);
|
|
84
|
+
return true;
|
|
85
|
+
}
|
|
86
|
+
if (ctx.pathname === '/workspace/access' && ctx.method === 'GET') {
|
|
87
|
+
workspaceRest.handleAccessRead(ctx.getQuery(), ctx.sendJson);
|
|
88
|
+
return true;
|
|
89
|
+
}
|
|
90
|
+
if (ctx.pathname === '/workspace/access' && ctx.method === 'PUT') {
|
|
91
|
+
workspaceRest.handleAccessSet(await ctx.readJsonBody(), ctx.sendJson);
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
if (ctx.pathname === '/workspace/access' && ctx.method === 'DELETE') {
|
|
95
|
+
workspaceRest.handleAccessClear(await ctx.readJsonBody(), ctx.sendJson);
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
if (ctx.pathname === '/workspace/access/revoke-user' && ctx.method === 'POST') {
|
|
99
|
+
workspaceRest.handleAccessRevokeUser(await ctx.readJsonBody(), ctx.sendJson);
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
70
102
|
return false;
|
|
71
103
|
}
|
|
72
104
|
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
const crypto = require('crypto');
|
|
4
4
|
|
|
5
5
|
const { cachedStatement, openLocalDb } = require('./db');
|
|
6
|
+
const { isSharedResourceId } = require('./cloud-resource-id');
|
|
6
7
|
const { appendStateEvent } = require('./events');
|
|
7
8
|
|
|
8
|
-
const SHARED_RESOURCE_ID = /^shr_[A-Za-z0-9_-]{16,128}$/;
|
|
9
9
|
const ATTACHMENT_ID = /^[A-Za-z0-9_-]{16,160}$/;
|
|
10
10
|
|
|
11
11
|
function invalid(message, statusCode = 400) {
|
|
@@ -40,7 +40,7 @@ function enqueueAttachment(input, options = {}) {
|
|
|
40
40
|
const resourceId = requiredText(input?.resourceId, 'resourceId', 132);
|
|
41
41
|
const localPath = requiredText(input?.localPath, 'localPath');
|
|
42
42
|
if (!ATTACHMENT_ID.test(attachmentId)) throw invalid('attachmentId has an invalid format');
|
|
43
|
-
if (!
|
|
43
|
+
if (!isSharedResourceId(resourceId)) throw invalid('resourceId must be a node UUID or legacy shared id');
|
|
44
44
|
const database = options.database || openLocalDb();
|
|
45
45
|
const existing = cachedStatement(
|
|
46
46
|
database,
|
|
@@ -127,7 +127,9 @@ function acknowledgeAttachment(attachmentIdInput, payload, options = {}) {
|
|
|
127
127
|
throw invalid('attachment response resource mismatch', 409);
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
-
const installed =
|
|
130
|
+
const installed = payload?.contract === 'workspace-tree@1'
|
|
131
|
+
? require('../workspace/tree-store').installCloudTreeSnapshot(attachment.localPath, payload, { database })
|
|
132
|
+
: require('./docs').installCloudDocSnapshot(attachment.localPath, payload);
|
|
131
133
|
const now = new Date().toISOString();
|
|
132
134
|
cachedStatement(database, `
|
|
133
135
|
UPDATE shared_resource_attachments
|
|
@@ -180,7 +182,7 @@ function failAttachment(attachmentIdInput, failure, options = {}) {
|
|
|
180
182
|
*/
|
|
181
183
|
function requestRehydration(input, options = {}) {
|
|
182
184
|
const resourceId = requiredText(input?.resourceId, 'resourceId', 132);
|
|
183
|
-
if (!
|
|
185
|
+
if (!isSharedResourceId(resourceId)) throw invalid('resourceId must be a node UUID or legacy shared id');
|
|
184
186
|
const database = options.database || openLocalDb();
|
|
185
187
|
const replica = require('./replicas').getReplicaByResourceId(resourceId, { database });
|
|
186
188
|
if (!replica) throw invalid(`shared resource ${resourceId} has no local replica`, 404);
|
|
@@ -248,7 +250,7 @@ function getAttachmentByResource(resourceId, options = {}) {
|
|
|
248
250
|
*/
|
|
249
251
|
function retryAttachment(resourceIdInput, options = {}) {
|
|
250
252
|
const resourceId = requiredText(resourceIdInput, 'resourceId', 132);
|
|
251
|
-
if (!
|
|
253
|
+
if (!isSharedResourceId(resourceId)) throw invalid('resourceId must be a node UUID or legacy shared id');
|
|
252
254
|
const database = options.database || openLocalDb();
|
|
253
255
|
const now = new Date().toISOString();
|
|
254
256
|
const result = cachedStatement(database, `
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { isUuid } = require('../workspace/tree/identity');
|
|
4
|
+
|
|
5
|
+
const LEGACY_SHARED_RESOURCE_ID = /^shr_[A-Za-z0-9_-]{16,128}$/;
|
|
6
|
+
const PRIVATE_RESOURCE_ID = /^pvr_[A-Za-z0-9_-]{16,128}$/;
|
|
7
|
+
|
|
8
|
+
function isSharedResourceId(value) {
|
|
9
|
+
return typeof value === 'string' && (LEGACY_SHARED_RESOURCE_ID.test(value) || isUuid(value));
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function isCloudResourceId(value) {
|
|
13
|
+
return isSharedResourceId(value) || (typeof value === 'string' && PRIVATE_RESOURCE_ID.test(value));
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
module.exports = { isCloudResourceId, isSharedResourceId };
|
|
@@ -532,6 +532,70 @@ function migrate(database = openLocalDb()) {
|
|
|
532
532
|
CREATE INDEX IF NOT EXISTS workspaces_location_type_idx
|
|
533
533
|
ON workspaces(location_type);
|
|
534
534
|
|
|
535
|
+
-- Permanent workspace/tree identity. A workspace's root node uses the
|
|
536
|
+
-- workspace UUID itself, so the root never acquires a second durable id.
|
|
537
|
+
CREATE TABLE IF NOT EXISTS workspace_id_aliases (
|
|
538
|
+
alias_id TEXT PRIMARY KEY,
|
|
539
|
+
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON UPDATE CASCADE ON DELETE CASCADE,
|
|
540
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
541
|
+
);
|
|
542
|
+
|
|
543
|
+
CREATE INDEX IF NOT EXISTS workspace_id_aliases_workspace_idx
|
|
544
|
+
ON workspace_id_aliases(workspace_id);
|
|
545
|
+
|
|
546
|
+
CREATE TABLE IF NOT EXISTS workspace_nodes (
|
|
547
|
+
id TEXT PRIMARY KEY,
|
|
548
|
+
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON UPDATE CASCADE ON DELETE CASCADE,
|
|
549
|
+
parent_id TEXT REFERENCES workspace_nodes(id) ON UPDATE CASCADE,
|
|
550
|
+
kind TEXT NOT NULL CHECK (kind IN ('folder', 'file', 'symlink')),
|
|
551
|
+
name TEXT NOT NULL,
|
|
552
|
+
relative_path TEXT NOT NULL,
|
|
553
|
+
symlink_target TEXT,
|
|
554
|
+
device_id TEXT,
|
|
555
|
+
inode TEXT,
|
|
556
|
+
trashed_at TEXT,
|
|
557
|
+
purged_at TEXT,
|
|
558
|
+
missing_at TEXT,
|
|
559
|
+
created_at TEXT NOT NULL,
|
|
560
|
+
updated_at TEXT NOT NULL,
|
|
561
|
+
CHECK ((id = workspace_id AND parent_id IS NULL AND relative_path = '')
|
|
562
|
+
OR (id <> workspace_id AND parent_id IS NOT NULL AND relative_path <> ''))
|
|
563
|
+
);
|
|
564
|
+
|
|
565
|
+
DROP INDEX IF EXISTS workspace_nodes_live_path_idx;
|
|
566
|
+
CREATE UNIQUE INDEX workspace_nodes_live_path_idx
|
|
567
|
+
ON workspace_nodes(workspace_id, relative_path)
|
|
568
|
+
WHERE purged_at IS NULL AND missing_at IS NULL AND trashed_at IS NULL;
|
|
569
|
+
|
|
570
|
+
CREATE INDEX IF NOT EXISTS workspace_nodes_parent_idx
|
|
571
|
+
ON workspace_nodes(workspace_id, parent_id);
|
|
572
|
+
|
|
573
|
+
CREATE INDEX IF NOT EXISTS workspace_nodes_inode_idx
|
|
574
|
+
ON workspace_nodes(workspace_id, device_id, inode)
|
|
575
|
+
WHERE device_id IS NOT NULL AND inode IS NOT NULL AND purged_at IS NULL;
|
|
576
|
+
|
|
577
|
+
CREATE TABLE IF NOT EXISTS workspace_access_state (
|
|
578
|
+
workspace_id TEXT PRIMARY KEY REFERENCES workspaces(id) ON UPDATE CASCADE ON DELETE CASCADE,
|
|
579
|
+
authority_epoch INTEGER NOT NULL DEFAULT 1 CHECK (authority_epoch > 0),
|
|
580
|
+
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now'))
|
|
581
|
+
);
|
|
582
|
+
|
|
583
|
+
CREATE TABLE IF NOT EXISTS workspace_node_permissions (
|
|
584
|
+
workspace_id TEXT NOT NULL REFERENCES workspaces(id) ON UPDATE CASCADE ON DELETE CASCADE,
|
|
585
|
+
node_id TEXT NOT NULL REFERENCES workspace_nodes(id) ON UPDATE CASCADE ON DELETE CASCADE,
|
|
586
|
+
user_id TEXT NOT NULL,
|
|
587
|
+
role TEXT NOT NULL CHECK (role IN ('owner', 'editor', 'viewer', 'no_access')),
|
|
588
|
+
permission_version INTEGER NOT NULL DEFAULT 1 CHECK (permission_version > 0),
|
|
589
|
+
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
590
|
+
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')),
|
|
591
|
+
revoked_at TEXT,
|
|
592
|
+
PRIMARY KEY (node_id, user_id)
|
|
593
|
+
);
|
|
594
|
+
|
|
595
|
+
CREATE INDEX IF NOT EXISTS workspace_node_permissions_user_idx
|
|
596
|
+
ON workspace_node_permissions(user_id, workspace_id)
|
|
597
|
+
WHERE revoked_at IS NULL;
|
|
598
|
+
|
|
535
599
|
CREATE TABLE IF NOT EXISTS repo_replicas (
|
|
536
600
|
git_dir TEXT PRIMARY KEY,
|
|
537
601
|
replica_id TEXT NOT NULL UNIQUE,
|
|
@@ -681,6 +745,16 @@ function ensureDocStatesSchema(database = openLocalDb()) {
|
|
|
681
745
|
} catch {
|
|
682
746
|
// Column already exists.
|
|
683
747
|
}
|
|
748
|
+
try {
|
|
749
|
+
database.exec('ALTER TABLE doc_states ADD COLUMN node_id TEXT');
|
|
750
|
+
} catch {
|
|
751
|
+
// Column already exists.
|
|
752
|
+
}
|
|
753
|
+
database.exec(`
|
|
754
|
+
CREATE UNIQUE INDEX IF NOT EXISTS doc_states_node_id_idx
|
|
755
|
+
ON doc_states(node_id)
|
|
756
|
+
WHERE node_id IS NOT NULL;
|
|
757
|
+
`);
|
|
684
758
|
}
|
|
685
759
|
|
|
686
760
|
// App-registered resources (state/app-resources.js): the engine-side mirror
|
|
@@ -179,13 +179,23 @@ function liveEditsSinceLastDiskState(entry) {
|
|
|
179
179
|
}
|
|
180
180
|
|
|
181
181
|
function docResourceName(absolutePath) {
|
|
182
|
+
try {
|
|
183
|
+
const found = require('../workspace/tree-store').nodeForPath(absolutePath);
|
|
184
|
+
if (found?.node?.kind === 'file') return `${DOC_RESOURCE_PREFIX}${found.node.id}`;
|
|
185
|
+
} catch {
|
|
186
|
+
// Unenrolled files keep the legacy path-derived local resource.
|
|
187
|
+
}
|
|
182
188
|
return `${DOC_RESOURCE_PREFIX}${fsPrivate().base64UrlEncode(absolutePath)}`;
|
|
183
189
|
}
|
|
184
190
|
|
|
185
191
|
function pathFromDocResource(resource) {
|
|
186
192
|
if (typeof resource !== 'string' || !resource.startsWith(DOC_RESOURCE_PREFIX)) return null;
|
|
193
|
+
const identity = resource.slice(DOC_RESOURCE_PREFIX.length);
|
|
194
|
+
if (require('../workspace/tree/identity').isUuid(identity)) {
|
|
195
|
+
return require('../workspace/tree-store').absolutePathForNode(identity);
|
|
196
|
+
}
|
|
187
197
|
try {
|
|
188
|
-
return fsPrivate().base64UrlDecode(
|
|
198
|
+
return fsPrivate().base64UrlDecode(identity);
|
|
189
199
|
} catch {
|
|
190
200
|
return null;
|
|
191
201
|
}
|
|
@@ -282,6 +292,16 @@ function spliceDocText(Y, entry, nextText, source) {
|
|
|
282
292
|
return { mutationId: newMutationId(), update: origin.patch.yjs, bytes: null };
|
|
283
293
|
}
|
|
284
294
|
|
|
295
|
+
function canCurrentUserEditDoc(entry) {
|
|
296
|
+
if (!entry.nodeId) return true;
|
|
297
|
+
const role = require('../workspace/access-store').currentUserRole(entry.nodeId);
|
|
298
|
+
return require('../workspace/tree/access').can(role, 'edit');
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
function requireCurrentUserDocEdit(entry) {
|
|
302
|
+
if (!canCurrentUserEditDoc(entry)) throw invalid('document edit is forbidden', 403);
|
|
303
|
+
}
|
|
304
|
+
|
|
285
305
|
/**
|
|
286
306
|
* The one door. Every locally produced document edit — a client Yjs update,
|
|
287
307
|
* a whole-text API write, a disk reconcile — becomes exactly one journal
|
|
@@ -361,14 +381,16 @@ function publishConflictOnly(entry, source) {
|
|
|
361
381
|
// through it.
|
|
362
382
|
function persistDocState(Y, entry) {
|
|
363
383
|
cachedStatement(openLocalDb(), `
|
|
364
|
-
INSERT INTO doc_states (path, state, updated_at, last_disk_hash)
|
|
365
|
-
VALUES (?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), ?)
|
|
384
|
+
INSERT INTO doc_states (path, node_id, state, updated_at, last_disk_hash)
|
|
385
|
+
VALUES (?, ?, ?, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), ?)
|
|
366
386
|
ON CONFLICT(path) DO UPDATE SET
|
|
387
|
+
node_id = excluded.node_id,
|
|
367
388
|
state = excluded.state,
|
|
368
389
|
updated_at = excluded.updated_at,
|
|
369
390
|
last_disk_hash = excluded.last_disk_hash
|
|
370
391
|
`).run(
|
|
371
392
|
entry.path,
|
|
393
|
+
entry.nodeId,
|
|
372
394
|
Buffer.from(Y.encodeStateAsUpdate(entry.doc)),
|
|
373
395
|
entry.lastDiskHash,
|
|
374
396
|
);
|
|
@@ -475,6 +497,11 @@ function reconcileFromDisk(Y, entry) {
|
|
|
475
497
|
// of disk (write-back still debounced) would "reconcile" the stale disk
|
|
476
498
|
// text back over live edits. (This is also the THEIRS === BASE no-op.)
|
|
477
499
|
if (onDisk === entry.lastDiskText) return;
|
|
500
|
+
if (!canCurrentUserEditDoc(entry)) {
|
|
501
|
+
entry.heldDiskChange = { reason: 'forbidden', at: new Date().toISOString() };
|
|
502
|
+
return;
|
|
503
|
+
}
|
|
504
|
+
entry.heldDiskChange = null;
|
|
478
505
|
const ytext = entry.doc.getText(TEXT_KEY);
|
|
479
506
|
const ours = ytext.toString();
|
|
480
507
|
const base = entry.lastDiskText;
|
|
@@ -548,6 +575,18 @@ function reconcileFromDisk(Y, entry) {
|
|
|
548
575
|
function seedFromDisk(Y, entry, persisted) {
|
|
549
576
|
const knownHash = persisted?.last_disk_hash;
|
|
550
577
|
if (!knownHash) {
|
|
578
|
+
if (!canCurrentUserEditDoc(entry)) {
|
|
579
|
+
try {
|
|
580
|
+
const onDisk = fs.readFileSync(entry.path, 'utf8');
|
|
581
|
+
const origin = { amalgmMutation: true, source: 'doc:read-only-seed', patch: null };
|
|
582
|
+
spliceTextInto(Y, entry.doc, entry.doc.getText(TEXT_KEY), onDisk, origin);
|
|
583
|
+
setLastDiskText(entry, onDisk, 'open');
|
|
584
|
+
persistDocState(Y, entry);
|
|
585
|
+
} catch {
|
|
586
|
+
// Unreadable/deleted: same posture as reconcile — stop following disk.
|
|
587
|
+
}
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
551
590
|
reconcileFromDisk(Y, entry);
|
|
552
591
|
return;
|
|
553
592
|
}
|
|
@@ -720,9 +759,17 @@ function loadDocEntry(pathInput) {
|
|
|
720
759
|
ensureDocStatesSchema(database);
|
|
721
760
|
|
|
722
761
|
const doc = new Y.Doc();
|
|
762
|
+
let nodeId = null;
|
|
763
|
+
try {
|
|
764
|
+
const found = require('../workspace/tree-store').nodeForPath(resolved);
|
|
765
|
+
nodeId = found?.node?.kind === 'file' ? found.node.id : null;
|
|
766
|
+
} catch {
|
|
767
|
+
// The document remains available even when tree enrollment is unavailable.
|
|
768
|
+
}
|
|
723
769
|
const entry = {
|
|
724
770
|
path: resolved,
|
|
725
771
|
resource: docResourceName(resolved),
|
|
772
|
+
nodeId,
|
|
726
773
|
doc,
|
|
727
774
|
reconcileTimer: null,
|
|
728
775
|
diskWriteTimer: null,
|
|
@@ -745,7 +792,14 @@ function loadDocEntry(pathInput) {
|
|
|
745
792
|
pathAliases: new Set([pathInput]),
|
|
746
793
|
};
|
|
747
794
|
|
|
748
|
-
let persisted =
|
|
795
|
+
let persisted = nodeId
|
|
796
|
+
? cachedStatement(database, `
|
|
797
|
+
SELECT state, last_disk_hash FROM doc_states
|
|
798
|
+
WHERE node_id = ? OR path = ?
|
|
799
|
+
ORDER BY CASE WHEN node_id = ? THEN 0 ELSE 1 END
|
|
800
|
+
LIMIT 1
|
|
801
|
+
`).get(nodeId, resolved, nodeId) || null
|
|
802
|
+
: cachedStatement(database, 'SELECT state, last_disk_hash FROM doc_states WHERE path = ?').get(resolved) || null;
|
|
749
803
|
if (persisted?.state) {
|
|
750
804
|
try {
|
|
751
805
|
Y.applyUpdate(doc, new Uint8Array(persisted.state), 'persisted');
|
|
@@ -772,6 +826,10 @@ function loadDocEntry(pathInput) {
|
|
|
772
826
|
// that merely lags our persisted state (the process died inside the disk
|
|
773
827
|
// write debounce) gets the doc written back out — never spliced over it.
|
|
774
828
|
seedFromDisk(Y, entry, persisted);
|
|
829
|
+
if (nodeId) {
|
|
830
|
+
cachedStatement(database, 'UPDATE doc_states SET node_id = ?, path = ? WHERE node_id = ? OR path = ?')
|
|
831
|
+
.run(nodeId, resolved, nodeId, resolved);
|
|
832
|
+
}
|
|
775
833
|
|
|
776
834
|
// A process can die after the durable journal insert but before Y.Doc/file
|
|
777
835
|
// materialization or event projection. Replay those exact operation IDs
|
|
@@ -797,6 +855,7 @@ function openDoc(pathInput) {
|
|
|
797
855
|
const entry = loadDocEntry(pathInput);
|
|
798
856
|
return {
|
|
799
857
|
resource: entry.resource,
|
|
858
|
+
nodeId: entry.nodeId,
|
|
800
859
|
path: entry.path,
|
|
801
860
|
state: Buffer.from(Y.encodeStateAsUpdate(entry.doc)).toString('base64'),
|
|
802
861
|
// The versioned-write handshake: hash of the exact current text; a writer
|
|
@@ -865,6 +924,7 @@ function writeDocText(pathInput, textInput, options = {}) {
|
|
|
865
924
|
|
|
866
925
|
const entry = loadDocEntry(pathInput);
|
|
867
926
|
entry.lastAccess = Date.now();
|
|
927
|
+
requireCurrentUserDocEdit(entry);
|
|
868
928
|
// Every respond() below acknowledges saved state, so it must stand on a
|
|
869
929
|
// settled journal — most pointedly the noop return: a retry of a write
|
|
870
930
|
// whose disk flush failed finds the text already in memory, and without
|
|
@@ -972,6 +1032,7 @@ function applyDocMutations(pathInput, mutationsInput) {
|
|
|
972
1032
|
if (mutations.length === 0) return { applied: 0, seq: currentSeq(), mutationIds: [] };
|
|
973
1033
|
const entry = loadDocEntry(pathInput);
|
|
974
1034
|
entry.lastAccess = Date.now();
|
|
1035
|
+
requireCurrentUserDocEdit(entry);
|
|
975
1036
|
const results = mutations.map((mutation) => commitDocMutation(Y, entry, mutation, 'doc:update'));
|
|
976
1037
|
|
|
977
1038
|
return {
|
|
@@ -1125,6 +1186,60 @@ function getDocText(pathInput) {
|
|
|
1125
1186
|
return entry.doc.getText(TEXT_KEY).toString();
|
|
1126
1187
|
}
|
|
1127
1188
|
|
|
1189
|
+
/**
|
|
1190
|
+
* Repoint persisted and open document materializations after tree identity
|
|
1191
|
+
* moved. The resource remains doc:<node UUID>; only the local path changes.
|
|
1192
|
+
*/
|
|
1193
|
+
function rebindNodePaths(changes) {
|
|
1194
|
+
const database = openLocalDb();
|
|
1195
|
+
ensureDocStatesSchema(database);
|
|
1196
|
+
for (const change of changes || []) {
|
|
1197
|
+
if (!change?.nodeId || !change?.newPath || change.oldPath === change.newPath) continue;
|
|
1198
|
+
database.transaction(() => {
|
|
1199
|
+
database.prepare('UPDATE doc_states SET path = ? WHERE node_id = ? OR path = ?')
|
|
1200
|
+
.run(change.newPath, change.nodeId, change.oldPath);
|
|
1201
|
+
database.prepare('UPDATE shared_resource_replicas SET local_path = ?, updated_at = ? WHERE local_resource = ? OR local_path = ?')
|
|
1202
|
+
.run(change.newPath, new Date().toISOString(), `${DOC_RESOURCE_PREFIX}${change.nodeId}`, change.oldPath);
|
|
1203
|
+
database.prepare('UPDATE shared_resource_promotions SET local_path = ?, updated_at = ? WHERE local_resource = ? OR local_path = ?')
|
|
1204
|
+
.run(change.newPath, new Date().toISOString(), `${DOC_RESOURCE_PREFIX}${change.nodeId}`, change.oldPath);
|
|
1205
|
+
database.prepare('UPDATE shared_resource_attachments SET local_path = ?, updated_at = ? WHERE local_path = ?')
|
|
1206
|
+
.run(change.newPath, new Date().toISOString(), change.oldPath);
|
|
1207
|
+
})();
|
|
1208
|
+
|
|
1209
|
+
const entry = Array.from(openDocs.values()).find((candidate) => (
|
|
1210
|
+
candidate.nodeId === change.nodeId || candidate.path === change.oldPath
|
|
1211
|
+
));
|
|
1212
|
+
if (!entry) continue;
|
|
1213
|
+
unwatchDocFile(entry);
|
|
1214
|
+
openDocs.delete(entry.path);
|
|
1215
|
+
entry.path = change.newPath;
|
|
1216
|
+
entry.nodeId = change.nodeId;
|
|
1217
|
+
entry.resource = `${DOC_RESOURCE_PREFIX}${change.nodeId}`;
|
|
1218
|
+
entry.pathAliases.add(change.newPath);
|
|
1219
|
+
resolvedPathCache.set(change.newPath, change.newPath);
|
|
1220
|
+
openDocs.set(change.newPath, entry);
|
|
1221
|
+
watchDocFile(loadYjs(), entry);
|
|
1222
|
+
}
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
function closeDocByNode(nodeId) {
|
|
1226
|
+
const entry = Array.from(openDocs.values()).find((candidate) => candidate.nodeId === nodeId);
|
|
1227
|
+
if (!entry) return false;
|
|
1228
|
+
closeDocEntry(loadYjs(), entry);
|
|
1229
|
+
return true;
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
function closeDocsByNodeIds(nodeIds) {
|
|
1233
|
+
const ids = nodeIds instanceof Set ? nodeIds : new Set(nodeIds || []);
|
|
1234
|
+
let closed = 0;
|
|
1235
|
+
for (const entry of Array.from(openDocs.values())) {
|
|
1236
|
+
if (!entry.nodeId || !ids.has(entry.nodeId)) continue;
|
|
1237
|
+
closeDocEntry(loadYjs(), entry);
|
|
1238
|
+
closed += 1;
|
|
1239
|
+
}
|
|
1240
|
+
return closed;
|
|
1241
|
+
}
|
|
1242
|
+
|
|
1128
1243
|
/** Test/shutdown helper: flush and close every open document. */
|
|
1129
1244
|
function closeAllDocs() {
|
|
1130
1245
|
let Y;
|
|
@@ -1141,6 +1256,8 @@ module.exports = {
|
|
|
1141
1256
|
applyCloudDocMutation,
|
|
1142
1257
|
applyDocMutations,
|
|
1143
1258
|
applyDocUpdates,
|
|
1259
|
+
closeDocByNode,
|
|
1260
|
+
closeDocsByNodeIds,
|
|
1144
1261
|
closeAllDocs,
|
|
1145
1262
|
docResourceName,
|
|
1146
1263
|
getDocText,
|
|
@@ -1148,6 +1265,7 @@ module.exports = {
|
|
|
1148
1265
|
openDoc,
|
|
1149
1266
|
pathFromDocResource,
|
|
1150
1267
|
readDocResource,
|
|
1268
|
+
rebindNodePaths,
|
|
1151
1269
|
writeDocText,
|
|
1152
1270
|
// Test hooks only — not part of the module contract.
|
|
1153
1271
|
_private: { openDocs },
|
|
@@ -110,9 +110,71 @@ function removeInheritedHarnessTools(database) {
|
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
+
function migrateWorkspaceIds(database) {
|
|
114
|
+
const { isUuidV7, uuidV7 } = require('../workspace/tree/identity');
|
|
115
|
+
const rows = database.prepare('SELECT * FROM workspaces').all();
|
|
116
|
+
|
|
117
|
+
database.transaction(() => {
|
|
118
|
+
for (const row of rows) {
|
|
119
|
+
if (isUuidV7(row.id)) continue;
|
|
120
|
+
let workspaceId = uuidV7();
|
|
121
|
+
while (database.prepare('SELECT 1 FROM workspaces WHERE id = ?').get(workspaceId)) {
|
|
122
|
+
workspaceId = uuidV7();
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
let workspaceJson = {};
|
|
126
|
+
try {
|
|
127
|
+
workspaceJson = JSON.parse(row.workspace_json || '{}');
|
|
128
|
+
} catch {
|
|
129
|
+
// Typed columns remain authoritative when legacy JSON is malformed.
|
|
130
|
+
}
|
|
131
|
+
workspaceJson.id = workspaceId;
|
|
132
|
+
|
|
133
|
+
database.prepare(`
|
|
134
|
+
UPDATE workspaces
|
|
135
|
+
SET id = ?, workspace_json = ?, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')
|
|
136
|
+
WHERE id = ?
|
|
137
|
+
`).run(workspaceId, JSON.stringify(workspaceJson), row.id);
|
|
138
|
+
database.prepare(`
|
|
139
|
+
INSERT OR IGNORE INTO workspace_id_aliases(alias_id, workspace_id)
|
|
140
|
+
VALUES (?, ?)
|
|
141
|
+
`).run(row.id, workspaceId);
|
|
142
|
+
database.prepare('UPDATE project_context SET project_id = ? WHERE project_id = ?')
|
|
143
|
+
.run(workspaceId, row.id);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
database.prepare(`
|
|
147
|
+
INSERT OR IGNORE INTO workspace_nodes (
|
|
148
|
+
id, workspace_id, parent_id, kind, name, relative_path, created_at, updated_at
|
|
149
|
+
)
|
|
150
|
+
SELECT id, id, NULL, 'folder', name, '', created_at, updated_at
|
|
151
|
+
FROM workspaces
|
|
152
|
+
`).run();
|
|
153
|
+
database.prepare(`
|
|
154
|
+
INSERT OR IGNORE INTO workspace_access_state(workspace_id, authority_epoch)
|
|
155
|
+
SELECT id, 1 FROM workspaces
|
|
156
|
+
`).run();
|
|
157
|
+
})();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function backfillLocalWorkspaceOwners(database) {
|
|
161
|
+
const ownerUserId = require('../workspace/identity').userId();
|
|
162
|
+
if (!ownerUserId) return;
|
|
163
|
+
database.prepare(`
|
|
164
|
+
INSERT OR IGNORE INTO workspace_node_permissions (
|
|
165
|
+
workspace_id, node_id, user_id, role
|
|
166
|
+
)
|
|
167
|
+
SELECT id, id, ?, 'owner'
|
|
168
|
+
FROM workspaces
|
|
169
|
+
WHERE source <> 'cloud'
|
|
170
|
+
`).run(ownerUserId);
|
|
171
|
+
}
|
|
172
|
+
|
|
113
173
|
function runLegacyMigrations(database) {
|
|
114
174
|
migrateLegacyToolboxTables(database);
|
|
115
175
|
removeInheritedHarnessTools(database);
|
|
176
|
+
migrateWorkspaceIds(database);
|
|
177
|
+
backfillLocalWorkspaceOwners(database);
|
|
116
178
|
}
|
|
117
179
|
|
|
118
180
|
module.exports = { runLegacyMigrations };
|