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.
Files changed (42) hide show
  1. package/lib/shared-realtime-tunnel.js +30 -1
  2. package/package.json +1 -1
  3. package/runtime/scripts/amalgm-mcp/fs/rest.js +26 -7
  4. package/runtime/scripts/amalgm-mcp/index.js +3 -0
  5. package/runtime/scripts/amalgm-mcp/server/routes/state.js +4 -0
  6. package/runtime/scripts/amalgm-mcp/server/routes/workspace.js +32 -0
  7. package/runtime/scripts/amalgm-mcp/state/attachments.js +7 -5
  8. package/runtime/scripts/amalgm-mcp/state/cloud-resource-id.js +16 -0
  9. package/runtime/scripts/amalgm-mcp/state/db.js +74 -0
  10. package/runtime/scripts/amalgm-mcp/state/docs.js +122 -4
  11. package/runtime/scripts/amalgm-mcp/state/migrations.js +62 -0
  12. package/runtime/scripts/amalgm-mcp/state/mutations.js +4 -4
  13. package/runtime/scripts/amalgm-mcp/state/promotions.js +2 -2
  14. package/runtime/scripts/amalgm-mcp/state/replicas.js +6 -5
  15. package/runtime/scripts/amalgm-mcp/state/shared-rest.js +27 -22
  16. package/runtime/scripts/amalgm-mcp/tests/state-promotion-ownership.test.js +5 -9
  17. package/runtime/scripts/amalgm-mcp/tests/workspace-access-store.test.js +120 -0
  18. package/runtime/scripts/amalgm-mcp/tests/workspace-checkpoint.test.js +137 -0
  19. package/runtime/scripts/amalgm-mcp/tests/workspace-reconcile.test.js +264 -0
  20. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-cloud.test.js +155 -0
  21. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-core.test.js +114 -0
  22. package/runtime/scripts/amalgm-mcp/tests/workspace-tree-store.test.js +227 -0
  23. package/runtime/scripts/amalgm-mcp/workspace/access-store.js +182 -0
  24. package/runtime/scripts/amalgm-mcp/workspace/binding.js +62 -0
  25. package/runtime/scripts/amalgm-mcp/workspace/checkpoint.js +237 -0
  26. package/runtime/scripts/amalgm-mcp/workspace/git.js +63 -12
  27. package/runtime/scripts/amalgm-mcp/workspace/ledger.js +96 -0
  28. package/runtime/scripts/amalgm-mcp/workspace/merge.js +290 -0
  29. package/runtime/scripts/amalgm-mcp/workspace/reconcile.js +347 -0
  30. package/runtime/scripts/amalgm-mcp/workspace/rest.js +182 -2
  31. package/runtime/scripts/amalgm-mcp/workspace/store.js +59 -12
  32. package/runtime/scripts/amalgm-mcp/workspace/transition.js +141 -0
  33. package/runtime/scripts/amalgm-mcp/workspace/tree/access.js +62 -0
  34. package/runtime/scripts/amalgm-mcp/workspace/tree/identity.js +53 -0
  35. package/runtime/scripts/amalgm-mcp/workspace/tree/inclusion.js +68 -0
  36. package/runtime/scripts/amalgm-mcp/workspace/tree/local-state.js +174 -0
  37. package/runtime/scripts/amalgm-mcp/workspace/tree/operations.js +93 -0
  38. package/runtime/scripts/amalgm-mcp/workspace/tree/reducer.js +138 -0
  39. package/runtime/scripts/amalgm-mcp/workspace/tree/snapshot.js +82 -0
  40. package/runtime/scripts/amalgm-mcp/workspace/tree-cloud.js +391 -0
  41. package/runtime/scripts/amalgm-mcp/workspace/tree-promotion.js +94 -0
  42. package/runtime/scripts/amalgm-mcp/workspace/tree-store.js +666 -0
@@ -0,0 +1,264 @@
1
+ 'use strict';
2
+
3
+ // The workspace rule under test: every writer — human, agent, or remote
4
+ // machine — writes into the same working tree, and no writer's work is
5
+ // ever silently lost. Non-overlapping changes coexist; overlapping
6
+ // changes preserve both and surface a named conflict; commits travel as
7
+ // card-controlled transitions; nothing overwrites another writer.
8
+
9
+ const test = require('node:test');
10
+ const assert = require('node:assert/strict');
11
+ const fs = require('fs');
12
+ const os = require('os');
13
+ const path = require('path');
14
+ const { execFile } = require('child_process');
15
+ const { promisify } = require('util');
16
+
17
+ const execFileAsync = promisify(execFile);
18
+
19
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-reconcile-test-'));
20
+ process.env.AMALGM_DIR = path.join(tempRoot, '.amalgm');
21
+ process.env.AMALGM_WORKSPACES_DIR = path.join(process.env.AMALGM_DIR, 'workspaces');
22
+ process.env.AMALGM_COMPUTER_ID = 'machine-fly';
23
+ process.env.AMALGM_DEVICE_ID = 'device-machine-fly';
24
+
25
+ fs.mkdirSync(process.env.AMALGM_DIR, { recursive: true });
26
+ fs.writeFileSync(
27
+ path.join(process.env.AMALGM_DIR, 'computer.json'),
28
+ JSON.stringify({ computer_id: 'machine-fly', user_id: '11111111-1111-4111-8111-111111111111' }),
29
+ );
30
+ const legacyLedgerDir = path.join(process.env.AMALGM_DIR, 'state', 'workspace-follow');
31
+ const migratedRecord = 'aaaaaaaaaaaaaaaaaaaaaaaa';
32
+ fs.mkdirSync(legacyLedgerDir, { recursive: true });
33
+ fs.writeFileSync(
34
+ path.join(legacyLedgerDir, `${migratedRecord}.json`),
35
+ JSON.stringify({ peerMachine: 'retained-peer', repoKey: 'retained-repo' }),
36
+ );
37
+
38
+ const { closeLocalDb } = require('../state/db');
39
+ const { captureWorktreeDiff, headOid } = require('../workspace/git');
40
+ const { CHECKPOINT_CONTRACT, revisionOf } = require('../workspace/checkpoint');
41
+ const { listPairStates } = require('../workspace/ledger');
42
+ const { reconciliationStats, reconcilePair } = require('../workspace/reconcile');
43
+
44
+ test.after(() => {
45
+ closeLocalDb();
46
+ fs.rmSync(tempRoot, { recursive: true, force: true });
47
+ });
48
+
49
+ async function run(cwd, ...args) {
50
+ const { stdout } = await execFileAsync(
51
+ 'git',
52
+ ['-c', 'user.email=test@amalgm.test', '-c', 'user.name=Amalgm Test', '-c', 'commit.gpgsign=false', ...args],
53
+ { cwd, env: { ...process.env, GIT_TERMINAL_PROMPT: '0', LC_ALL: 'C' } },
54
+ );
55
+ return stdout;
56
+ }
57
+
58
+ const REMOTE = 'https://github.com/amalgm-test/app';
59
+ const TEN_LINES = Array.from({ length: 10 }, (_, i) => `line ${i + 1}`).join('\n');
60
+
61
+ /** origin bare + an author clone (the peer) + a receiver clone (local). */
62
+ async function makeRig(name) {
63
+ const origin = path.join(tempRoot, `${name}-origin.git`);
64
+ await run(tempRoot, 'init', '--bare', '-b', 'main', origin);
65
+ const author = path.join(tempRoot, `${name}-author`);
66
+ await run(tempRoot, 'clone', '-q', origin, author);
67
+ fs.writeFileSync(path.join(author, 'app.js'), 'v1\n');
68
+ fs.writeFileSync(path.join(author, 'doc.md'), `${TEN_LINES}\n`);
69
+ await run(author, 'add', '.');
70
+ await run(author, 'commit', '-m', 'c1');
71
+ await run(author, 'push', '-q', 'origin', 'main');
72
+ const receiver = path.join(tempRoot, `${name}-receiver`);
73
+ await run(tempRoot, 'clone', '-q', origin, receiver);
74
+ return { origin, author, receiver };
75
+ }
76
+
77
+ function cardFor(worktree, oid, { dirty = false, branch = 'main' } = {}) {
78
+ return {
79
+ path: worktree,
80
+ kind: 'primary',
81
+ health: 'healthy',
82
+ operation: 'none',
83
+ head: { branch, oid, detached: false },
84
+ repo: { remote: REMOTE, rootCommit: 'root' },
85
+ dirty: {
86
+ staged: 0, unstaged: dirty ? 1 : 0, untracked: 0, conflicted: 0, submodulesChanged: 0,
87
+ },
88
+ };
89
+ }
90
+
91
+ async function checkpointFor(worktree, machine) {
92
+ const captured = await captureWorktreeDiff(worktree);
93
+ return {
94
+ checkpoint: CHECKPOINT_CONTRACT,
95
+ machine,
96
+ repoKey: REMOTE,
97
+ branch: 'main',
98
+ baseOid: captured.baseOid,
99
+ revision: captured.diff === '' ? null : revisionOf(captured.baseOid, captured.diff),
100
+ diff: captured.diff,
101
+ capturedAt: new Date().toISOString(),
102
+ };
103
+ }
104
+
105
+ async function reconcile(rig, digest, { receiverDirty = true, peerDirty } = {}) {
106
+ const oidLocal = await headOid(rig.receiver);
107
+ const oidPeer = await headOid(rig.author);
108
+ const entry = await checkpointFor(rig.author, 'machine-mac');
109
+ return reconcilePair(
110
+ cardFor(rig.receiver, oidLocal, { dirty: receiverDirty }),
111
+ cardFor(rig.author, oidPeer, { dirty: peerDirty ?? entry.revision !== null }),
112
+ entry,
113
+ digest,
114
+ );
115
+ }
116
+
117
+ test('reconciliation exposes no per-machine enable or direction state', () => {
118
+ const stats = reconciliationStats();
119
+ assert.equal(Object.hasOwn(stats, 'enabled'), false);
120
+ assert.equal(Object.hasOwn(stats, 'direction'), false);
121
+ });
122
+
123
+ test('the renamed reconciliation ledger preserves existing pair ownership', () => {
124
+ const states = listPairStates();
125
+ assert.equal(states.some((state) => state.peerMachine === 'retained-peer'), true);
126
+ assert.equal(fs.existsSync(legacyLedgerDir), false);
127
+ assert.equal(
128
+ fs.existsSync(path.join(process.env.AMALGM_DIR, 'state', 'workspace-reconciliation', `${migratedRecord}.json`)),
129
+ true,
130
+ );
131
+ });
132
+
133
+ test('two writers in different files coexist, and a peer going clean removes only peer content', async () => {
134
+ const rig = await makeRig('coexist');
135
+
136
+ // Local work first — then the peer's edit arrives around it.
137
+ fs.writeFileSync(path.join(rig.receiver, 'local.md'), 'receiver work\n');
138
+ fs.appendFileSync(path.join(rig.author, 'app.js'), 'peer edit\n');
139
+
140
+ const applied = await reconcile(rig, 'pair-coexist');
141
+ assert.equal(applied.state, 'applied');
142
+ assert.equal(applied.reason, 'coexisting-with-local-work');
143
+ assert.equal(fs.readFileSync(path.join(rig.receiver, 'app.js'), 'utf8'), 'v1\npeer edit\n');
144
+ assert.equal(fs.readFileSync(path.join(rig.receiver, 'local.md'), 'utf8'), 'receiver work\n');
145
+
146
+ // The peer reverts: exactly their contribution leaves, local work stays.
147
+ fs.writeFileSync(path.join(rig.author, 'app.js'), 'v1\n');
148
+ const restored = await reconcile(rig, 'pair-coexist');
149
+ assert.equal(restored.state, 'local-work');
150
+ assert.equal(restored.reason, 'peer-clean');
151
+ assert.equal(fs.readFileSync(path.join(rig.receiver, 'app.js'), 'utf8'), 'v1\n');
152
+ assert.equal(fs.readFileSync(path.join(rig.receiver, 'local.md'), 'utf8'), 'receiver work\n');
153
+ });
154
+
155
+ test('two writers in the same file, different regions, merge into one combined file', async () => {
156
+ const rig = await makeRig('regions');
157
+
158
+ const docPath = path.join(rig.receiver, 'doc.md');
159
+ fs.writeFileSync(docPath, `${TEN_LINES}\n`.replace('line 1', 'line 1 (local)'));
160
+ fs.writeFileSync(path.join(rig.author, 'doc.md'), `${TEN_LINES}\n`.replace('line 10', 'line 10 (peer)'));
161
+
162
+ const outcome = await reconcile(rig, 'pair-regions');
163
+ assert.equal(outcome.state, 'applied');
164
+ const merged = fs.readFileSync(docPath, 'utf8');
165
+ assert.ok(merged.includes('line 1 (local)'), 'local region survives');
166
+ assert.ok(merged.includes('line 10 (peer)'), 'peer region arrives');
167
+ });
168
+
169
+ test('same region: both versions preserved, a named conflict surfaces, and resolution clears it', async () => {
170
+ const rig = await makeRig('overlap');
171
+
172
+ const appPath = path.join(rig.receiver, 'app.js');
173
+ fs.writeFileSync(appPath, 'local v2\n');
174
+ fs.writeFileSync(path.join(rig.author, 'app.js'), 'peer v2\n');
175
+
176
+ const conflicted = await reconcile(rig, 'pair-overlap');
177
+ assert.equal(conflicted.state, 'conflict');
178
+ assert.equal(conflicted.conflicts[0].path, 'app.js');
179
+ assert.equal(conflicted.conflicts[0].kind, 'content-overlap');
180
+ assert.equal(fs.readFileSync(appPath, 'utf8'), 'local v2\n', 'local version never overwritten');
181
+
182
+ // Unchanged inputs → the standing conflict is reported, not re-fought.
183
+ const standing = await reconcile(rig, 'pair-overlap');
184
+ assert.equal(standing.state, 'conflict');
185
+ assert.equal(fs.readFileSync(appPath, 'utf8'), 'local v2\n');
186
+
187
+ // The human resolves by adopting the peer's version → folds cleanly.
188
+ fs.writeFileSync(appPath, 'peer v2\n');
189
+ const resolved = await reconcile(rig, 'pair-overlap');
190
+ assert.equal(resolved.state, 'applied');
191
+ });
192
+
193
+ test('a peer deletion never removes a file the local writer edited', async () => {
194
+ const rig = await makeRig('delete');
195
+
196
+ fs.writeFileSync(path.join(rig.receiver, 'app.js'), 'precious local edit\n');
197
+ fs.rmSync(path.join(rig.author, 'app.js'));
198
+
199
+ const outcome = await reconcile(rig, 'pair-delete');
200
+ assert.equal(outcome.state, 'conflict');
201
+ assert.equal(outcome.conflicts[0].kind, 'delete-vs-edit');
202
+ assert.equal(fs.readFileSync(path.join(rig.receiver, 'app.js'), 'utf8'), 'precious local edit\n');
203
+ });
204
+
205
+ test('an echoed change — already present locally — folds as a no-op instead of looping', async () => {
206
+ const rig = await makeRig('echo');
207
+
208
+ fs.writeFileSync(path.join(rig.receiver, 'app.js'), 'shared edit\n');
209
+ fs.writeFileSync(path.join(rig.author, 'app.js'), 'shared edit\n');
210
+
211
+ const outcome = await reconcile(rig, 'pair-echo');
212
+ assert.equal(outcome.state, 'applied');
213
+ assert.equal(fs.readFileSync(path.join(rig.receiver, 'app.js'), 'utf8'), 'shared edit\n');
214
+ });
215
+
216
+ test('when the peer commits held work, the fast-forward absorbs it and local work rides along', async () => {
217
+ const rig = await makeRig('absorb');
218
+
219
+ // Coexisting first: peer content held, local work alongside.
220
+ fs.writeFileSync(path.join(rig.receiver, 'local.md'), 'receiver work\n');
221
+ fs.appendFileSync(path.join(rig.author, 'app.js'), 'peer edit\n');
222
+ assert.equal((await reconcile(rig, 'pair-absorb')).state, 'applied');
223
+
224
+ // The peer commits exactly what we hold and pushes.
225
+ await run(rig.author, 'commit', '-am', 'c2');
226
+ await run(rig.author, 'push', '-q', 'origin', 'main');
227
+ const c2 = await headOid(rig.author);
228
+
229
+ // Converge: at most a restore pass, then the forward, then settled.
230
+ let outcome;
231
+ for (let pass = 0; pass < 3; pass += 1) {
232
+ outcome = await reconcile(rig, 'pair-absorb', { peerDirty: false });
233
+ if (outcome.state === 'forwarded') break;
234
+ assert.equal(outcome.state, 'restoring', `unexpected state ${outcome.state}`);
235
+ }
236
+ assert.equal(outcome.state, 'forwarded');
237
+ assert.equal(await headOid(rig.receiver), c2);
238
+ assert.equal(fs.readFileSync(path.join(rig.receiver, 'app.js'), 'utf8'), 'v1\npeer edit\n');
239
+ assert.equal(fs.readFileSync(path.join(rig.receiver, 'local.md'), 'utf8'), 'receiver work\n');
240
+
241
+ const settled = await reconcile(rig, 'pair-absorb', { peerDirty: false });
242
+ assert.equal(settled.state, 'local-work');
243
+ });
244
+
245
+ test('a peer behind us never moves the tree back, and diverged histories never move at all', async () => {
246
+ const rig = await makeRig('history');
247
+ const c1 = await headOid(rig.receiver);
248
+
249
+ // Receiver commits ahead locally; the stale peer is simply behind.
250
+ fs.appendFileSync(path.join(rig.receiver, 'app.js'), 'local commit\n');
251
+ await run(rig.receiver, 'commit', '-am', 'local c2');
252
+ const behind = await reconcile(rig, 'pair-history', { receiverDirty: false });
253
+ assert.equal(behind.state, 'peer-behind');
254
+
255
+ // The peer commits its own line off c1: genuine divergence, no movement.
256
+ fs.appendFileSync(path.join(rig.author, 'app.js'), 'peer commit\n');
257
+ await run(rig.author, 'commit', '-am', 'peer c2');
258
+ await run(rig.author, 'push', '-q', 'origin', 'main');
259
+ const localHead = await headOid(rig.receiver);
260
+ const diverged = await reconcile(rig, 'pair-history', { receiverDirty: false });
261
+ assert.equal(diverged.state, 'diverged');
262
+ assert.equal(await headOid(rig.receiver), localHead, 'divergence never moves the tree');
263
+ assert.equal(c1 === localHead, false);
264
+ });
@@ -0,0 +1,155 @@
1
+ 'use strict';
2
+
3
+ const test = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+ const crypto = require('crypto');
6
+ const fs = require('fs');
7
+ const os = require('os');
8
+ const path = require('path');
9
+
10
+ const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'amalgm-workspace-tree-cloud-test-'));
11
+ process.env.AMALGM_DIR = path.join(tempRoot, '.amalgm');
12
+ process.env.AMALGM_WORKSPACES_DIR = path.join(tempRoot, 'workspaces');
13
+ process.env.AMALGM_COMPUTER_ID = 'computer-tree-cloud-test';
14
+ process.env.AMALGM_DEVICE_ID = 'device-tree-cloud-test';
15
+ fs.mkdirSync(process.env.AMALGM_DIR, { recursive: true });
16
+ fs.mkdirSync(process.env.AMALGM_WORKSPACES_DIR, { recursive: true });
17
+ const localUserId = '11111111-1111-4111-8111-111111111111';
18
+ fs.writeFileSync(path.join(process.env.AMALGM_DIR, 'computer.json'), JSON.stringify({ user_id: localUserId }));
19
+
20
+ const { closeLocalDb, openLocalDb } = require('../state/db');
21
+ const {
22
+ applyCloudTreeMutation,
23
+ installCloudTreeSnapshot,
24
+ nodeForPath,
25
+ } = require('../workspace/tree-store');
26
+ const { enqueueWorkspaceContentAttachments } = require('../workspace/tree-cloud');
27
+ const { uuidV7 } = require('../workspace/tree/identity');
28
+
29
+ test.after(() => {
30
+ closeLocalDb();
31
+ fs.rmSync(tempRoot, { recursive: true, force: true });
32
+ });
33
+
34
+ test('a recipient installs a workspace snapshot with its real role and applies UUID-addressed renames', () => {
35
+ const workspaceId = uuidV7();
36
+ const folderId = uuidV7();
37
+ const fileId = uuidV7();
38
+ const now = new Date().toISOString();
39
+ const snapshot = {
40
+ schemaVersion: 1,
41
+ workspaceId,
42
+ version: 0,
43
+ nodes: [
44
+ { id: fileId, workspaceId, parentId: folderId, kind: 'file', name: 'a.txt', createdAt: now, updatedAt: now },
45
+ { id: workspaceId, workspaceId, parentId: null, kind: 'folder', name: 'Shared workspace', createdAt: now, updatedAt: now },
46
+ { id: folderId, workspaceId, parentId: workspaceId, kind: 'folder', name: 'docs', createdAt: now, updatedAt: now },
47
+ ],
48
+ };
49
+ const bytes = Buffer.from(JSON.stringify(snapshot));
50
+ const target = path.join(process.env.AMALGM_WORKSPACES_DIR, 'recipient');
51
+ fs.mkdirSync(target);
52
+
53
+ const installed = installCloudTreeSnapshot(target, {
54
+ resourceId: workspaceId,
55
+ contract: 'workspace-tree@1',
56
+ schemaVersion: 1,
57
+ authorityEpoch: 7,
58
+ headVersion: 0,
59
+ snapshotVersion: 0,
60
+ snapshotChecksum: crypto.createHash('sha256').update(bytes).digest('hex'),
61
+ snapshotBase64: bytes.toString('base64'),
62
+ role: 'viewer',
63
+ });
64
+ assert.equal(installed.replica.resourceId, workspaceId);
65
+ assert.equal(fs.existsSync(path.join(target, 'docs', 'a.txt')), true);
66
+ assert.equal(nodeForPath(path.join(target, 'docs', 'a.txt')).node.id, fileId);
67
+ const permission = openLocalDb().prepare(`
68
+ SELECT role FROM workspace_node_permissions WHERE node_id = ? AND user_id = ? AND revoked_at IS NULL
69
+ `).get(workspaceId, localUserId);
70
+ assert.equal(permission.role, 'viewer');
71
+ const discovered = enqueueWorkspaceContentAttachments(workspaceId, [
72
+ { node_id: fileId, contract: 'text-yjs@1' },
73
+ ]);
74
+ assert.equal(discovered.length, 1);
75
+ assert.equal(discovered[0].resourceId, fileId);
76
+ assert.deepEqual(enqueueWorkspaceContentAttachments(workspaceId, [
77
+ { node_id: fileId, contract: 'text-yjs@1' },
78
+ ]), []);
79
+
80
+ applyCloudTreeMutation({
81
+ envelope: {
82
+ resourceId: workspaceId,
83
+ authorityEpoch: 7,
84
+ mutationId: 'mut_cloud_tree_0001',
85
+ deviceId: 'other-machine',
86
+ baseVersion: 0,
87
+ contract: 'workspace-tree@1',
88
+ schemaVersion: 1,
89
+ operationKind: 'tree.rename',
90
+ operation: { kind: 'tree.rename', workspaceId, nodeId: folderId, name: 'notes', changedAt: now },
91
+ },
92
+ version: 1,
93
+ committedAt: now,
94
+ });
95
+ assert.equal(fs.existsSync(path.join(target, 'notes', 'a.txt')), true);
96
+ assert.equal(nodeForPath(path.join(target, 'notes', 'a.txt')).node.id, fileId);
97
+
98
+ const nextFolderId = uuidV7();
99
+ const projected = {
100
+ schemaVersion: 1,
101
+ workspaceId,
102
+ version: 2,
103
+ nodes: [
104
+ { id: workspaceId, workspaceId, parentId: null, kind: 'folder', name: 'Shared workspace', createdAt: now, updatedAt: now },
105
+ { id: nextFolderId, workspaceId, parentId: workspaceId, kind: 'folder', name: 'allowed', createdAt: now, updatedAt: now },
106
+ { id: fileId, workspaceId, parentId: nextFolderId, kind: 'file', name: 'a.txt', createdAt: now, updatedAt: now },
107
+ ],
108
+ };
109
+ const projectedBytes = Buffer.from(JSON.stringify(projected));
110
+ installCloudTreeSnapshot(target, {
111
+ resourceId: workspaceId,
112
+ contract: 'workspace-tree@1',
113
+ schemaVersion: 1,
114
+ authorityEpoch: 8,
115
+ headVersion: 2,
116
+ snapshotVersion: 2,
117
+ snapshotChecksum: crypto.createHash('sha256').update(projectedBytes).digest('hex'),
118
+ snapshotBase64: projectedBytes.toString('base64'),
119
+ role: 'editor',
120
+ permissions: [{ node_id: nextFolderId, role: 'editor' }],
121
+ });
122
+ assert.equal(fs.existsSync(path.join(target, 'notes')), false);
123
+ assert.equal(fs.existsSync(path.join(target, 'allowed', 'a.txt')), true);
124
+ assert.equal(nodeForPath(path.join(target, 'allowed', 'a.txt')).node.id, fileId);
125
+ const rootPermission = openLocalDb().prepare(`
126
+ SELECT role FROM workspace_node_permissions WHERE node_id = ? AND user_id = ? AND revoked_at IS NULL
127
+ `).get(workspaceId, localUserId);
128
+ const childPermission = openLocalDb().prepare(`
129
+ SELECT role FROM workspace_node_permissions WHERE node_id = ? AND user_id = ? AND revoked_at IS NULL
130
+ `).get(nextFolderId, localUserId);
131
+ assert.equal(rootPermission, undefined);
132
+ assert.equal(childPermission.role, 'editor');
133
+
134
+ const conflictPath = path.join(target, 'allowed', 'already-here.txt');
135
+ fs.writeFileSync(conflictPath, 'preserve these bytes\n');
136
+ assert.throws(() => applyCloudTreeMutation({
137
+ envelope: {
138
+ resourceId: workspaceId,
139
+ authorityEpoch: 8,
140
+ mutationId: 'mut_cloud_tree_0002',
141
+ deviceId: 'other-machine',
142
+ baseVersion: 2,
143
+ contract: 'workspace-tree@1',
144
+ schemaVersion: 1,
145
+ operationKind: 'tree.create',
146
+ operation: {
147
+ kind: 'tree.create', workspaceId, nodeId: uuidV7(), parentId: nextFolderId,
148
+ nodeKind: 'file', name: 'already-here.txt', createdAt: now, changedAt: now,
149
+ },
150
+ },
151
+ version: 3,
152
+ committedAt: now,
153
+ }), /destination already exists/);
154
+ assert.equal(fs.readFileSync(conflictPath, 'utf8'), 'preserve these bytes\n');
155
+ });
@@ -0,0 +1,114 @@
1
+ 'use strict';
2
+
3
+ const test = require('node:test');
4
+ const assert = require('node:assert/strict');
5
+
6
+ const { can, resolveEffectiveRole } = require('../workspace/tree/access');
7
+ const { isUuidV7, uuidV7, uuidV7Timestamp } = require('../workspace/tree/identity');
8
+ const { applyTreeOperation } = require('../workspace/tree/reducer');
9
+
10
+ test('UUIDv7 identity is standard, timestamped, and independent of path', () => {
11
+ const now = 1_782_000_123_456;
12
+ const id = uuidV7(now, () => Buffer.alloc(16, 0xab));
13
+ assert.equal(isUuidV7(id), true);
14
+ assert.equal(uuidV7Timestamp(id), now);
15
+ assert.equal(id[14], '7');
16
+ assert.match(id[19], /[89ab]/);
17
+ });
18
+
19
+ test('the closest explicit permission wins and no_access fails closed', () => {
20
+ const parents = new Map([
21
+ ['workspace', null],
22
+ ['folder', 'workspace'],
23
+ ['file', 'folder'],
24
+ ]);
25
+ assert.equal(resolveEffectiveRole('file', parents, new Map([['workspace', 'viewer']])), 'viewer');
26
+ assert.equal(resolveEffectiveRole('file', parents, new Map([
27
+ ['workspace', 'viewer'],
28
+ ['folder', 'editor'],
29
+ ])), 'editor');
30
+ assert.equal(resolveEffectiveRole('file', parents, new Map([
31
+ ['workspace', 'owner'],
32
+ ['file', 'no_access'],
33
+ ])), 'no_access');
34
+ assert.equal(can('editor', 'trash'), true);
35
+ assert.equal(can('editor', 'purge'), false);
36
+ assert.equal(can('owner', 'purge'), true);
37
+ });
38
+
39
+ test('tree operations preserve identity across rename and reject cycles', () => {
40
+ const workspaceId = uuidV7();
41
+ const folderId = uuidV7();
42
+ const fileId = uuidV7();
43
+ let nodes = new Map([[
44
+ workspaceId,
45
+ {
46
+ id: workspaceId,
47
+ workspaceId,
48
+ parentId: null,
49
+ kind: 'folder',
50
+ name: 'Workspace',
51
+ trashedAt: null,
52
+ purgedAt: null,
53
+ createdAt: new Date().toISOString(),
54
+ updatedAt: new Date().toISOString(),
55
+ },
56
+ ]]);
57
+ nodes = applyTreeOperation(nodes, {
58
+ kind: 'tree.create', workspaceId, nodeId: folderId, parentId: workspaceId, nodeKind: 'folder', name: 'src',
59
+ });
60
+ nodes = applyTreeOperation(nodes, {
61
+ kind: 'tree.create', workspaceId, nodeId: fileId, parentId: folderId, nodeKind: 'file', name: 'index.js',
62
+ });
63
+ nodes = applyTreeOperation(nodes, {
64
+ kind: 'tree.rename', workspaceId, nodeId: fileId, name: 'main.js',
65
+ });
66
+ assert.equal(nodes.get(fileId).id, fileId);
67
+ assert.equal(nodes.get(fileId).name, 'main.js');
68
+ assert.throws(() => applyTreeOperation(nodes, {
69
+ kind: 'tree.move', workspaceId, nodeId: folderId, parentId: fileId,
70
+ }), /destination folder does not exist|inside itself/);
71
+ });
72
+
73
+ test('trash cascades preserve independently trashed children', () => {
74
+ const workspaceId = uuidV7();
75
+ const folderId = uuidV7();
76
+ const fileId = uuidV7();
77
+ const createdAt = '2026-07-21T00:00:00.000Z';
78
+ let nodes = new Map([[
79
+ workspaceId,
80
+ {
81
+ id: workspaceId,
82
+ workspaceId,
83
+ parentId: null,
84
+ kind: 'folder',
85
+ name: 'Workspace',
86
+ trashedAt: null,
87
+ purgedAt: null,
88
+ createdAt,
89
+ updatedAt: createdAt,
90
+ },
91
+ ]]);
92
+ nodes = applyTreeOperation(nodes, {
93
+ kind: 'tree.create', workspaceId, nodeId: folderId, parentId: workspaceId, nodeKind: 'folder', name: 'folder', createdAt,
94
+ });
95
+ nodes = applyTreeOperation(nodes, {
96
+ kind: 'tree.create', workspaceId, nodeId: fileId, parentId: folderId, nodeKind: 'file', name: 'note.md', createdAt,
97
+ });
98
+ nodes = applyTreeOperation(nodes, {
99
+ kind: 'tree.trash', workspaceId, nodeId: fileId, trashedAt: '2026-07-21T00:01:00.000Z',
100
+ });
101
+ nodes = applyTreeOperation(nodes, {
102
+ kind: 'tree.trash', workspaceId, nodeId: folderId, trashedAt: '2026-07-21T00:02:00.000Z',
103
+ });
104
+ assert.equal(nodes.get(fileId).trashedAt, '2026-07-21T00:01:00.000Z');
105
+
106
+ nodes = applyTreeOperation(nodes, {
107
+ kind: 'tree.restore', workspaceId, nodeId: folderId,
108
+ });
109
+ assert.equal(nodes.get(folderId).trashedAt, null);
110
+ assert.equal(nodes.get(fileId).trashedAt, '2026-07-21T00:01:00.000Z');
111
+ assert.throws(() => applyTreeOperation(nodes, {
112
+ kind: 'tree.restore', workspaceId, nodeId: folderId,
113
+ }), /must be trashed/);
114
+ });