Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/free-rivers-like.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tanstack/db-sqlite-persistence-core': patch
---

fix: persisted preload() hangs when upstream sync never calls markReady
44 changes: 34 additions & 10 deletions packages/db-sqlite-persistence-core/src/persisted.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
) {}

Expand Down Expand Up @@ -2209,6 +2209,13 @@ function createWrappedSyncConfig<
...sourceSyncConfig,
sync: (params) => {
const transactionStack: Array<OpenSyncTransaction<T, TKey>> = []
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<void> | null = null
Expand Down Expand Up @@ -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<T, TKey> = {
Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -2552,18 +2572,22 @@ function createLoopbackSyncConfig<
params.collection as Collection<T, TKey, PersistedCollectionUtils>,
)

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()
}
})
Comment thread
coderabbitai[bot] marked this conversation as resolved.

return {
cleanup: () => {
cleanedUp = true
runtime.cleanup()
runtime.clearSyncControls()
},
Expand Down
179 changes: 179 additions & 0 deletions packages/db-sqlite-persistence-core/tests/persisted.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -256,6 +256,16 @@ async function flushAsyncWork(delayMs: number = 0): Promise<void> {
await new Promise((resolve) => setTimeout(resolve, delayMs))
}

function deferred<T = void>() {
let resolve!: (value: T | PromiseLike<T>) => void
let reject!: (reason?: unknown) => void
const promise = new Promise<T>((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()
Expand Down Expand Up @@ -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<Todo, string> = {
sync: (_params) => {
// deliberately never call params.markReady() - simulates offlineFirst paused query
return { cleanup: () => {} }
},
}

const collection = createCollection(
persistedCollectionOptions<Todo, string>({
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<Todo, string>({
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<Array<{ key: string; value: Todo }>>()
const adapter = createRecordingAdapter()
adapter.loadSubset = () => {
hydrationStarted.resolve()
return hydration.promise
}
const collection = createCollection(
persistedCollectionOptions<Todo, string>({
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<Array<{ key: string; value: Todo }>>()
const adapter = createRecordingAdapter()
adapter.loadSubset = () => {
hydrationStarted.resolve()
return hydration.promise
}
let markUpstreamReady: (() => void) | undefined
const collection = createCollection(
persistedCollectionOptions<Todo, string>({
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<Todo, string>({
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<Array<{ key: string; value: Todo }>>()
const adapter = createRecordingAdapter()
adapter.loadSubset = () => {
hydrationStarted.resolve()
return hydration.promise
}
let markUpstreamReady: (() => void) | undefined
const collection = createCollection(
persistedCollectionOptions<Todo, string>({
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`, () => {
Expand Down
Loading