diff --git a/.changeset/free-rivers-like.md b/.changeset/free-rivers-like.md new file mode 100644 index 000000000..82aa03d8f --- /dev/null +++ b/.changeset/free-rivers-like.md @@ -0,0 +1,5 @@ +--- +'@tanstack/db-sqlite-persistence-core': patch +--- + +fix: persisted preload() hangs when upstream sync never calls markReady diff --git a/packages/db-sqlite-persistence-core/src/persisted.ts b/packages/db-sqlite-persistence-core/src/persisted.ts index ca60a050a..abf3453fb 100644 --- a/packages/db-sqlite-persistence-core/src/persisted.ts +++ b/packages/db-sqlite-persistence-core/src/persisted.ts @@ -828,7 +828,7 @@ class PersistedCollectionRuntime< private readonly mode: PersistedMode, private readonly collectionId: string, private readonly persistence: PersistedResolvedPersistence, - private readonly syncMode: `eager` | `on-demand`, + readonly syncMode: `eager` | `on-demand`, private readonly dbName: string, ) {} @@ -2209,6 +2209,13 @@ function createWrappedSyncConfig< ...sourceSyncConfig, sync: (params) => { const transactionStack: Array> = [] + const startupState = { cleanedUp: false, readySignalled: false } + const signalReady = () => { + if (!startupState.cleanedUp && !startupState.readySignalled) { + startupState.readySignalled = true + params.markReady() + } + } const getOpenTransaction = () => transactionStack[transactionStack.length - 1] let fullStartPromise: Promise | null = null @@ -2245,16 +2252,13 @@ function createWrappedSyncConfig< ...params, markReady: () => { void (fullStartPromise ?? runtime.ensureStarted()) - .then(() => { - params.markReady() - }) .catch((error) => { console.warn( `Failed persisted sync startup before markReady:`, error, ) - params.markReady() }) + .finally(signalReady) }, begin: (options?: { immediate?: boolean }) => { const transaction: OpenSyncTransaction = { @@ -2494,8 +2498,24 @@ function createWrappedSyncConfig< } let sourceResult: SyncConfigRes = {} - const startupState = { cleanedUp: false } fullStartPromise = runtime.ensureStarted() + + // Mark ready after SQLite hydration so preload() resolves from local data + // even if the upstream never calls markReady() (e.g. query paused offline). + // Skipped in on-demand mode -- no rows load at startup, so the upstream sync + // owns readiness there. On failure, mark ready to avoid blocking consumers. + void fullStartPromise.then( + () => { + if (runtime.syncMode !== `on-demand`) { + signalReady() + } + }, + (error) => { + console.warn(`Failed persisted sync startup:`, error) + signalReady() + }, + ) + const sourceResultPromise = (async () => { await runtime.ensureStartupMetadataLoaded() @@ -2552,18 +2572,22 @@ function createLoopbackSyncConfig< params.collection as Collection, ) + let cleanedUp = false + void runtime .ensureStarted() - .then(() => { - params.markReady() - }) .catch((error) => { console.warn(`Failed persisted loopback startup:`, error) - params.markReady() + }) + .finally(() => { + if (!cleanedUp) { + params.markReady() + } }) return { cleanup: () => { + cleanedUp = true runtime.cleanup() runtime.clearSyncControls() }, diff --git a/packages/db-sqlite-persistence-core/tests/persisted.test.ts b/packages/db-sqlite-persistence-core/tests/persisted.test.ts index 42bbcf563..e505ae6d8 100644 --- a/packages/db-sqlite-persistence-core/tests/persisted.test.ts +++ b/packages/db-sqlite-persistence-core/tests/persisted.test.ts @@ -256,6 +256,16 @@ async function flushAsyncWork(delayMs: number = 0): Promise { await new Promise((resolve) => setTimeout(resolve, delayMs)) } +function deferred() { + let resolve!: (value: T | PromiseLike) => void + let reject!: (reason?: unknown) => void + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise + reject = rejectPromise + }) + return { promise, resolve, reject } +} + describe(`persistedCollectionOptions`, () => { it(`provides a sync-absent loopback configuration with persisted utils`, async () => { const adapter = createRecordingAdapter() @@ -1640,6 +1650,175 @@ describe(`persistedCollectionOptions`, () => { title: `Updated`, }) }) + + it(`preload resolves from local SQLite data when upstream sync never calls markReady`, async () => { + // Regression test: collection.preload() used to hang forever when the upstream + // sync never called markReady() (e.g. TanStack Query offlineFirst pausing a + // query). The fix fires params.markReady() after ensureStarted() so local + // SQLite data is enough to unblock preload(). + const adapter = createRecordingAdapter([{ id: `1`, title: `Offline Todo` }]) + + const neverReadySync: SyncConfig = { + sync: (_params) => { + // deliberately never call params.markReady() - simulates offlineFirst paused query + return { cleanup: () => {} } + }, + } + + const collection = createCollection( + persistedCollectionOptions({ + id: `persisted-offline-preload`, + getKey: (item) => item.id, + sync: neverReadySync, + persistence: { + adapter, + }, + }), + ) + + await collection.preload() + + expect(stripVirtualProps(collection.get(`1`))).toEqual({ + id: `1`, + title: `Offline Todo`, + }) + }) + + it(`keeps on-demand readiness gated on the upstream`, async () => { + const upstreamStarted = deferred() + let markUpstreamReady: (() => void) | undefined + const collection = createCollection( + persistedCollectionOptions({ + id: `on-demand-readiness`, + syncMode: `on-demand`, + getKey: (item) => item.id, + sync: { + sync: ({ markReady }) => { + markUpstreamReady = markReady + upstreamStarted.resolve() + }, + }, + persistence: { adapter: createRecordingAdapter() }, + }), + ) + + collection.startSyncImmediate() + await upstreamStarted.promise + expect(collection.status).toBe(`loading`) + + markUpstreamReady?.() + await collection.stateWhenReady() + expect(collection.status).toBe(`ready`) + }) + + it(`cleanup before hydration prevents late readiness`, async () => { + const hydrationStarted = deferred() + const hydration = deferred>() + const adapter = createRecordingAdapter() + adapter.loadSubset = () => { + hydrationStarted.resolve() + return hydration.promise + } + const collection = createCollection( + persistedCollectionOptions({ + id: `cleanup-during-hydration`, + getKey: (item) => item.id, + sync: { sync: () => ({}) }, + persistence: { adapter }, + }), + ) + let readyEvents = 0 + collection.on(`status:ready`, () => readyEvents++) + + collection.startSyncImmediate() + await hydrationStarted.promise + await collection.cleanup() + hydration.resolve([]) + await hydration.promise + await Promise.resolve() + + expect(readyEvents).toBe(0) + }) + + it(`signals first readiness once when hydration and upstream race`, async () => { + const hydrationStarted = deferred() + const hydration = deferred>() + const adapter = createRecordingAdapter() + adapter.loadSubset = () => { + hydrationStarted.resolve() + return hydration.promise + } + let markUpstreamReady: (() => void) | undefined + const collection = createCollection( + persistedCollectionOptions({ + id: `readiness-race`, + getKey: (item) => item.id, + sync: { sync: ({ markReady }) => void (markUpstreamReady = markReady) }, + persistence: { adapter }, + }), + ) + let readyEvents = 0 + collection.on(`status:ready`, () => readyEvents++) + + collection.startSyncImmediate() + await hydrationStarted.promise + markUpstreamReady?.() + hydration.resolve([]) + await collection.stateWhenReady() + await Promise.resolve() + + expect(readyEvents).toBe(1) + }) + + it(`settles readiness when startup fails without upstream readiness`, async () => { + const adapter = createRecordingAdapter() + adapter.loadSubset = () => Promise.reject(new Error(`startup failed`)) + const collection = createCollection( + persistedCollectionOptions({ + id: `failed-startup-settles`, + getKey: (item) => item.id, + sync: { sync: () => ({}) }, + persistence: { adapter }, + }), + ) + + await collection.stateWhenReady() + expect(collection.status).toBe(`ready`) + }) + + it(`ignores upstream readiness and hydration callbacks after cleanup`, async () => { + const hydrationStarted = deferred() + const hydration = deferred>() + const adapter = createRecordingAdapter() + adapter.loadSubset = () => { + hydrationStarted.resolve() + return hydration.promise + } + let markUpstreamReady: (() => void) | undefined + const collection = createCollection( + persistedCollectionOptions({ + id: `late-callbacks-after-cleanup`, + getKey: (item) => item.id, + sync: { sync: ({ markReady }) => void (markUpstreamReady = markReady) }, + persistence: { adapter }, + }), + ) + let readyEvents = 0 + collection.on(`status:ready`, () => readyEvents++) + + collection.startSyncImmediate() + await hydrationStarted.promise + await collection.cleanup() + markUpstreamReady?.() + hydration.resolve([ + { key: `late`, value: { id: `late`, title: `Must not apply` } }, + ]) + await hydration.promise + await Promise.resolve() + + expect(readyEvents).toBe(0) + expect(collection.get(`late`)).toBeUndefined() + }) }) describe(`persisted key and identifier helpers`, () => {