diff --git a/hub-client/src/hooks/useCollectionSets.ts b/hub-client/src/hooks/useCollectionSets.ts index 517374f36..f0cae6a40 100644 --- a/hub-client/src/hooks/useCollectionSets.ts +++ b/hub-client/src/hooks/useCollectionSets.ts @@ -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()); diff --git a/hub-client/src/services/projectSetService.test.ts b/hub-client/src/services/projectSetService.test.ts new file mode 100644 index 000000000..2da8dbb23 --- /dev/null +++ b/hub-client/src/services/projectSetService.test.ts @@ -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) => 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']); +}); diff --git a/hub-client/src/services/projectSetService.ts b/hub-client/src/services/projectSetService.ts index 00f7cda60..9e4a03106 100644 --- a/hub-client/src/services/projectSetService.ts +++ b/hub-client/src/services/projectSetService.ts @@ -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; + whenConnected(timeoutMs?: number): Promise; } +type CollectionCreationPolicy = 'server-required' | 'local-first'; + /** * Timeouts for the connectCollection find/classify path. Tests inject * small values; production uses the defaults. @@ -333,19 +336,21 @@ function acquireServer(syncServerUrl: string): ServerConnection { wsAdapter, refCount: 0, connectedPeers: 0, - whenConnected(timeoutMs: number): Promise { + whenConnected(timeoutMs?: number): Promise { if (conn.connectedPeers > 0) return Promise.resolve(true); return new Promise((resolve) => { - let timer: ReturnType; + let timer: ReturnType | 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); }); }, @@ -563,45 +568,72 @@ export async function connectCollections( return { connected, failed }; } +async function createCollectionDocument( + syncServerUrl: string, + name: string | undefined, + policy: CollectionCreationPolicy, +): Promise { + 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; + const doc = automergeFrom(initial); + const handle = server.repo.import(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 { - 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; - const doc = automergeFrom(initial); - const handle = server.repo.import(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'); } /** @@ -785,9 +817,12 @@ export async function connect( * * @returns The document ID of the newly created ProjectSetDocument. */ -export async function createProjectSet(syncServerUrl: string): Promise { +export async function createProjectSet( + syncServerUrl: string, + name?: string, +): Promise { await disconnect(); - return createCollection(syncServerUrl); + return createCollectionDocument(syncServerUrl, name, 'local-first'); } /**