diff --git a/hub-client/src/services/projectStorage.ts b/hub-client/src/services/projectStorage.ts index 30ba6a1c..fb3d1c4d 100644 --- a/hub-client/src/services/projectStorage.ts +++ b/hub-client/src/services/projectStorage.ts @@ -4,70 +4,16 @@ * This module provides CRUD operations for project entries and integrates * with the schema versioning/migration system. */ -import { openDB } from 'idb'; -import type { IDBPDatabase } from 'idb'; import type { ProjectEntry } from '../types/project'; import type { ExportData, UserSettings } from './storage/types'; import { - DB_NAME, STORES, - CURRENT_DB_VERSION, CURRENT_SCHEMA_VERSION, - getStructuralMigrations, - runMigrations, + getDb, + resetDbPromise, getSchemaVersion, } from './storage'; -/** - * Cached database promise. - * Reset to null if database needs to be reopened. - */ -let dbPromise: Promise | null = null; - -/** - * Get or open the database, running migrations as needed. - * - * This function: - * 1. Opens the database with the current version - * 2. Runs structural migrations (store/index creation) in the upgrade callback - * 3. Runs data transformation migrations after the database is open - */ -async function getDb(): Promise { - if (!dbPromise) { - dbPromise = (async () => { - // Open the database with structural migrations in the upgrade callback - const db = await openDB(DB_NAME, CURRENT_DB_VERSION, { - upgrade(db, oldVersion, _newVersion, transaction) { - // Create projects store if this is a fresh database - if (!db.objectStoreNames.contains(STORES.PROJECTS)) { - const store = db.createObjectStore(STORES.PROJECTS, { keyPath: 'id' }); - store.createIndex('indexDocId', 'indexDocId', { unique: true }); - store.createIndex('lastAccessed', 'lastAccessed'); - } - - // Run structural migrations for version upgrades - // oldVersion is 0 for new databases, so we start from 1 - const fromVersion = oldVersion || 1; - const structuralMigrations = getStructuralMigrations(fromVersion); - - for (const migration of structuralMigrations) { - if (migration.structural) { - console.log(`Running structural migration v${migration.version}: ${migration.description}`); - migration.structural(db, transaction); - } - } - }, - }); - - // Run data transformation migrations after the database is open - await runMigrations(db); - - return db; - })(); - } - return dbPromise; -} - /** * Generate a unique ID for a new project entry. */ @@ -279,8 +225,6 @@ export async function getDatabaseSchemaVersion(): Promise { * Call this when you need to force a reconnection (e.g., after schema changes). */ export function closeDatabase(): void { - if (dbPromise) { - dbPromise.then(db => db.close()).catch(() => {}); - dbPromise = null; - } + getDb().then(db => db.close()).catch(() => {}); + resetDbPromise(); } diff --git a/hub-client/src/services/storage/db.ts b/hub-client/src/services/storage/db.ts new file mode 100644 index 00000000..330968d4 --- /dev/null +++ b/hub-client/src/services/storage/db.ts @@ -0,0 +1,71 @@ +/** + * Shared database initialization. + * + * Both projectStorage and userSettings import getDb from here, + * ensuring the upgrade callback always runs when the database is first created. + */ + +import { openDB } from 'idb'; +import type { IDBPDatabase } from 'idb'; +import { DB_NAME, STORES } from './types'; +import { CURRENT_DB_VERSION, getStructuralMigrations } from './migrations'; +import { runMigrations } from './migrationRunner'; + +/** + * Cached database promise. + * Reset to null if database needs to be reopened. + */ +let dbPromise: Promise | null = null; + +/** + * Get or open the database, running migrations as needed. + * + * This function: + * 1. Opens the database with the current version + * 2. Runs structural migrations (store/index creation) in the upgrade callback + * 3. Runs data transformation migrations after the database is open + * + * The database instance is cached — subsequent calls return the same promise. + */ +export async function getDb(): Promise { + if (!dbPromise) { + dbPromise = (async () => { + const db = await openDB(DB_NAME, CURRENT_DB_VERSION, { + upgrade(db, oldVersion, _newVersion, transaction) { + // Create projects store if this is a fresh database + if (!db.objectStoreNames.contains(STORES.PROJECTS)) { + const store = db.createObjectStore(STORES.PROJECTS, { keyPath: 'id' }); + store.createIndex('indexDocId', 'indexDocId', { unique: true }); + store.createIndex('lastAccessed', 'lastAccessed'); + } + + // Run structural migrations for version upgrades + // oldVersion is 0 for new databases, so we start from 1 + const fromVersion = oldVersion || 1; + const structuralMigrations = getStructuralMigrations(fromVersion); + + for (const migration of structuralMigrations) { + if (migration.structural) { + console.log(`Running structural migration v${migration.version}: ${migration.description}`); + migration.structural(db, transaction); + } + } + }, + }); + + // Run data transformation migrations after the database is open + await runMigrations(db); + + return db; + })(); + } + return dbPromise; +} + +/** + * Reset the cached database promise. + * Call this if the database connection is lost or needs to be reopened. + */ +export function resetDbPromise(): void { + dbPromise = null; +} diff --git a/hub-client/src/services/storage/index.ts b/hub-client/src/services/storage/index.ts index e7c4b9db..1bfda275 100644 --- a/hub-client/src/services/storage/index.ts +++ b/hub-client/src/services/storage/index.ts @@ -39,6 +39,9 @@ export { MigrationFailedError, } from './migrationRunner'; +// Database initialization +export { getDb, resetDbPromise } from './db'; + // Utilities export { generateColorFromId, diff --git a/hub-client/src/services/userSettings.ts b/hub-client/src/services/userSettings.ts index 34a11ab6..917a7de9 100644 --- a/hub-client/src/services/userSettings.ts +++ b/hub-client/src/services/userSettings.ts @@ -5,28 +5,16 @@ * User identity is used for presence features (cursor colors, display names). */ -import { openDB } from 'idb'; -import type { IDBPDatabase } from 'idb'; import type { UserSettings } from './storage/types'; import { - DB_NAME, STORES, - CURRENT_DB_VERSION, + getDb, generateColorFromId, generateAnonymousName, isValidHexColor, isValidUserName, } from './storage'; -/** - * Get the database instance. - * Note: This opens the DB independently to avoid circular dependencies with projectStorage. - * The DB version and migration system ensures consistency. - */ -async function getDb(): Promise { - return openDB(DB_NAME, CURRENT_DB_VERSION); -} - /** * Get the current user identity. * @@ -35,12 +23,6 @@ async function getDb(): Promise { */ export async function getUserIdentity(): Promise { const db = await getDb(); - - // Check if store exists - if (!db.objectStoreNames.contains(STORES.USER_SETTINGS)) { - throw new Error('User settings store not found. Database may not be fully initialized.'); - } - const settings = await db.get(STORES.USER_SETTINGS, 'identity'); if (settings) { diff --git a/package-lock.json b/package-lock.json index 32a1e780..21e5790e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -328,7 +328,6 @@ "version": "8.50.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.50.1", "@typescript-eslint/types": "8.50.1", @@ -535,7 +534,6 @@ "version": "8.15.0", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -634,6 +632,7 @@ "hub-client/node_modules/dompurify": { "version": "3.2.7", "license": "(MPL-2.0 OR Apache-2.0)", + "peer": true, "optionalDependencies": { "@types/trusted-types": "^2.0.7" } @@ -666,7 +665,6 @@ "version": "9.39.2", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -1053,6 +1051,7 @@ "hub-client/node_modules/marked": { "version": "14.0.0", "license": "MIT", + "peer": true, "bin": { "marked": "bin/marked.js" }, @@ -1395,7 +1394,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -1868,7 +1866,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" }, @@ -1892,7 +1889,6 @@ } ], "license": "MIT", - "peer": true, "engines": { "node": ">=18" } @@ -1914,7 +1910,6 @@ "resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz", "integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==", "license": "MIT", - "peer": true, "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", @@ -3352,7 +3347,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -3452,7 +3448,6 @@ "integrity": "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3722,6 +3717,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -3865,7 +3861,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -4260,7 +4255,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/dunder-proto": { "version": "1.0.1", @@ -4481,7 +4477,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", @@ -4818,7 +4813,6 @@ "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.7.tgz", "integrity": "sha512-jq9l1DM0zVIvsm3lv9Nw9nlJnMNPOcAtsbsgiUhWcFzPE99Gvo6yRTlszSLLYacMeQ6quHD6hMfId8crVHvexw==", "license": "MIT", - "peer": true, "engines": { "node": ">=16.9.0" } @@ -5113,7 +5107,6 @@ "integrity": "sha512-Cvc9WUhxSMEo4McES3P7oK3QaXldCfNWp7pl2NNeiIFlCoLr3kfq9kb1fxftiwk1FLV7CvpvDfonxtzUDeSOPg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "cssstyle": "^4.2.1", "data-urls": "^5.0.0", @@ -5222,6 +5215,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -5676,6 +5670,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -5768,7 +5763,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5778,7 +5772,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5791,7 +5784,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/react-refresh": { "version": "0.18.0", @@ -5853,8 +5847,7 @@ "version": "6.0.0", "resolved": "https://registry.npmjs.org/reveal.js/-/reveal.js-6.0.0.tgz", "integrity": "sha512-RayDr1FL3Jglnf6p9xHGJ0U18va96PiuLs/JHnd1cdDOXvC+3lsXKe6ujl7PX0pvnhNW2Tpqnr6PEKpJVO2exw==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/reveal.js-menu": { "version": "2.1.0", @@ -6516,7 +6509,6 @@ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -6609,7 +6601,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -6718,7 +6709,6 @@ "integrity": "sha512-FQMeF0DJdWY0iOnbv466n/0BudNdKj1l5jYgl5JVTwjSsZSlqyXFt/9+1sEyhR6CLowbZpV7O1sCHrzBhucKKg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@vitest/expect": "4.0.17", "@vitest/mocker": "4.0.17", @@ -6996,7 +6986,6 @@ "resolved": "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz", "integrity": "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg==", "license": "MIT", - "peer": true, "engines": { "node": ">=10.0.0" }, @@ -7068,7 +7057,6 @@ "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.5.tgz", "integrity": "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g==", "license": "MIT", - "peer": true, "funding": { "url": "https://github.com/sponsors/colinhacks" } diff --git a/ts-packages/quarto-sync-client/src/client.test.ts b/ts-packages/quarto-sync-client/src/client.test.ts new file mode 100644 index 00000000..859201f7 --- /dev/null +++ b/ts-packages/quarto-sync-client/src/client.test.ts @@ -0,0 +1,205 @@ +/** + * Tests for createSyncClient identity handling. + * + * Regression test: identity must be written to the index document + * regardless of peer connection status. A prior bug gated identity + * writes behind an `isOnline` check, which always failed because + * the peer timeout was 1ms. + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import type { DocumentId } from '@automerge/automerge-repo'; + +// Track calls to setIdentity +const setIdentitySpy = vi.fn(); + +// ── Mocks ────────────────────────────────────────────────────────────── + +vi.mock('@automerge/automerge', () => ({ + clone: vi.fn((doc: unknown) => structuredClone(doc)), + from: vi.fn((val: unknown) => structuredClone(val)), + save: vi.fn(() => new Uint8Array(0)), +})); + +vi.mock('@automerge/automerge-repo-network-websocket', () => ({ + BrowserWebSocketClientAdapter: vi.fn(), +})); + +vi.mock('@automerge/automerge-repo-storage-indexeddb', () => ({ + IndexedDBStorageAdapter: vi.fn(), +})); + +// Mock setIdentity so we can assert it was called +vi.mock('@quarto/quarto-automerge-schema', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + setIdentity: (...args: unknown[]) => { + setIdentitySpy(...args); + return original.setIdentity(...(args as Parameters)); + }, + }; +}); + +/** + * Create a mock DocHandle that stores its document in-memory. + */ +function createMockHandle(initialDoc: T): { + handle: { doc: () => T; change: (fn: (d: T) => void) => void; on: () => void; off: () => void; documentId: string; whenReady: () => Promise; update: (fn: (d: T) => T) => void }; + getDoc: () => T; +} { + let current = structuredClone(initialDoc); + const handle = { + documentId: 'mock-doc-id' as DocumentId, + doc: () => current, + change: (fn: (d: T) => void) => { + const draft = structuredClone(current); + fn(draft); + current = draft; + }, + update: (fn: (d: T) => T) => { + current = fn(structuredClone(current)); + }, + on: vi.fn(), + off: vi.fn(), + whenReady: () => Promise.resolve(), + }; + return { handle, getDoc: () => current }; +} + +// Mock Repo — never emits 'peer', so waitForPeer always times out +vi.mock('@automerge/automerge-repo', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + Repo: vi.fn(), + // Keep real URL helpers + generateAutomergeUrl: original.generateAutomergeUrl, + parseAutomergeUrl: original.parseAutomergeUrl, + }; +}); + +import { Repo } from '@automerge/automerge-repo'; +import { createSyncClient } from './client.js'; +import type { SyncClientCallbacks } from './types.js'; +import type { IndexDocument } from '@quarto/quarto-automerge-schema'; + +/** Minimal no-op callbacks. */ +function noopCallbacks(): SyncClientCallbacks { + return { + onFileAdded: vi.fn(), + onFileChanged: vi.fn(), + onBinaryChanged: vi.fn(), + onFileRemoved: vi.fn(), + onFilesChange: vi.fn(), + onIdentitiesChange: vi.fn(), + onConnectionChange: vi.fn(), + onError: vi.fn(), + }; +} + +/** + * Install a mock Repo that never connects to a peer. + * - `find()` returns `connectHandle` (for `connect` flow) + * - `import()` returns `createHandle` (for `createNewProject` flow) + * - `create()` returns `createHandle` + * - networkSubsystem never emits 'peer', so `waitForPeer` always times out + */ +function installMockRepo( + connectHandle: ReturnType['handle'], + createHandle: ReturnType['handle'], +) { + const mockNetworkSubsystem = { + on: vi.fn(), + off: vi.fn(), + }; + + // Repo is called with `new`, so the mock must be a constructor + vi.mocked(Repo).mockImplementation(function (this: unknown) { + Object.assign(this as Record, { + find: vi.fn().mockResolvedValue(connectHandle), + import: vi.fn().mockReturnValue(createHandle), + create: vi.fn().mockReturnValue(createHandle), + networkSubsystem: mockNetworkSubsystem, + }); + return this as Repo; + } as unknown as typeof Repo); +} + +describe('createSyncClient identity', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('connect writes identity even when peer connection times out', async () => { + const indexDoc: IndexDocument = { files: {}, version: 1, identities: {} }; + const { handle, getDoc } = createMockHandle(indexDoc); + + installMockRepo(handle, handle); + + const cbs = noopCallbacks(); + const client = createSyncClient(cbs); + + await client.connect( + 'ws://localhost:9999', + 'mock-doc-id', + 'actor-123', + 'Alice', + '#FF0000', + ); + + // Identity must have been written + expect(setIdentitySpy).toHaveBeenCalledWith( + expect.anything(), + 'actor-123', + 'Alice', + '#FF0000', + ); + + // Verify the document was actually mutated + const doc = getDoc(); + expect(doc.identities?.['actor-123']).toEqual({ + name: 'Alice', + color: '#FF0000', + }); + }); + + it('createNewProject writes identity even when peer connection times out', async () => { + const indexDoc: IndexDocument = { files: {}, version: 1, identities: {} }; + const { handle, getDoc } = createMockHandle(indexDoc); + + installMockRepo(handle, handle); + + const cbs = noopCallbacks(); + const client = createSyncClient(cbs); + + // Use resolveActorId callback (mirrors App.tsx which passes actorId=undefined) + const resolveActorId = vi.fn().mockResolvedValue('actor-456'); + + await client.createNewProject( + { syncServer: 'ws://localhost:9999', files: [] }, + undefined, // actorId — App.tsx passes undefined + 'Bob', + '#00FF00', + resolveActorId, + ); + + // resolveActorId must be called regardless of connection status + expect(resolveActorId).toHaveBeenCalled(); + + // Identity must have been written + expect(setIdentitySpy).toHaveBeenCalledWith( + expect.anything(), + 'actor-456', + 'Bob', + '#00FF00', + ); + + // Verify the document was actually mutated + const doc = getDoc(); + expect(doc.identities?.['actor-456']).toEqual({ + name: 'Bob', + color: '#00FF00', + }); + }); +}); diff --git a/ts-packages/quarto-sync-client/src/client.ts b/ts-packages/quarto-sync-client/src/client.ts index 48f96a3a..da4c73f1 100644 --- a/ts-packages/quarto-sync-client/src/client.ts +++ b/ts-packages/quarto-sync-client/src/client.ts @@ -364,18 +364,14 @@ export function createSyncClient(callbacks: SyncClientCallbacks, astOptions?: AS ); } - - - // Only attempt to modify documents if we're online (to avoid conflicts) - if (isOnline) { - // Migrate schema and sync identity - indexHandle.change(d => { - migrateIndexDocument(d); - if (actorId && screenName) { - setIdentity(d, actorId, screenName, color || ''); - } - }); - } + // Migrate schema and sync identity. + // Always write locally — Automerge will sync when the peer connects. + indexHandle.change(d => { + migrateIndexDocument(d); + if (actorId && screenName) { + setIdentity(d, actorId, screenName, color || ''); + } + }); const currentDoc = indexHandle.doc()!; const files = getFilesFromIndex(currentDoc); @@ -721,8 +717,7 @@ export function createSyncClient(callbacks: SyncClientCallbacks, astOptions?: AS const indexUrl = generateAutomergeUrl(); const { documentId: indexDocId } = parseAutomergeUrl(indexUrl); - // Only resolve actor ID from server if we're online - const resolvedActorId = isOnline && resolveActorId + const resolvedActorId = resolveActorId ? (await resolveActorId(indexDocId)) ?? undefined : actorId; state.actorId = resolvedActorId ?? null;