Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion hub-client/src/hooks/useCollectionSets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,7 +255,7 @@ export function useCollectionSets(): [CollectionSetsState, CollectionSetsActions
setStatus('connecting');
setError(null);
try {
const docId = await projectSetService.createCollection(syncServer, DEFAULT_ROOT_NAME);
const docId = await projectSetService.createProjectSet(syncServer, DEFAULT_ROOT_NAME);
await establishRoot(docId, syncServer);
await migrateLocalCollections(syncServer);
setCollections(projectSetService.listCollections());
Expand Down
50 changes: 50 additions & 0 deletions hub-client/src/services/projectSetService.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { expect, it, vi } from 'vitest';
const mocks = vi.hoisted(() => {
const handle = {
documentId: 'offline-root-doc',
doc: vi.fn(() => ({ projects: {}, version: 1 })),
on: vi.fn(),
off: vi.fn(),
};
const repo = {
import: vi.fn(() => handle),
flush: vi.fn(async () => {}),
networkSubsystem: { on: vi.fn() },
};

return {
repo,
from: vi.fn((document: Record<string, unknown>) => document),
save: vi.fn(() => new Uint8Array([1, 2, 3])),
};
});
vi.mock('@automerge/automerge-repo', () => ({
Repo: class {
import = mocks.repo.import;
flush = mocks.repo.flush;
networkSubsystem = mocks.repo.networkSubsystem;
},
}));
vi.mock('@automerge/automerge-repo-network-websocket', () => ({
BrowserWebSocketClientAdapter: class { disconnect = vi.fn(); },
}));
vi.mock('@automerge/automerge-repo-storage-indexeddb', () => ({
IndexedDBStorageAdapter: class {},
}));
vi.mock('@automerge/automerge', () => ({
from: mocks.from,
save: mocks.save,
}));
import { createProjectSet } from './projectSetService';
it('imports an empty personal root locally while sync is offline', async () => {
await expect(
createProjectSet('wss://offline.example/ws', 'My projects'),
).resolves.toBe('offline-root-doc');
expect(mocks.from).toHaveBeenCalledWith({
projects: {},
version: 1,
name: 'My projects',
});
expect(mocks.repo.import).toHaveBeenCalledWith(new Uint8Array([1, 2, 3]));
expect(mocks.repo.flush).toHaveBeenCalledWith(['offline-root-doc']);
});
119 changes: 77 additions & 42 deletions hub-client/src/services/projectSetService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,11 +90,14 @@ interface ServerConnection {
connectedPeers: number;
/**
* Resolve `true` as soon as a sync peer is connected (immediately if
* one already is), or `false` after `timeoutMs` with none.
* one already is), or `false` after `timeoutMs` with none. Without a
* timeout, wait for the next connection indefinitely.
*/
whenConnected(timeoutMs: number): Promise<boolean>;
whenConnected(timeoutMs?: number): Promise<boolean>;
}

type CollectionCreationPolicy = 'server-required' | 'local-first';

/**
* Timeouts for the connectCollection find/classify path. Tests inject
* small values; production uses the defaults.
Expand Down Expand Up @@ -333,19 +336,21 @@ function acquireServer(syncServerUrl: string): ServerConnection {
wsAdapter,
refCount: 0,
connectedPeers: 0,
whenConnected(timeoutMs: number): Promise<boolean> {
whenConnected(timeoutMs?: number): Promise<boolean> {
if (conn.connectedPeers > 0) return Promise.resolve(true);
return new Promise((resolve) => {
let timer: ReturnType<typeof setTimeout>;
let timer: ReturnType<typeof setTimeout> | undefined;
const onPeer = () => {
clearTimeout(timer);
if (timer !== undefined) clearTimeout(timer);
repo.networkSubsystem.off('peer', onPeer);
resolve(true);
};
timer = setTimeout(() => {
repo.networkSubsystem.off('peer', onPeer);
resolve(false);
}, timeoutMs);
if (timeoutMs !== undefined) {
timer = setTimeout(() => {
repo.networkSubsystem.off('peer', onPeer);
resolve(false);
}, timeoutMs);
}
repo.networkSubsystem.on('peer', onPeer);
});
},
Expand Down Expand Up @@ -563,45 +568,72 @@ export async function connectCollections(
return { connected, failed };
}

async function createCollectionDocument(
syncServerUrl: string,
name: string | undefined,
policy: CollectionCreationPolicy,
): Promise<string> {
const server = acquireServer(syncServerUrl);
try {
if (policy === 'server-required') {
// Shared collections require a peer so callers know the document has
// reached the configured server before they publish its pointer.
if (!(await server.whenConnected(10000))) {
throw new Error(
'Could not reach sync server. Please check your connection and try again.',
);
}
onConnectionChange?.(true);
} else {
// A fresh personal root is useful while offline. The websocket adapter
// keeps reconnecting, and the Repo will announce this handle when its
// first peer eventually arrives.
onConnectionChange?.(false);
void server.whenConnected().then(() => onConnectionChange?.(true));
}

const initial = {
projects: {},
version: CURRENT_PROJECT_SET_SCHEMA_VERSION,
...(name !== undefined ? { name } : {}),
} as Record<string, unknown>;
const doc = automergeFrom(initial);
const handle = server.repo.import<ProjectSetDocument>(automergeSerialize(doc));

if (policy === 'local-first') {
// Repo saves are normally debounced. Setup must not publish pointers
// until the empty root is durably available for an offline reload.
await server.repo.flush([handle.documentId]);
}

const onChange = () => notifyChange();
handle.on('change', onChange);
const conn: CollectionConnection = {
docId: handle.documentId,
syncServer: syncServerUrl,
handle,
cleanup: () => handle.off('change', onChange),
};
connections.set(conn.docId, conn);
notifyChange();
return handle.documentId;
} catch (err) {
releaseServer(syncServerUrl);
throw err;
}
}

/**
* Create a new collection document on a sync server.
* Create a new shared collection document on a sync server.
*
* @returns The document ID of the new ProjectSetDocument.
* @throws If the sync server is unreachable.
*/
export async function createCollection(
export function createCollection(
syncServerUrl: string,
name?: string,
): Promise<string> {
const server = acquireServer(syncServerUrl);
// Creation requires the server so the document actually syncs.
if (!(await server.whenConnected(10000))) {
releaseServer(syncServerUrl);
throw new Error(
'Could not reach sync server. Please check your connection and try again.',
);
}
onConnectionChange?.(true);

const initial = {
projects: {},
version: CURRENT_PROJECT_SET_SCHEMA_VERSION,
...(name !== undefined ? { name } : {}),
} as Record<string, unknown>;
const doc = automergeFrom(initial);
const handle = server.repo.import<ProjectSetDocument>(automergeSerialize(doc));

const onChange = () => notifyChange();
handle.on('change', onChange);
const conn: CollectionConnection = {
docId: handle.documentId,
syncServer: syncServerUrl,
handle,
cleanup: () => handle.off('change', onChange),
};
connections.set(conn.docId, conn);
notifyChange();
return handle.documentId;
return createCollectionDocument(syncServerUrl, name, 'server-required');
}

/**
Expand Down Expand Up @@ -785,9 +817,12 @@ export async function connect(
*
* @returns The document ID of the newly created ProjectSetDocument.
*/
export async function createProjectSet(syncServerUrl: string): Promise<string> {
export async function createProjectSet(
syncServerUrl: string,
name?: string,
): Promise<string> {
await disconnect();
return createCollection(syncServerUrl);
return createCollectionDocument(syncServerUrl, name, 'local-first');
}

/**
Expand Down