0.10.0: Archify Sync, optional live collaboration on a model

Several people edit one model at once through a server you run yourself
(archify-vscode-ext-sync); the files stay ordinary files on each disk.
Sign-in through the browser, offline-first with later merge, teammates'
cursors in the Source view and text editors, and a Shared section on the
model page with people, versions, Compare and Restore.

Without a server nothing changes: the sync code is a separate bundle that
loads only when a model is shared, after a sign-in, or when a sync command
or invite link is used.
This commit is contained in:
root-at-skic committed 2026-11-06 22:55:01 +02:00
1 parent a9021312b0
commit c99f3e5fe8
27 files changed
+2899 -14

No files matched your search

+163
View File
@@ -1,6 +1,10 @@
import assert from 'node:assert/strict';
import path from 'node:path';
import * as vscode from 'vscode';
import * as Y from 'yjs';
import { FileBridge } from '../../src/sync/fileBridge';
import { LiveSpace } from '../../src/sync/liveSpace';
import type { SyncAuth } from '../../src/sync/auth';
const EXTENSION_ID = 'root-at-skic.archify-vscode-ext';
@@ -483,6 +487,165 @@ export async function run(): Promise<void> {
await vscode.workspace.fs.delete(target);
});
await check('without a shared model, sync stays unloaded and its commands are there', async () => {
const commands = await vscode.commands.getCommands(true);
for (const id of ['archify.sync.signIn', 'archify.sync.share', 'archify.sync.join']) assert.ok(commands.includes(id), `${id} is registered`);
// The extension host's module cache: dist/sync.js (Yjs, the WebSocket) is not loaded.
const loaded = Object.keys(require.cache).filter((file) => file.endsWith(`${path.sep}dist${path.sep}sync.js`));
assert.deepEqual(loaded, [], 'the sync bundle is not loaded');
});
await check('sync file bridge: local edits reach the document, teammates\' edits reach files and editors', async () => {
const root = workspaceFile('shared');
const file = (rel: string) => vscode.Uri.joinPath(root, ...rel.split('/'));
const write = (rel: string, text: string) => vscode.workspace.fs.writeFile(file(rel), new TextEncoder().encode(text));
const read = async (rel: string) => new TextDecoder().decode(await vscode.workspace.fs.readFile(file(rel)));
const exists = (rel: string) => Promise.resolve(vscode.workspace.fs.stat(file(rel))).then(() => true, () => false);
await write('main.archify', 'model:\n id: shared\n');
await write('0100-context/a.archify', 'schema_version: 1\r\nmeta:\r\n title: A\r\n');
await write('notes.txt', 'not a model file');
const doc = new Y.Doc();
const teammate = new Y.Doc();
// The server in between: every update reaches the other side.
doc.on('update', (u: Uint8Array, origin: unknown) => origin !== 'remote' && Y.applyUpdate(teammate, u, 'remote'));
teammate.on('update', (u: Uint8Array, origin: unknown) => origin !== 'remote' && Y.applyUpdate(doc, u, 'remote'));
const quiet = { info() {}, warn() {}, error() {}, debug() {}, trace() {} } as unknown as vscode.LogOutputChannel;
const bridge = new FileBridge(root, doc, { origin: 'local' }, quiet);
const theirs = () => teammate.getMap<Y.Text>('files');
try {
await bridge.pushAll(false);
bridge.start(true);
assert.deepEqual([...theirs().keys()].sort(), ['0100-context/a.archify', 'main.archify'], 'only model files are shared');
assert.equal(theirs().get('0100-context/a.archify')!.toString(), 'schema_version: 1\nmeta:\n title: A\n', 'line endings travel as \\n');
// A local edit in an open editor.
const main = await vscode.workspace.openTextDocument(file('main.archify'));
const edit = new vscode.WorkspaceEdit();
edit.insert(main.uri, new vscode.Position(2, 0), ' name: Shared\n');
assert.ok(await vscode.workspace.applyEdit(edit));
await waitFor('local edit at the teammate', () => theirs().get('main.archify')!.toString() === 'model:\n id: shared\n name: Shared\n');
await main.save();
// The teammate types twice in a row into the open file: both land, nothing is undone by an echo.
theirs().get('main.archify')!.insert(0, '# one\n');
theirs().get('main.archify')!.insert(0, '# two\n');
await waitFor('teammate edits in the open editor', () => main.getText() === '# two\n# one\nmodel:\n id: shared\n name: Shared\n');
await waitFor('saved, so git sees it', () => !main.isDirty);
await new Promise((r) => setTimeout(r, 500));
assert.equal(theirs().get('main.archify')!.toString(), main.getText(), 'no echo changed it back');
// The teammate edits a closed file, keeping its CRLF on disk.
const a = theirs().get('0100-context/a.archify')!;
a.insert(a.length, ' output: a.html\n');
const deadline = Date.now() + 10_000;
while ((await read('0100-context/a.archify')) !== 'schema_version: 1\r\nmeta:\r\n title: A\r\n output: a.html\r\n' && Date.now() < deadline) await new Promise((r) => setTimeout(r, 200));
assert.equal(await read('0100-context/a.archify'), 'schema_version: 1\r\nmeta:\r\n title: A\r\n output: a.html\r\n');
// A new file and a deletion from the teammate.
teammate.transact(() => {
const t = new Y.Text();
t.insert(0, 'schema_version: 1\n');
theirs().set('registry/people.archify-registry', t);
theirs().set('../escape.archify', new Y.Text('nope'));
});
const until = async (what: string, probe: () => Promise<boolean>) => {
const end = Date.now() + 10_000;
while (!(await probe())) {
if (Date.now() > end) throw new Error(`Timed out waiting for ${what}`);
await new Promise((r) => setTimeout(r, 200));
}
};
await until('new file on disk', () => exists('registry/people.archify-registry'));
assert.ok(!(await exists('../escape.archify')), 'a path outside the model is never written');
teammate.transact(() => theirs().delete('0100-context/a.archify'));
await until('deleted file gone', async () => !(await exists('0100-context/a.archify')));
// A change on disk by another tool (git, an agent) to a closed file.
await write('registry/people.archify-registry', 'schema_version: 1\nelements: {}\n');
await waitFor('disk change at the teammate', () => theirs().get('registry/people.archify-registry')?.toString() === 'schema_version: 1\nelements: {}\n');
} finally {
bridge.dispose();
await vscode.commands.executeCommand('workbench.action.closeAllEditors');
await vscode.workspace.fs.delete(root, { recursive: true });
}
});
// Opt-in: a running Archify Sync server, a space on it and an access token.
const syncServer = process.env.ARCHIFY_SYNC_TEST_URL;
if (syncServer) await check('two shared copies of a model stay the same through the sync server', async () => {
const space = process.env.ARCHIFY_SYNC_TEST_SPACE!;
const token = process.env.ARCHIFY_SYNC_TEST_TOKEN!;
const storage = workspaceFile('.sync-storage');
const auth = {
session: async () => ({ account: { id: 'it', display_name: 'Integration' } }),
accessToken: async () => token,
} as unknown as SyncAuth;
const quiet = { info() {}, warn() {}, error() {}, debug() {}, trace() {} } as unknown as vscode.LogOutputChannel;
const services = (dir: string) => ({ context: { globalStorageUri: vscode.Uri.joinPath(storage, dir) } as vscode.ExtensionContext, auth, log: quiet, onChange() {} });
const link = { server: syncServer, space, name: 'IT' };
const mine = workspaceFile('live-mine');
const theirs = workspaceFile('live-theirs');
const read = async (root: vscode.Uri, rel: string) => {
try {
return new TextDecoder().decode(await vscode.workspace.fs.readFile(vscode.Uri.joinPath(root, ...rel.split('/'))));
} catch {
return undefined;
}
};
const until = async (what: string, probe: () => Promise<boolean>) => {
const end = Date.now() + 15_000;
while (!(await probe())) {
if (Date.now() > end) throw new Error(`Timed out waiting for ${what}`);
await new Promise((r) => setTimeout(r, 200));
}
};
await vscode.workspace.fs.writeFile(vscode.Uri.joinPath(mine, 'main.archify'), new TextEncoder().encode('model:\n id: it\n'));
await vscode.workspace.fs.writeFile(vscode.Uri.joinPath(mine, 'registry', 'people.archify-registry'), new TextEncoder().encode('schema_version: 1\n'));
await vscode.workspace.fs.createDirectory(theirs);
const a = new LiveSpace(mine, link, 'share', services('a'));
let b: LiveSpace | undefined;
try {
await a.start();
await until('shared copy online', async () => a.state === 'online');
b = new LiveSpace(theirs, link, 'join', services('b'));
await b.start();
await until('joined copy gets the files', async () => (await read(theirs, 'main.archify')) === 'model:\n id: it\n' && (await read(theirs, 'registry/people.archify-registry')) === 'schema_version: 1\n');
// Typing in one copy's editor shows up in the other copy's file.
const doc = await vscode.workspace.openTextDocument(vscode.Uri.joinPath(mine, 'main.archify'));
const edit = new vscode.WorkspaceEdit();
edit.insert(doc.uri, new vscode.Position(2, 0), ' name: Live 🚀\n');
assert.ok(await vscode.workspace.applyEdit(edit));
await until('edit arrives in the other copy', async () => (await read(theirs, 'main.archify')) === 'model:\n id: it\n name: Live 🚀\n');
// A file written by another tool in the other copy comes back.
await vscode.workspace.fs.writeFile(vscode.Uri.joinPath(theirs, '0100-context', 'c.archify'), new TextEncoder().encode('schema_version: 1\n'));
await until('new file arrives back', async () => (await read(mine, '0100-context/c.archify')) === 'schema_version: 1\n');
await until('presence shows one person in both', async () => a.people().length === 1 && b!.people().length === 1);
// A cursor in one copy shows in the other, and stays on its word while text is typed above it.
const at = 'model:\n id: it\n name: '.length;
assert.ok(a.setCursor(vscode.Uri.joinPath(mine, 'main.archify'), { anchor: at, head: at + 4 }));
const theirsMain = vscode.Uri.joinPath(theirs, 'main.archify');
await until('cursor arrives in the other copy', async () => b!.cursors(theirsMain).length === 1);
const cursor = b.cursors(theirsMain)[0];
assert.deepEqual([cursor.name, cursor.anchor, cursor.head], ['Integration', at, at + 4]);
assert.equal(a.cursors(vscode.Uri.joinPath(mine, 'main.archify')).length, 0, 'your own cursor is not drawn for you');
const above = new vscode.WorkspaceEdit();
above.insert(theirsMain, new vscode.Position(0, 0), '# top\n');
assert.ok(await vscode.workspace.applyEdit(above));
await until('cursor moves with the text', async () => b!.cursors(theirsMain)[0]?.anchor === at + '# top\n'.length);
a.setCursor(vscode.Uri.joinPath(mine, 'main.archify'), undefined);
await until('cursor goes away', async () => b!.cursors(theirsMain).length === 0);
} finally {
a.dispose();
b?.dispose();
await vscode.commands.executeCommand('workbench.action.revertAndCloseActiveEditor');
for (const uri of [mine, theirs, storage]) await vscode.workspace.fs.delete(uri, { recursive: true }).then(undefined, () => undefined);
}
});
await check('registry and decision pages own their document: edits make it dirty and VS Code undo reverts them', async () => {
for (const [file, viewType] of [
['model/registry/containers.archify-registry', 'archify.registry'],
+101
View File
@@ -0,0 +1,101 @@
import assert from 'node:assert/strict';
import * as fs from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { test } from 'node:test';
import * as Y from 'yjs';
import { checkServerUrl, parseInviteLink, syncSocketUrl } from '../src/sync/api';
import { OfflineStore } from '../src/sync/offlineStore';
import { formatLink, parseLink } from '../src/sync/protocol';
import { applyToYText, safeChange } from '../src/sync/textSync';
import { applyChanges } from '../src/util/textDiff';
test('safeChange never splits a surrogate pair', () => {
const cases: [string, string][] = [
['x😀y', 'x😃y'],
['😀', '😃'],
['a😀', 'a😀b'],
['😀b', 'a😀b'],
['label: 🚀 API', 'label: 🛰 API'],
['plain', 'plainer'],
];
for (const [before, after] of cases) {
const change = safeChange(before, after)!;
assert.equal(applyChanges(before, [change]), after, `${before} → ${after}`);
for (const at of [change.from, change.to]) {
const code = before.charCodeAt(at);
assert.ok(!(code >= 0xdc00 && code <= 0xdfff), `change edge ${at} lands inside a pair in ${before}`);
}
}
assert.equal(safeChange('same', 'same'), undefined);
});
test('applyToYText reaches the text and syncs between copies', () => {
const a = new Y.Doc();
const b = new Y.Doc();
a.on('update', (u: Uint8Array) => Y.applyUpdate(b, u));
const text = a.getMap<Y.Text>('files');
const t = new Y.Text();
text.set('main.archify', t);
applyToYText(t, 'model:\n id: shop\n');
applyToYText(t, '# 😀\nmodel:\n id: shop\n');
applyToYText(t, '# 😃\nmodel:\n id: shop2\n');
assert.equal(b.getMap<Y.Text>('files').get('main.archify')!.toString(), '# 😃\nmodel:\n id: shop2\n');
assert.equal(applyToYText(t, '# 😃\nmodel:\n id: shop2\n'), false);
});
test('the offline copy keeps every update and survives a torn write', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'archify-sync-'));
try {
const doc = new Y.Doc();
const store = new OfflineStore(dir, 'space');
assert.equal(store.exists(), false);
doc.on('update', (u: Uint8Array) => store.append(u));
doc.getText('t').insert(0, 'one ');
doc.getText('t').insert(4, 'two');
assert.equal(store.pending, 2);
// A crash in the middle of the next append.
fs.appendFileSync(path.join(dir, 'space.log'), Buffer.from([200, 0, 0, 0, 1, 2]));
const again = new Y.Doc();
for (const u of new OfflineStore(dir, 'space').load()) Y.applyUpdate(again, u);
assert.equal(again.getText('t').toString(), 'one two');
store.compact(Y.encodeStateAsUpdate(doc));
assert.equal(store.pending, 0);
const compacted = new Y.Doc();
for (const u of new OfflineStore(dir, 'space').load()) Y.applyUpdate(compacted, u);
assert.equal(compacted.getText('t').toString(), 'one two');
store.remove();
assert.equal(store.exists(), false);
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
test('server addresses: https, or http only on this machine', () => {
assert.equal(checkServerUrl('https://archify.example.com/').url, 'https://archify.example.com');
assert.equal(checkServerUrl('http://localhost:8080').url, 'http://localhost:8080');
assert.equal(checkServerUrl('http://127.0.0.1:8080/sync/').url, 'http://127.0.0.1:8080/sync');
assert.ok(checkServerUrl('http://archify.example.com').problem);
assert.ok(checkServerUrl('ftp://x').problem);
assert.ok(checkServerUrl('nonsense').problem);
assert.equal(syncSocketUrl('https://a.example', 'id'), 'wss://a.example/v1/spaces/id/sync?v=1');
});
test('invite links in both forms', () => {
const token = 'AbCdEfGhIjKlMnOpQrStUvWxYz0123456789_-abcde';
assert.deepEqual(parseInviteLink(`https://sync.example/join/${token}`), { server: 'https://sync.example', invite: token });
assert.deepEqual(parseInviteLink(`https://example.com/archify/join/${token}`), { server: 'https://example.com/archify', invite: token });
const ext = `vscode://root-at-skic.archify-vscode-ext/join?server=${encodeURIComponent('https://sync.example/')}&invite=${token}`;
assert.deepEqual(parseInviteLink(ext), { server: 'https://sync.example', invite: token });
assert.equal(parseInviteLink('https://example.com/other'), undefined);
assert.equal(parseInviteLink('not a link'), undefined);
});
test('link files round-trip and refuse junk', () => {
const link = { server: 'https://sync.example', space: '0198f3a2-1b2c-7d3e-8f40-123456789abc', name: 'Shop' };
assert.deepEqual(parseLink(formatLink(link)), link);
assert.equal(parseLink('{"server":"https://x","space":"../../etc"}'), undefined);
assert.equal(parseLink('not json'), undefined);
});