diff --git a/packages/components/src/lib/clear-local-cache.ts b/packages/components/src/lib/clear-local-cache.ts index ed91ba83f..521998b94 100644 --- a/packages/components/src/lib/clear-local-cache.ts +++ b/packages/components/src/lib/clear-local-cache.ts @@ -386,6 +386,8 @@ export function readPendingLocalClearMode(): PendingLocalClearMode | null { // gets the wipe, while `RuntimeProvider` awaits the same promise so the repo DB // is never reopened mid-delete. let bootClearPromise: Promise | null = null; +// Caller-supplied database names already deleted during this page load. +const bootClearedDatabaseNames = new Set(); async function runPendingClearOnBoot(): Promise { const mode = readPendingLocalClearMode(); @@ -422,12 +424,25 @@ export async function maybeClearLodyCacheOnBoot(extraNames: string[] = []): Prom const mode = await bootClearPromise; // Nothing was pending, or this caller has no extra databases to contribute. if (!mode || extraNames.length === 0) return; - await Promise.all(extraNames.map(deleteDatabaseBestEffort)); + // The memo above resolves to the MODE, not to "already ran", because a caller + // that arrives later in the same boot still has to contribute its databases. + // But the clear is a one-shot: by the second time `RuntimeProvider` builds a + // runtime, the databases it names have been re-created and re-synced, so + // deleting them again destroys live data instead of stale cache. Claim the + // names synchronously, before the first await, so concurrent callers cannot + // both claim one. + const unclaimed = extraNames.filter((name) => !bootClearedDatabaseNames.has(name)); + for (const name of unclaimed) { + bootClearedDatabaseNames.add(name); + } + if (unclaimed.length === 0) return; + await Promise.all(unclaimed.map(deleteDatabaseBestEffort)); } /** Test-only: forget the per-page-load memo so each case starts clean. */ export function resetBootClearMemoForTests(): void { bootClearPromise = null; + bootClearedDatabaseNames.clear(); } /** diff --git a/packages/components/tests/clear-local-cache.test.ts b/packages/components/tests/clear-local-cache.test.ts index b9c9d90a1..2f66de4d4 100644 --- a/packages/components/tests/clear-local-cache.test.ts +++ b/packages/components/tests/clear-local-cache.test.ts @@ -157,6 +157,37 @@ describe('maybeClearLodyCacheOnBoot', () => { expect(repoFilePathsDeletes).toHaveLength(1); }); + it('deletes a caller-supplied database once per page load, not on every later call', async () => { + markCacheClearPending(); + const workspaceDatabases = [ + 'lody-loro-repo-db-ws-current', + 'lody-loro-stream-cursors-ws-current', + ]; + + // `AppInitializer` starts the boot clear; `RuntimeProvider` then contributes + // the current workspace's databases while building the runtime. + await maybeClearLodyCacheOnBoot(); + await maybeClearLodyCacheOnBoot(workspaceDatabases); + // The runtime is rebuilt later in the same page load and passes the same + // names again. By then it has re-created and re-synced those databases, so a + // second delete destroys live data rather than stale cache. + await maybeClearLodyCacheOnBoot(workspaceDatabases); + + for (const name of workspaceDatabases) { + expect(deletedDatabases.filter((deleted) => deleted === name)).toHaveLength(1); + } + }); + + it('still deletes a name first contributed after the boot clear already ran', async () => { + presentDatabases = []; + markCacheClearPending(); + + await maybeClearLodyCacheOnBoot(); + await maybeClearLodyCacheOnBoot(['lody-loro-repo-db-ws-late']); + + expect(deletedDatabases.filter((name) => name === 'lody-loro-repo-db-ws-late')).toHaveLength(1); + }); + it('still deletes a caller-supplied database name that enumeration missed', async () => { presentDatabases = []; markCacheClearPending();