From 8f8fbe28d7ae178fa57694371df8c31983140482 Mon Sep 17 00:00:00 2001 From: Dante Date: Thu, 10 Sep 2026 14:36:51 +0800 Subject: [PATCH 1/2] fix: wait for metadata before archive cascade Model: gpt-5.6-sol Signed-off-by: Dante --- ...-09-10-archive-after-metadata-hydration.md | 44 +++++++ packages/components/src/components/AGENTS.md | 5 +- .../src/hooks/use-session-actions.ts | 71 +++++++++++ .../tests/use-session-actions.test.ts | 116 +++++++++++++++++- 4 files changed, 230 insertions(+), 6 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-09-10-archive-after-metadata-hydration.md diff --git a/.agents/notes/implemented/bug-fix/2026-09-10-archive-after-metadata-hydration.md b/.agents/notes/implemented/bug-fix/2026-09-10-archive-after-metadata-hydration.md new file mode 100644 index 000000000..fcd674144 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-09-10-archive-after-metadata-hydration.md @@ -0,0 +1,44 @@ +# Archive only after metadata hydration + +Status: implemented +Translation: pending + +## Abstract + +Session Detail can render a bootstrapped root Session before the workspace metadata scan has +discovered its child Tabs. Archive now waits for that initial scan before deriving the lifecycle +subtree, preventing an early action from archiving only the root. Already-hydrated actions retain +their existing repository-read and rendered-cache fallback behavior. + +## Problem + +Archive derives lifecycle descendants from `sessionMetaCacheAtom`. During cold start, bootstrap +metadata may make the requested root interactive while `docMetaCacheReadyAtom` is still false and +the cache does not yet contain a direct `parentSessionId` child. Starting writes from that partial +view leaves the child active after its root is archived. + +## Decision + +`archiveSession` waits for both `docMetaCacheReadyAtom` and a ready `docMetaCacheScopeAtom` owned by +the captured workspace runtime before reading the lifecycle cache or authoring archive side effects. +Readiness is the existing signal that the workspace-wide metadata scan has merged its snapshot with +live events, so this keeps descendant discovery on the same source of truth without issuing another +full metadata query for each archive action. A runtime switch rejects the pending action and releases +its subscriptions rather than combining the old repository with a new workspace cache. + +The individual root metadata read still prefers the repository and falls back to rendered metadata. +This preserves closing a visible Session when its own repository read lags after the initial scan. + +## Verification + +The owning `use-session-actions` suite constructs a visible root with readiness false, starts an +archive, and asserts that no metadata write occurs. It then hydrates a synthetic direct child Tab, +marks the cache ready, and verifies that both root and child receive the archived idle state. A second +case switches runtimes during the wait and verifies rejection without writes. Existing coverage +continues to verify the rendered-meta fallback after initial hydration. + +## Limits + +This change does not alter which relationship fields define lifecycle containment, restore or delete +semantics, or metadata scan failure handling. It only closes the pre-hydration archive window tracked +by [Lody issue #574](https://github.com/LodyAI/Lody/issues/574). diff --git a/packages/components/src/components/AGENTS.md b/packages/components/src/components/AGENTS.md index 6aacac4a9..52a33dc6d 100644 --- a/packages/components/src/components/AGENTS.md +++ b/packages/components/src/components/AGENTS.md @@ -58,8 +58,9 @@ Ownership and explanations: [README.md](README.md). reintroduce a create-then-hand-off flow (pending-turn refs, post-mount ref flushes): a promoted tab must not exist before its first message is locally durable, and preserved composer text crosses the promotion via the input draft cache, not a component ref. - `archiveSession` falls back to the rendered meta cache when the repo read lags - hydration, and a close failure surfaces a toast — never a silent no-op. + `archiveSession` waits for the initial doc-meta scan before deriving lifecycle + descendants, then falls back to the rendered meta cache when an individual repo read + lags; a close failure surfaces a toast — never a silent no-op. - Desktop changelogs open in-app as sanitized Markdown with raw HTML off. Only missing notes fall back to the website, via `getChangelogUrl` and `openExternalUrl`, never a hardcoded link. diff --git a/packages/components/src/hooks/use-session-actions.ts b/packages/components/src/hooks/use-session-actions.ts index 38f4a4e09..0d0d7bf1a 100644 --- a/packages/components/src/hooks/use-session-actions.ts +++ b/packages/components/src/hooks/use-session-actions.ts @@ -47,6 +47,8 @@ import debug from 'debug'; import { v4 as uuidv4 } from 'uuid'; import { activeWorkspaceRuntimeAtom, type WorkspaceRuntime } from '@/atoms/runtime'; import { + docMetaCacheScopeAtom, + docMetaCacheReadyAtom, setDocMetaByRoomIdAtom, sessionMetaCacheAtom, sessionMetaCountAtom, @@ -64,6 +66,68 @@ import { useAuthenticatedConvex } from './use-authenticated-convex'; const log = debug('lody:session-actions'); +function assertDocMetaCacheReadyForRuntime( + store: ReturnType, + runtime: WorkspaceRuntime +): void { + const activeRuntime = store.get(activeWorkspaceRuntimeAtom); + const cacheScope = store.get(docMetaCacheScopeAtom); + if ( + activeRuntime !== runtime || + cacheScope?.runtime !== runtime || + !cacheScope.ready || + !store.get(docMetaCacheReadyAtom) + ) { + throw new Error('Workspace changed while waiting for session metadata'); + } +} + +function waitForDocMetaCacheReady( + store: ReturnType, + runtime: WorkspaceRuntime +): Promise { + const isReady = () => { + const cacheScope = store.get(docMetaCacheScopeAtom); + return ( + store.get(activeWorkspaceRuntimeAtom) === runtime && + cacheScope?.runtime === runtime && + cacheScope.ready && + store.get(docMetaCacheReadyAtom) + ); + }; + if (isReady()) return Promise.resolve(); + + return new Promise((resolve, reject) => { + let settled = false; + let unsubscribeReady: () => void = () => undefined; + let unsubscribeScope: () => void = () => undefined; + let unsubscribeRuntime: () => void = () => undefined; + const settle = (error?: Error) => { + if (settled) return; + settled = true; + unsubscribeReady(); + unsubscribeScope(); + unsubscribeRuntime(); + if (error) reject(error); + else resolve(); + }; + const check = () => { + if (store.get(activeWorkspaceRuntimeAtom) !== runtime) { + settle(new Error('Workspace changed while waiting for session metadata')); + return; + } + if (isReady()) settle(); + }; + + unsubscribeReady = store.sub(docMetaCacheReadyAtom, check); + unsubscribeScope = store.sub(docMetaCacheScopeAtom, check); + unsubscribeRuntime = store.sub(activeWorkspaceRuntimeAtom, check); + + // Close the check-to-subscribe race after installing all subscriptions. + check(); + }); +} + type RepoDocMetaPatch = Parameters[1]; type CreateSessionResult = { sessionId: SessionId; @@ -1208,10 +1272,17 @@ export function useSessionActions(): SessionActions { throw new Error('Runtime not ready'); } + // Lifecycle descendants are discovered from the workspace-wide metadata + // cache. A rendered root can arrive through bootstrap data before that + // cache contains its child tabs, so do not author any archive writes until + // the initial scan establishes a complete containment view. + await waitForDocMetaCacheReady(store, runtime); + const sessionRoomId = getSessionRoomId(sessionId); const repoMeta = (await runtime.repo.getDocMeta(sessionRoomId))?.meta as | SessionMeta | undefined; + assertDocMetaCacheReadyForRuntime(store, runtime); // The repo read is preferred (freshest lifecycle fields), but it can lag // a session the UI already renders. The archive write below is an // idempotent patch, so the rendered meta cache is enough to proceed — a diff --git a/packages/components/tests/use-session-actions.test.ts b/packages/components/tests/use-session-actions.test.ts index 4d7412ccb..cf6cb4d3b 100644 --- a/packages/components/tests/use-session-actions.test.ts +++ b/packages/components/tests/use-session-actions.test.ts @@ -78,7 +78,11 @@ vi.mock('../src/hooks/use-authenticated-convex', () => ({ })); import { runtimeAtom, type WorkspaceRuntime } from '../src/atoms/runtime'; -import { docMetaCacheReadyAtom, sessionMetaCacheAtom } from '../src/atoms/doc-meta'; +import { + docMetaCacheReadyAtom, + docMetaCacheScopeAtom, + sessionMetaCacheAtom, +} from '../src/atoms/doc-meta'; import { currentWorkspaceIdAtom, currentWorkspaceSlugAtom } from '../src/atoms/workspace-context'; import { countSessionMentions, @@ -234,11 +238,18 @@ describe('useSessionActions', () => { workspaceSlug?: string | null; docMetaCacheReady?: boolean; sessionMetaCache?: Record; + jotaiStore?: ReturnType; } = {} ): Promise => { - const jotaiStore = createStore(); + const jotaiStore = options.jotaiStore ?? createStore(); jotaiStore.set(runtimeAtom, runtime); jotaiStore.set(docMetaCacheReadyAtom, options.docMetaCacheReady ?? false); + jotaiStore.set(docMetaCacheScopeAtom, { + runtime, + workspaceId: runtime.workspaceId, + workspaceSlug: runtime.workspaceSlug, + ready: options.docMetaCacheReady ?? false, + }); jotaiStore.set(sessionMetaCacheAtom, options.sessionMetaCache ?? {}); jotaiStore.set(currentWorkspaceIdAtom, options.workspaceId ?? ('workspace-1' as WorkspaceId)); jotaiStore.set(currentWorkspaceSlugAtom, options.workspaceSlug ?? 'workspace-slug'); @@ -1051,7 +1062,7 @@ describe('useSessionActions', () => { flush: vi.fn(async () => undefined), } as unknown as WorkspaceRuntime['repo'], }); - const actions = await renderActions(runtime); + const actions = await renderActions(runtime, { docMetaCacheReady: true }); await actions.archiveSession(sessionId); @@ -1080,6 +1091,7 @@ describe('useSessionActions', () => { repo: { getDocMeta, upsertDocMeta } as unknown as WorkspaceRuntime['repo'], }); const actions = await renderActions(runtime, { + docMetaCacheReady: true, sessionMetaCache: { [getSessionRoomId(sessionId)]: renderedMeta }, }); @@ -1096,6 +1108,99 @@ describe('useSessionActions', () => { ); }); + it('waits for complete metadata hydration before archiving a root and its child tab', async () => { + const rootSession = { + id: 'archive-hydrating-root' as SessionId, + machineId: 'machine-root' as MachineId, + createdAt: '2026-09-10T00:00:00.000Z', + } as SessionMeta; + const tabSession = { + id: 'archive-hydrating-tab' as SessionId, + machineId: rootSession.machineId, + parentSessionId: rootSession.id, + createdAt: '2026-09-10T00:01:00.000Z', + } as SessionMeta; + const upsertDocMeta = vi.fn(async () => undefined); + const getDocMeta = vi.fn(async (roomId: string) => { + if (roomId === getSessionRoomId(rootSession.id)) return { meta: rootSession }; + if (roomId === getMachineRoomId(rootSession.machineId)) return { meta: {} }; + return undefined; + }); + const runtime = createRuntime({ + repo: { getDocMeta, upsertDocMeta } as unknown as WorkspaceRuntime['repo'], + }); + const jotaiStore = createStore(); + const actions = await renderActions(runtime, { + jotaiStore, + docMetaCacheReady: false, + sessionMetaCache: { [getSessionRoomId(rootSession.id)]: rootSession }, + }); + + const archivePromise = actions.archiveSession(rootSession.id); + await Promise.resolve(); + expect(upsertDocMeta).not.toHaveBeenCalled(); + + jotaiStore.set(sessionMetaCacheAtom, { + [getSessionRoomId(rootSession.id)]: rootSession, + [getSessionRoomId(tabSession.id)]: tabSession, + }); + jotaiStore.set(docMetaCacheReadyAtom, true); + jotaiStore.set(docMetaCacheScopeAtom, { + runtime, + workspaceId: runtime.workspaceId, + workspaceSlug: runtime.workspaceSlug, + ready: true, + }); + await archivePromise; + + for (const session of [rootSession, tabSession]) { + expect(upsertDocMeta).toHaveBeenCalledWith( + getSessionRoomId(session.id), + expect.objectContaining({ isArchived: true, status: { type: 'idle' } }) + ); + } + }); + + it('cancels a pre-hydration archive when the workspace runtime changes', async () => { + const sessionId = 'archive-old-workspace-root' as SessionId; + const sessionMeta = { + id: sessionId, + machineId: 'machine-old' as MachineId, + createdAt: '2026-09-10T00:00:00.000Z', + } as SessionMeta; + const upsertDocMeta = vi.fn(async () => undefined); + const runtime = createRuntime({ + repo: { + getDocMeta: vi.fn(async () => ({ meta: sessionMeta })), + upsertDocMeta, + } as unknown as WorkspaceRuntime['repo'], + }); + const jotaiStore = createStore(); + const actions = await renderActions(runtime, { + jotaiStore, + docMetaCacheReady: false, + sessionMetaCache: { [getSessionRoomId(sessionId)]: sessionMeta }, + }); + + const archivePromise = actions.archiveSession(sessionId); + const nextRuntime = createRuntime({ workspaceId: 'workspace-2' as WorkspaceId }); + jotaiStore.set(runtimeAtom, nextRuntime); + + await expect(archivePromise).rejects.toThrow( + 'Workspace changed while waiting for session metadata' + ); + expect(upsertDocMeta).not.toHaveBeenCalled(); + + jotaiStore.set(docMetaCacheReadyAtom, true); + jotaiStore.set(docMetaCacheScopeAtom, { + runtime: nextRuntime, + workspaceId: nextRuntime.workspaceId, + workspaceSlug: nextRuntime.workspaceSlug, + ready: true, + }); + expect(upsertDocMeta).not.toHaveBeenCalled(); + }); + it('archives child tabs and independently opened session workspaces together', async () => { const rootSession = { id: 'archive-root' as SessionId, @@ -1136,7 +1241,10 @@ describe('useSessionActions', () => { const runtime = createRuntime({ repo: { getDocMeta, upsertDocMeta } as unknown as WorkspaceRuntime['repo'], }); - const actions = await renderActions(runtime, { sessionMetaCache }); + const actions = await renderActions(runtime, { + docMetaCacheReady: true, + sessionMetaCache, + }); await actions.archiveSession(rootSession.id); From 2128112e68c5a7b61f71542c3d33048004a48729 Mon Sep 17 00:00:00 2001 From: Dante Date: Thu, 10 Sep 2026 15:26:53 +0800 Subject: [PATCH 2/2] fix: settle live metadata before cache readiness Model: gpt-5.6-sol Signed-off-by: Dante --- ...-09-10-archive-after-metadata-hydration.md | 22 +- packages/components/src/AGENTS.md | 3 + packages/components/src/atoms/doc-meta.ts | 107 +++++-- .../tests/doc-meta-subscription.test.ts | 282 +++++++++++++++++- 4 files changed, 374 insertions(+), 40 deletions(-) diff --git a/.agents/notes/implemented/bug-fix/2026-09-10-archive-after-metadata-hydration.md b/.agents/notes/implemented/bug-fix/2026-09-10-archive-after-metadata-hydration.md index fcd674144..0f21039ad 100644 --- a/.agents/notes/implemented/bug-fix/2026-09-10-archive-after-metadata-hydration.md +++ b/.agents/notes/implemented/bug-fix/2026-09-10-archive-after-metadata-hydration.md @@ -22,9 +22,12 @@ view leaves the child active after its root is archived. `archiveSession` waits for both `docMetaCacheReadyAtom` and a ready `docMetaCacheScopeAtom` owned by the captured workspace runtime before reading the lifecycle cache or authoring archive side effects. Readiness is the existing signal that the workspace-wide metadata scan has merged its snapshot with -live events, so this keeps descendant discovery on the same source of truth without issuing another -full metadata query for each archive action. A runtime switch rejects the pending action and releases -its subscriptions rather than combining the old repository with a new workspace cache. +live events. The subscription therefore keeps readiness false while metadata or existence events +observed during the bootstrap window remain in its deferred projection queue, including any full +metadata fetch needed to initialize a newly discovered document. This keeps descendant discovery on +the same source of truth without issuing another full metadata query for each archive action. A +runtime switch rejects the pending action and releases its subscriptions rather than combining the +old repository with a new workspace cache. The individual root metadata read still prefers the repository and falls back to rendered metadata. This preserves closing a visible Session when its own repository read lags after the initial scan. @@ -35,10 +38,15 @@ The owning `use-session-actions` suite constructs a visible root with readiness archive, and asserts that no metadata write occurs. It then hydrates a synthetic direct child Tab, marks the cache ready, and verifies that both root and child receive the archived idle state. A second case switches runtimes during the wait and verifies rejection without writes. Existing coverage -continues to verify the rendered-meta fallback after initial hydration. +continues to verify the rendered-meta fallback after initial hydration. The metadata subscription +suite also injects both metadata and existence events after the bootstrap snapshot is captured, +asserts readiness stays false while their deferred projection is outstanding, and verifies the newly +discovered child is cached before readiness becomes true. ## Limits -This change does not alter which relationship fields define lifecycle containment, restore or delete -semantics, or metadata scan failure handling. It only closes the pre-hydration archive window tracked -by [Lody issue #574](https://github.com/LodyAI/Lody/issues/574). +This change does not alter which relationship fields define lifecycle containment or restore/delete +semantics. A failed or empty live full-metadata fetch remains pending until a later event or the +bootstrap snapshot supplies authoritative metadata; this adds no retry policy. The change only closes +the pre-hydration archive window tracked by +[Lody issue #574](https://github.com/LodyAI/Lody/issues/574). diff --git a/packages/components/src/AGENTS.md b/packages/components/src/AGENTS.md index 4a38c4b73..451cfb856 100644 --- a/packages/components/src/AGENTS.md +++ b/packages/components/src/AGENTS.md @@ -52,6 +52,9 @@ Parent `AGENTS.md` files also apply. and the mobile workspace stack do not start early. The workspace identity's syncing state follows that same scoped readiness, not the coarser connection state; an online transport does not imply that workspace data is ready. +- Doc-meta readiness includes live metadata and existence events observed during + the bootstrap scan. Do not publish a ready scope until their deferred projection + batches and required full-metadata fetches have settled. ## Billing data diff --git a/packages/components/src/atoms/doc-meta.ts b/packages/components/src/atoms/doc-meta.ts index db75ce172..eba59607b 100644 --- a/packages/components/src/atoms/doc-meta.ts +++ b/packages/components/src/atoms/doc-meta.ts @@ -533,6 +533,36 @@ export const docMetaSubscriptionAtom = atomEffect((get, set) => { const existenceEpochByDocId = new Map(); const existenceStateByDocId = new Map(); const fullMetaFetchEpochByDocId = new Map(); + // The bootstrap scan overlaps the live watch. Keep readiness false until any + // events observed during that window, including their full-metadata fetches, + // have reached the cache projection. + const pendingPatches = new Map>(); + type ExistenceEvent = { docId: string; state: DocExistenceState }; + const pendingExistenceUpdates: ExistenceEvent[] = []; + let flushTimer: ReturnType | null = null; + let bootstrapCacheMerged = false; + let pendingFullMetaFetches = 0; + + const markCacheReadyIfSettled = () => { + if ( + cancelled || + !bootstrapCacheMerged || + flushTimer !== null || + pendingExistenceUpdates.length > 0 || + pendingPatches.size > 0 || + pendingFullMetaFetches > 0 || + pendingMetaDocIds.size > 0 + ) { + return; + } + set(docMetaCacheReadyAtom, true); + set(docMetaCacheScopeAtom, { + runtime, + workspaceId: runtime.workspaceId, + workspaceSlug: runtime.workspaceSlug, + ready: true, + }); + }; const clearCachedDocMeta = (docId: string) => { if (isSessionDocRoomId(docId)) { @@ -603,26 +633,46 @@ export const docMetaSubscriptionAtom = atomEffect((get, set) => { const fetchEpoch = (fullMetaFetchEpochByDocId.get(docId) ?? 0) + 1; fullMetaFetchEpochByDocId.set(docId, fetchEpoch); - - void fetchDocMeta(runtime.repo, docId).then((meta) => { - if (cancelled) return; - if (fullMetaFetchEpochByDocId.get(docId) !== fetchEpoch) return; - if (existenceStateByDocId.get(docId) === 'deleted') return; - if (expectedExistence) { - if (existenceStateByDocId.get(docId) !== expectedExistence.state) return; - if ((existenceEpochByDocId.get(docId) ?? 0) !== expectedExistence.epoch) return; - } - if (!meta) { - if (expectedExistence?.state === 'missing') { - clearCachedDocMeta(docId); - } else { - pendingMetaDocIds.add(docId); + pendingFullMetaFetches += 1; + + void fetchDocMeta(runtime.repo, docId) + .then((meta) => { + if (cancelled) return; + if (fullMetaFetchEpochByDocId.get(docId) !== fetchEpoch) return; + if (existenceStateByDocId.get(docId) === 'deleted') return; + if (expectedExistence) { + if (existenceStateByDocId.get(docId) !== expectedExistence.state) return; + if ((existenceEpochByDocId.get(docId) ?? 0) !== expectedExistence.epoch) return; } - return; - } - pendingMetaDocIds.delete(docId); - setCachedDocMeta(docId, meta); - }); + if (!meta) { + if (expectedExistence?.state === 'missing') { + pendingMetaDocIds.delete(docId); + clearCachedDocMeta(docId); + } else if (hasCachedDocMeta(docId)) { + pendingMetaDocIds.delete(docId); + } else { + pendingMetaDocIds.add(docId); + } + return; + } + pendingMetaDocIds.delete(docId); + setCachedDocMeta(docId, meta); + }) + .catch((error: unknown) => { + if (cancelled) return; + if (fullMetaFetchEpochByDocId.get(docId) !== fetchEpoch) return; + if (existenceStateByDocId.get(docId) === 'deleted') return; + if (hasCachedDocMeta(docId)) { + pendingMetaDocIds.delete(docId); + return; + } + pendingMetaDocIds.add(docId); + console.warn('[doc-meta] Failed to fetch metadata:', docId, error); + }) + .finally(() => { + pendingFullMetaFetches -= 1; + markCacheReadyIfSettled(); + }); }; const handleDocumentExistence = (docId: string, state: DocExistenceState) => { @@ -635,6 +685,7 @@ export const docMetaSubscriptionAtom = atomEffect((get, set) => { existenceStateByDocId.set(docId, state); if (state === 'deleted') { + pendingMetaDocIds.delete(docId); clearCachedDocMeta(docId); return; } @@ -695,11 +746,6 @@ export const docMetaSubscriptionAtom = atomEffect((get, set) => { // Bound each projection turn so reconnect catch-up cannot monopolize the // mobile main thread. Rejected: microtask-only batching, which still blocks // paint until a large CRDT metadata burst is fully projected into Jotai. - const pendingPatches = new Map>(); - type ExistenceEvent = { docId: string; state: DocExistenceState }; - const pendingExistenceUpdates: ExistenceEvent[] = []; - let flushTimer: ReturnType | null = null; - const takePendingPatchBatch = (maxEntries: number): Array<[string, Record]> => { const entries: Array<[string, Record]> = []; if (maxEntries <= 0) return entries; @@ -758,6 +804,7 @@ export const docMetaSubscriptionAtom = atomEffect((get, set) => { if (pendingExistenceUpdates.length > 0 || pendingPatches.size > 0) { scheduleFlush(); } + markCacheReadyIfSettled(); }; function scheduleFlush() { @@ -821,13 +868,11 @@ export const docMetaSubscriptionAtom = atomEffect((get, set) => { set(agentConfigMetaCacheAtom, (prev) => mergeBootstrapMetaCache(cache.agents, prev, existenceStateByDocId) ); - set(docMetaCacheReadyAtom, true); - set(docMetaCacheScopeAtom, { - runtime, - workspaceId: runtime.workspaceId, - workspaceSlug: runtime.workspaceSlug, - ready: true, - }); + for (const docId of pendingMetaDocIds) { + if (hasCachedDocMeta(docId)) pendingMetaDocIds.delete(docId); + } + bootstrapCacheMerged = true; + markCacheReadyIfSettled(); }); return () => { diff --git a/packages/components/tests/doc-meta-subscription.test.ts b/packages/components/tests/doc-meta-subscription.test.ts index b58cbc2b1..c97d4139f 100644 --- a/packages/components/tests/doc-meta-subscription.test.ts +++ b/packages/components/tests/doc-meta-subscription.test.ts @@ -90,9 +90,10 @@ class CompatRepoDouble { class ScanRacingRepoDouble extends CompatRepoDouble { constructor( entries: CompatRepoEntry[], - private readonly duringScan: () => void + private readonly duringScan: () => void, + snapshots?: Map | undefined> ) { - super(entries); + super(entries, snapshots); } override async listDoc(): Promise { @@ -194,6 +195,283 @@ describe('docMetaSubscriptionAtom', () => { } }); + it.each(['doc-metadata', 'doc-existence-changed'] as const)( + 'keeps bootstrap readiness false until a queued %s event is fully projected', + async (eventKind) => { + vi.useFakeTimers(); + const rootId = 'bootstrap-race-root' as SessionId; + const childId = 'bootstrap-race-child' as SessionId; + const rootDocId = getSessionRoomId(rootId); + const childDocId = getSessionRoomId(childId); + const childMeta = { + id: childId, + title: 'Child created during bootstrap', + createdAt: '2026-09-10T00:00:01.000Z', + parentSessionId: rootId, + }; + let resolveChildMeta!: ( + entry: Record & { meta: Record } + ) => void; + const childMetaLoad = new Promise< + Record & { meta: Record } + >((resolve) => { + resolveChildMeta = resolve; + }); + const repo: ScanRacingRepoDouble = new ScanRacingRepoDouble( + [ + { + docId: rootDocId, + exists: true, + meta: { + id: rootId, + title: 'Bootstrap root', + createdAt: '2026-09-10T00:00:00.000Z', + }, + }, + ], + () => { + repo.emit( + eventKind === 'doc-metadata' + ? { + kind: 'doc-metadata', + docId: childDocId, + patch: { title: childMeta.title }, + by: 'live', + } + : { + kind: 'doc-existence-changed', + docId: childDocId, + from: 'missing', + to: 'active', + by: 'live', + } + ); + } + ); + const getDocMeta = vi.spyOn(repo, 'getDocMeta').mockImplementation(async (docId) => { + if (docId === childDocId) return childMetaLoad; + return undefined; + }); + + const store = createStore(); + const unmount = store.sub(docMetaSubscriptionAtom, () => {}); + const runtime = createRuntime(repo as unknown as LoroRepo); + + try { + store.set(runtimeAtom, runtime); + for (let attempt = 0; attempt < 10; attempt += 1) { + if (store.get(sessionMetaCacheAtom)[rootDocId]) break; + await Promise.resolve(); + } + + expect(store.get(sessionMetaCacheAtom)[rootDocId]?.id).toBe(rootId); + expect(store.get(docMetaCacheReadyAtom)).toBe(false); + expect(store.get(docMetaCacheScopeAtom)?.ready).toBe(false); + expect(store.get(sessionMetaCacheAtom)[childDocId]).toBeUndefined(); + + await vi.runOnlyPendingTimersAsync(); + + expect(getDocMeta).toHaveBeenCalledWith(childDocId); + expect(store.get(docMetaCacheReadyAtom)).toBe(false); + expect(store.get(docMetaCacheScopeAtom)?.ready).toBe(false); + expect(store.get(sessionMetaCacheAtom)[childDocId]).toBeUndefined(); + + resolveChildMeta({ exists: true, meta: childMeta }); + for (let attempt = 0; attempt < 10; attempt += 1) { + if (store.get(docMetaCacheReadyAtom)) break; + await Promise.resolve(); + } + + expect(store.get(docMetaCacheReadyAtom)).toBe(true); + expect(store.get(docMetaCacheScopeAtom)).toEqual({ + runtime, + workspaceId: runtime.workspaceId, + workspaceSlug: runtime.workspaceSlug, + ready: true, + }); + expect(store.get(sessionMetaCacheAtom)[childDocId]).toEqual(childMeta); + } finally { + unmount(); + vi.useRealTimers(); + } + } + ); + + it('releases bootstrap readiness when a pending active doc is confirmed missing', async () => { + vi.useFakeTimers(); + const rootId = 'missing-race-root' as SessionId; + const childId = 'missing-race-child' as SessionId; + const rootDocId = getSessionRoomId(rootId); + const childDocId = getSessionRoomId(childId); + const repo: ScanRacingRepoDouble = new ScanRacingRepoDouble( + [ + { + docId: rootDocId, + exists: true, + meta: { + id: rootId, + title: 'Missing race root', + createdAt: '2026-09-10T00:00:00.000Z', + }, + }, + ], + () => { + repo.emit({ + kind: 'doc-existence-changed', + docId: childDocId, + from: 'missing', + to: 'active', + by: 'live', + }); + } + ); + const store = createStore(); + const unmount = store.sub(docMetaSubscriptionAtom, () => {}); + + try { + store.set(runtimeAtom, createRuntime(repo as unknown as LoroRepo)); + for (let attempt = 0; attempt < 10; attempt += 1) { + if (store.get(sessionMetaCacheAtom)[rootDocId]) break; + await Promise.resolve(); + } + await vi.runOnlyPendingTimersAsync(); + + expect(store.get(docMetaCacheReadyAtom)).toBe(false); + + repo.emit({ + kind: 'doc-existence-changed', + docId: childDocId, + from: 'active', + to: 'missing', + by: 'live', + }); + await vi.runOnlyPendingTimersAsync(); + + expect(store.get(docMetaCacheReadyAtom)).toBe(true); + expect(store.get(sessionMetaCacheAtom)[childDocId]).toBeUndefined(); + } finally { + unmount(); + vi.useRealTimers(); + } + }); + + it('clears cached metadata when a missing event is confirmed by an empty fetch', async () => { + vi.useFakeTimers(); + const sessionId = 'cached-then-missing' as SessionId; + const docId = getSessionRoomId(sessionId); + const repo = new CompatRepoDouble([ + { + docId, + exists: true, + meta: { + id: sessionId, + title: 'Cached before missing', + createdAt: '2026-09-10T00:00:00.000Z', + }, + }, + ]); + const store = createStore(); + const unmount = store.sub(docMetaSubscriptionAtom, () => {}); + + try { + store.set(runtimeAtom, createRuntime(repo as unknown as LoroRepo)); + for (let attempt = 0; attempt < 10; attempt += 1) { + if (store.get(docMetaCacheReadyAtom)) break; + await Promise.resolve(); + } + expect(store.get(sessionMetaCacheAtom)[docId]?.id).toBe(sessionId); + + repo.emit({ + kind: 'doc-existence-changed', + docId, + from: 'active', + to: 'missing', + by: 'live', + }); + await vi.runOnlyPendingTimersAsync(); + + expect(store.get(sessionMetaCacheAtom)[docId]).toBeUndefined(); + expect(store.get(docMetaCacheReadyAtom)).toBe(true); + } finally { + unmount(); + vi.useRealTimers(); + } + }); + + it.each(['null', 'reject'] as const)( + 'keeps bootstrap metadata authoritative when an older live fetch finishes with %s', + async (fetchOutcome) => { + vi.useFakeTimers(); + const childId = 'snapshot-race-child' as SessionId; + const childDocId = getSessionRoomId(childId); + const childEntry: CompatRepoEntry = { + docId: childDocId, + exists: true, + meta: { + id: childId, + title: 'Snapshot race child', + createdAt: '2026-09-10T00:00:00.000Z', + }, + }; + let resolveScan!: (entries: CompatRepoEntry[]) => void; + const scan = new Promise((resolve) => { + resolveScan = resolve; + }); + let resolveFetch!: ( + entry: (Record & { meta: Record }) | undefined + ) => void; + let rejectFetch!: (error: Error) => void; + const fetch = new Promise< + (Record & { meta: Record }) | undefined + >((resolve, reject) => { + resolveFetch = resolve; + rejectFetch = reject; + }); + const repo = new CompatRepoDouble([]); + vi.spyOn(repo, 'listDoc').mockReturnValue(scan); + vi.spyOn(repo, 'getDocMeta').mockReturnValue(fetch); + const store = createStore(); + const unmount = store.sub(docMetaSubscriptionAtom, () => {}); + + try { + store.set(runtimeAtom, createRuntime(repo as unknown as LoroRepo)); + await Promise.resolve(); + repo.emit({ + kind: 'doc-existence-changed', + docId: childDocId, + from: 'missing', + to: 'active', + by: 'live', + }); + await vi.runOnlyPendingTimersAsync(); + + expect(store.get(docMetaCacheReadyAtom)).toBe(false); + + resolveScan([childEntry]); + for (let attempt = 0; attempt < 10; attempt += 1) { + if (store.get(sessionMetaCacheAtom)[childDocId]) break; + await Promise.resolve(); + } + + expect(store.get(sessionMetaCacheAtom)[childDocId]).toEqual(childEntry.meta); + expect(store.get(docMetaCacheReadyAtom)).toBe(false); + + if (fetchOutcome === 'null') resolveFetch(undefined); + else rejectFetch(new Error('metadata unavailable')); + for (let attempt = 0; attempt < 10; attempt += 1) { + if (store.get(docMetaCacheReadyAtom)) break; + await Promise.resolve(); + } + + expect(store.get(docMetaCacheReadyAtom)).toBe(true); + expect(store.get(sessionMetaCacheAtom)[childDocId]).toEqual(childEntry.meta); + } finally { + unmount(); + vi.useRealTimers(); + } + } + ); + it('immediately projects same-repo archive and restore writes into session lists', async () => { const repo = await LoroRepo.create({}); const sessionId = 'local-archive-session' as SessionId;