From ec3efc644c0d3afe7209b4be9e3abcb6e9a60f78 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=20K=C3=BChl?= Date: Wed, 6 Aug 2025 18:55:43 +0200 Subject: [PATCH 1/9] test: add unit test to ensure markReady is not called when source collection sync does not trigger it --- .../tests/query/live-query-collection.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/packages/db/tests/query/live-query-collection.test.ts b/packages/db/tests/query/live-query-collection.test.ts index 0ebcf4d4..8b469f17 100644 --- a/packages/db/tests/query/live-query-collection.test.ts +++ b/packages/db/tests/query/live-query-collection.test.ts @@ -170,4 +170,23 @@ describe(`createLiveQueryCollection`, () => { expect(liveQuery.status).toBe(`ready`) expect(liveQuery.size).toBe(0) }) + + it(`shouldn't call markReady when source collection sync doesn't call markReady`, async () => { + const collection = createCollection<{ id: string }>({ + sync: { + sync({ begin, commit }) { + begin() + commit() + }, + }, + getKey: (item) => item.id, + startSync: true, + }) + + const liveQuery = createLiveQueryCollection({ + query: (q) => q.from({ collection }), + startSync: true, + }) + expect(liveQuery.isReady()).toBe(false) + }) }) From 7f7a81cb7f51dd8b4bb5ebee5a37d83646e76b51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=20K=C3=BChl?= Date: Wed, 6 Aug 2025 18:56:26 +0200 Subject: [PATCH 2/9] fix(db): simplify allCollectionsReady check to only require 'ready' status --- packages/db/src/query/live-query-collection.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/packages/db/src/query/live-query-collection.ts b/packages/db/src/query/live-query-collection.ts index 9d7877f5..d823d88a 100644 --- a/packages/db/src/query/live-query-collection.ts +++ b/packages/db/src/query/live-query-collection.ts @@ -164,8 +164,7 @@ export function liveQueryCollectionOptions< const allCollectionsReady = () => { return Object.values(collections).every( - (collection) => - collection.status === `ready` || collection.status === `initialCommit` + (collection) => collection.status === `ready` ) } From f1b24c23712cce118ad2588399b4403b0db897bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Tom=20K=C3=BChl?= Date: Wed, 6 Aug 2025 18:59:36 +0200 Subject: [PATCH 3/9] test(db): add markReady calls --- packages/db/tests/query/indexes.test.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/db/tests/query/indexes.test.ts b/packages/db/tests/query/indexes.test.ts index 5dfad097..06dab016 100644 --- a/packages/db/tests/query/indexes.test.ts +++ b/packages/db/tests/query/indexes.test.ts @@ -237,7 +237,7 @@ describe(`Query Index Optimization`, () => { getKey: (item) => item.id, startSync: true, sync: { - sync: ({ begin, write, commit }) => { + sync: ({ begin, write, commit, markReady }) => { // Provide initial data through sync begin() for (const item of testData) { @@ -247,6 +247,7 @@ describe(`Query Index Optimization`, () => { }) } commit() + markReady() // Listen for mutations and sync them back (only register once) if (!emitter.all.has(`sync`)) { @@ -534,7 +535,7 @@ describe(`Query Index Optimization`, () => { getKey: (item) => item.id, startSync: true, sync: { - sync: ({ begin, write, commit }) => { + sync: ({ begin, write, commit, markReady }) => { begin() write({ type: `insert`, @@ -547,6 +548,7 @@ describe(`Query Index Optimization`, () => { }, }) commit() + markReady() }, }, }) @@ -622,7 +624,7 @@ describe(`Query Index Optimization`, () => { getKey: (item) => item.id, startSync: true, sync: { - sync: ({ begin, write, commit }) => { + sync: ({ begin, write, commit, markReady }) => { begin() write({ type: `insert`, @@ -645,6 +647,7 @@ describe(`Query Index Optimization`, () => { }, }) commit() + markReady() }, }, }) From 3fb8d3e3e146dcf50ea1ea87d858cc6cba617354 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Sun, 10 Aug 2025 17:33:38 +0100 Subject: [PATCH 4/9] tweaks --- packages/db/src/collection.ts | 6 + .../db/src/query/live-query-collection.ts | 13 ++- .../tests/query/live-query-collection.test.ts | 2 +- packages/react-db/tests/useLiveQuery.test.tsx | 27 +++-- packages/solid-db/tests/useLiveQuery.test.tsx | 27 +++-- packages/svelte-db/src/useLiveQuery.svelte.ts | 5 +- .../tests/useLiveQuery.svelte.test.ts | 105 ++++++++++++++++-- packages/vue-db/src/useLiveQuery.ts | 5 +- packages/vue-db/tests/useLiveQuery.test.ts | 102 +++++++++++++++-- 9 files changed, 242 insertions(+), 50 deletions(-) diff --git a/packages/db/src/collection.ts b/packages/db/src/collection.ts index eea7905c..0bb3fc14 100644 --- a/packages/db/src/collection.ts +++ b/packages/db/src/collection.ts @@ -957,6 +957,12 @@ export class CollectionImpl< for (const listener of this.changeListeners) { listener([]) } + // Emit to key-specific listeners + for (const [_key, keyListeners] of this.changeKeyListeners) { + for (const listener of keyListeners) { + listener([]) + } + } } /** diff --git a/packages/db/src/query/live-query-collection.ts b/packages/db/src/query/live-query-collection.ts index d823d88a..12fbd8c5 100644 --- a/packages/db/src/query/live-query-collection.ts +++ b/packages/db/src/query/live-query-collection.ts @@ -168,6 +168,13 @@ export function liveQueryCollectionOptions< ) } + const allCollectionsReadyOrInitialCommit = () => { + return Object.values(collections).every( + (collection) => + collection.status === `ready` || collection.status === `initialCommit` + ) + } + let graphCache: D2 | undefined let inputsCache: Record> | undefined let pipelineCache: ResultStream | undefined @@ -293,7 +300,7 @@ export function liveQueryCollectionOptions< const maybeRunGraph = () => { // We only run the graph if all the collections are ready - if (allCollectionsReady()) { + if (allCollectionsReadyOrInitialCommit()) { graph.run() // On the initial run, we may need to do an empty commit to ensure that // the collection is initialized @@ -302,7 +309,9 @@ export function liveQueryCollectionOptions< commit() } // Mark the collection as ready after the first successful run - markReady() + if (allCollectionsReady()) { + markReady() + } } } diff --git a/packages/db/tests/query/live-query-collection.test.ts b/packages/db/tests/query/live-query-collection.test.ts index 8b469f17..15bd930d 100644 --- a/packages/db/tests/query/live-query-collection.test.ts +++ b/packages/db/tests/query/live-query-collection.test.ts @@ -171,7 +171,7 @@ describe(`createLiveQueryCollection`, () => { expect(liveQuery.size).toBe(0) }) - it(`shouldn't call markReady when source collection sync doesn't call markReady`, async () => { + it(`shouldn't call markReady when source collection sync doesn't call markReady`, () => { const collection = createCollection<{ id: string }>({ sync: { sync({ begin, commit }) { diff --git a/packages/react-db/tests/useLiveQuery.test.tsx b/packages/react-db/tests/useLiveQuery.test.tsx index 9814dd07..69ed9b49 100644 --- a/packages/react-db/tests/useLiveQuery.test.tsx +++ b/packages/react-db/tests/useLiveQuery.test.tsx @@ -1077,15 +1077,17 @@ describe(`Query Collections`, () => { it(`should update isLoading when collection status changes`, async () => { let beginFn: (() => void) | undefined let commitFn: (() => void) | undefined + let markReadyFn: (() => void) | undefined const collection = createCollection({ id: `status-change-has-loaded-test`, getKey: (person: Person) => person.id, startSync: false, sync: { - sync: ({ begin, commit }) => { + sync: ({ begin, commit, markReady }) => { beginFn = begin commitFn = commit + markReadyFn = markReady // Don't sync immediately }, }, @@ -1116,9 +1118,10 @@ describe(`Query Collections`, () => { // Trigger the first commit to make collection ready act(() => { - if (beginFn && commitFn) { + if (beginFn && commitFn && markReadyFn) { beginFn() commitFn() + markReadyFn() } }) @@ -1202,8 +1205,10 @@ describe(`Query Collections`, () => { it(`should handle isLoading with complex queries including joins`, async () => { let personBeginFn: (() => void) | undefined let personCommitFn: (() => void) | undefined + let personMarkReadyFn: (() => void) | undefined let issueBeginFn: (() => void) | undefined let issueCommitFn: (() => void) | undefined + let issueMarkReadyFn: (() => void) | undefined const personCollection = createCollection({ id: `join-has-loaded-persons`, @@ -1212,10 +1217,8 @@ describe(`Query Collections`, () => { sync: { sync: ({ begin, commit, markReady }) => { personBeginFn = begin - personCommitFn = () => { - commit() - markReady() - } + personCommitFn = commit + personMarkReadyFn = markReady // Don't sync immediately }, }, @@ -1231,10 +1234,8 @@ describe(`Query Collections`, () => { sync: { sync: ({ begin, commit, markReady }) => { issueBeginFn = begin - issueCommitFn = () => { - commit() - markReady() - } + issueCommitFn = commit + issueMarkReadyFn = markReady // Don't sync immediately }, }, @@ -1269,13 +1270,15 @@ describe(`Query Collections`, () => { // Trigger the first commit for both collections to make them ready act(() => { - if (personBeginFn && personCommitFn) { + if (personBeginFn && personCommitFn && personMarkReadyFn) { personBeginFn() personCommitFn() + personMarkReadyFn() } - if (issueBeginFn && issueCommitFn) { + if (issueBeginFn && issueCommitFn && issueMarkReadyFn) { issueBeginFn() issueCommitFn() + issueMarkReadyFn() } }) diff --git a/packages/solid-db/tests/useLiveQuery.test.tsx b/packages/solid-db/tests/useLiveQuery.test.tsx index e8f58ca3..43ed8b2c 100644 --- a/packages/solid-db/tests/useLiveQuery.test.tsx +++ b/packages/solid-db/tests/useLiveQuery.test.tsx @@ -1053,15 +1053,17 @@ describe(`Query Collections`, () => { it(`should update isLoading when collection status changes`, async () => { let beginFn: (() => void) | undefined let commitFn: (() => void) | undefined + let markReadyFn: (() => void) | undefined const collection = createCollection({ id: `status-change-has-loaded-test`, getKey: (person: Person) => person.id, startSync: false, sync: { - sync: ({ begin, commit }) => { + sync: ({ begin, commit, markReady }) => { beginFn = begin commitFn = commit + markReadyFn = markReady // Don't sync immediately }, }, @@ -1089,9 +1091,10 @@ describe(`Query Collections`, () => { collection.preload() // Trigger the first commit to make collection ready - if (beginFn && commitFn) { + if (beginFn && commitFn && markReadyFn) { beginFn() commitFn() + markReadyFn() } // Insert data @@ -1172,8 +1175,10 @@ describe(`Query Collections`, () => { it(`should handle isLoading with complex queries including joins`, async () => { let personBeginFn: (() => void) | undefined let personCommitFn: (() => void) | undefined + let personMarkReadyFn: (() => void) | undefined let issueBeginFn: (() => void) | undefined let issueCommitFn: (() => void) | undefined + let issueMarkReadyFn: (() => void) | undefined const personCollection = createCollection({ id: `join-has-loaded-persons`, @@ -1182,10 +1187,8 @@ describe(`Query Collections`, () => { sync: { sync: ({ begin, commit, markReady }) => { personBeginFn = begin - personCommitFn = () => { - commit() - markReady() - } + personCommitFn = commit + personMarkReadyFn = markReady // Don't sync immediately }, }, @@ -1201,10 +1204,8 @@ describe(`Query Collections`, () => { sync: { sync: ({ begin, commit, markReady }) => { issueBeginFn = begin - issueCommitFn = () => { - commit() - markReady() - } + issueCommitFn = commit + issueMarkReadyFn = markReady // Don't sync immediately }, }, @@ -1236,13 +1237,15 @@ describe(`Query Collections`, () => { issueCollection.preload() // Trigger the first commit for both collections to make them ready - if (personBeginFn && personCommitFn) { + if (personBeginFn && personCommitFn && personMarkReadyFn) { personBeginFn() personCommitFn() + personMarkReadyFn() } - if (issueBeginFn && issueCommitFn) { + if (issueBeginFn && issueCommitFn && issueMarkReadyFn) { issueBeginFn() issueCommitFn() + issueMarkReadyFn() } // Insert data into both collections diff --git a/packages/svelte-db/src/useLiveQuery.svelte.ts b/packages/svelte-db/src/useLiveQuery.svelte.ts index 1d078789..1bc40816 100644 --- a/packages/svelte-db/src/useLiveQuery.svelte.ts +++ b/packages/svelte-db/src/useLiveQuery.svelte.ts @@ -246,7 +246,10 @@ export function useLiveQuery( if (isCollection) { // It's already a collection, ensure sync is started for Svelte helpers - unwrappedParam.startSyncImmediate() + // Only start sync if the collection is in idle state + if (unwrappedParam.status === `idle`) { + unwrappedParam.startSyncImmediate() + } return unwrappedParam } diff --git a/packages/svelte-db/tests/useLiveQuery.svelte.test.ts b/packages/svelte-db/tests/useLiveQuery.svelte.test.ts index 1d1e982f..db7c7f6c 100644 --- a/packages/svelte-db/tests/useLiveQuery.svelte.test.ts +++ b/packages/svelte-db/tests/useLiveQuery.svelte.test.ts @@ -751,6 +751,7 @@ describe(`Query Collections`, () => { it(`should be false initially and true after collection is ready`, () => { let beginFn: (() => void) | undefined let commitFn: (() => void) | undefined + let markReadyFn: (() => void) | undefined // Create a collection that doesn't start sync immediately const collection = createCollection({ @@ -758,9 +759,10 @@ describe(`Query Collections`, () => { getKey: (person: Person) => person.id, startSync: false, // Don't start sync immediately sync: { - sync: ({ begin, commit }) => { + sync: ({ begin, commit, markReady }) => { beginFn = begin commitFn = commit + markReadyFn = markReady // Don't call begin/commit immediately }, }, @@ -787,9 +789,10 @@ describe(`Query Collections`, () => { collection.preload() // Trigger the first commit to make collection ready - if (beginFn && commitFn) { + if (beginFn && commitFn && markReadyFn) { beginFn() commitFn() + markReadyFn() } // Insert data @@ -874,15 +877,17 @@ describe(`Query Collections`, () => { it(`should update isReady when collection status changes`, () => { let beginFn: (() => void) | undefined let commitFn: (() => void) | undefined + let markReadyFn: (() => void) | undefined const collection = createCollection({ id: `status-change-is-ready-test`, getKey: (person: Person) => person.id, startSync: false, sync: { - sync: ({ begin, commit }) => { + sync: ({ begin, commit, markReady }) => { beginFn = begin commitFn = commit + markReadyFn = markReady // Don't sync immediately }, }, @@ -904,10 +909,71 @@ describe(`Query Collections`, () => { expect(query.isReady).toBe(false) collection.preload() - if (beginFn && commitFn) { + if (beginFn && commitFn && markReadyFn) { + beginFn() + commitFn() + markReadyFn() + } + collection.insert({ + id: `1`, + name: `John Doe`, + age: 35, + email: `john.doe@example.com`, + isActive: true, + team: `team1`, + }) + flushSync() + expect(query.isReady).toBe(true) + }) + }) + + it(`should update isLoading when collection status changes`, () => { + let beginFn: (() => void) | undefined + let commitFn: (() => void) | undefined + let markReadyFn: (() => void) | undefined + + const collection = createCollection({ + id: `status-change-is-loading-test`, + getKey: (person: Person) => person.id, + startSync: false, + sync: { + sync: ({ begin, commit, markReady }) => { + beginFn = begin + commitFn = commit + markReadyFn = markReady + // Don't sync immediately + }, + }, + onInsert: () => Promise.resolve(), + onUpdate: () => Promise.resolve(), + onDelete: () => Promise.resolve(), + }) + + cleanup = $effect.root(() => { + const query = useLiveQuery((q) => + q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + })) + ) + + // Initially should be true + expect(query.isLoading).toBe(true) + + // Start sync manually + collection.preload() + + // Trigger the first commit to make collection ready + if (beginFn && commitFn && markReadyFn) { beginFn() commitFn() + markReadyFn() } + + // Insert data collection.insert({ id: `1`, name: `John Doe`, @@ -916,8 +982,16 @@ describe(`Query Collections`, () => { isActive: true, team: `team1`, }) + flushSync() + + expect(query.isLoading).toBe(false) expect(query.isReady).toBe(true) + + // Wait for collection to become ready + flushSync() + expect(query.isLoading).toBe(false) + expect(query.status).toBe(`ready`) }) }) @@ -965,17 +1039,20 @@ describe(`Query Collections`, () => { it(`should handle isReady with complex queries including joins`, () => { let personBeginFn: (() => void) | undefined let personCommitFn: (() => void) | undefined + let personMarkReadyFn: (() => void) | undefined let issueBeginFn: (() => void) | undefined let issueCommitFn: (() => void) | undefined + let issueMarkReadyFn: (() => void) | undefined const personCollection = createCollection({ id: `join-is-ready-persons`, getKey: (person: Person) => person.id, startSync: false, sync: { - sync: ({ begin, commit }) => { + sync: ({ begin, commit, markReady }) => { personBeginFn = begin personCommitFn = commit + personMarkReadyFn = markReady // Don't sync immediately }, }, @@ -990,9 +1067,10 @@ describe(`Query Collections`, () => { getKey: (issue: Issue) => issue.id, startSync: false, sync: { - sync: ({ begin, commit }) => { + sync: ({ begin, commit, markReady }) => { issueBeginFn = begin issueCommitFn = commit + issueMarkReadyFn = markReady // Don't sync immediately }, }, @@ -1017,13 +1095,15 @@ describe(`Query Collections`, () => { expect(query.isReady).toBe(false) personCollection.preload() issueCollection.preload() - if (personBeginFn && personCommitFn) { + if (personBeginFn && personCommitFn && personMarkReadyFn) { personBeginFn() personCommitFn() + personMarkReadyFn() } - if (issueBeginFn && issueCommitFn) { + if (issueBeginFn && issueCommitFn && issueMarkReadyFn) { issueBeginFn() issueCommitFn() + issueMarkReadyFn() } personCollection.insert({ id: `1`, @@ -1044,7 +1124,7 @@ describe(`Query Collections`, () => { }) }) - it(`should handle isReady with parameterized queries`, async () => { + it(`should handle isReady with parameterized queries`, () => { let beginFn: (() => void) | undefined let commitFn: (() => void) | undefined @@ -1053,9 +1133,12 @@ describe(`Query Collections`, () => { getKey: (person: Person) => person.id, startSync: false, sync: { - sync: ({ begin, commit }) => { + sync: ({ begin, commit, markReady }) => { beginFn = begin - commitFn = commit + commitFn = () => { + commit() + markReady() + } // Don't sync immediately }, }, diff --git a/packages/vue-db/src/useLiveQuery.ts b/packages/vue-db/src/useLiveQuery.ts index 1339613a..a7d222ed 100644 --- a/packages/vue-db/src/useLiveQuery.ts +++ b/packages/vue-db/src/useLiveQuery.ts @@ -230,7 +230,10 @@ export function useLiveQuery( if (isCollection) { // It's already a collection, ensure sync is started for Vue hooks - unwrappedParam.startSyncImmediate() + // Only start sync if the collection is in idle state + if (unwrappedParam.status === `idle`) { + unwrappedParam.startSyncImmediate() + } return unwrappedParam } diff --git a/packages/vue-db/tests/useLiveQuery.test.ts b/packages/vue-db/tests/useLiveQuery.test.ts index 874729d8..ab2c4431 100644 --- a/packages/vue-db/tests/useLiveQuery.test.ts +++ b/packages/vue-db/tests/useLiveQuery.test.ts @@ -840,6 +840,7 @@ describe(`Query Collections`, () => { it(`should be false initially and true after collection is ready`, async () => { let beginFn: (() => void) | undefined let commitFn: (() => void) | undefined + let markReadyFn: (() => void) | undefined // Create a collection that doesn't start sync immediately const collection = createCollection({ @@ -847,9 +848,10 @@ describe(`Query Collections`, () => { getKey: (person: Person) => person.id, startSync: false, // Don't start sync immediately sync: { - sync: ({ begin, commit }) => { + sync: ({ begin, commit, markReady }) => { beginFn = begin commitFn = commit + markReadyFn = markReady // Don't call begin/commit immediately }, }, @@ -875,9 +877,10 @@ describe(`Query Collections`, () => { collection.preload() // Trigger the first commit to make collection ready - if (beginFn && commitFn) { + if (beginFn && commitFn && markReadyFn) { beginFn() commitFn() + markReadyFn() } // Insert data @@ -955,15 +958,17 @@ describe(`Query Collections`, () => { it(`should update isReady when collection status changes`, async () => { let beginFn: (() => void) | undefined let commitFn: (() => void) | undefined + let markReadyFn: (() => void) | undefined const collection = createCollection({ id: `status-change-is-ready-test`, getKey: (person: Person) => person.id, startSync: false, sync: { - sync: ({ begin, commit }) => { + sync: ({ begin, commit, markReady }) => { beginFn = begin commitFn = commit + markReadyFn = markReady // Don't sync immediately }, }, @@ -984,9 +989,10 @@ describe(`Query Collections`, () => { expect(isReady.value).toBe(false) collection.preload() - if (beginFn && commitFn) { + if (beginFn && commitFn && markReadyFn) { beginFn() commitFn() + markReadyFn() } collection.insert({ id: `1`, @@ -999,6 +1005,73 @@ describe(`Query Collections`, () => { await waitFor(() => expect(isReady.value).toBe(true)) }) + it(`should update isLoading when collection status changes`, async () => { + let beginFn: (() => void) | undefined + let commitFn: (() => void) | undefined + let markReadyFn: (() => void) | undefined + + const collection = createCollection({ + id: `status-change-is-loading-test`, + getKey: (person: Person) => person.id, + startSync: false, + sync: { + sync: ({ begin, commit, markReady }) => { + beginFn = begin + commitFn = commit + markReadyFn = markReady + // Don't sync immediately + }, + }, + onInsert: () => Promise.resolve(), + onUpdate: () => Promise.resolve(), + onDelete: () => Promise.resolve(), + }) + + const { isLoading, isReady, status } = useLiveQuery((q) => + q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + })) + ) + + // Initially should be true + expect(isLoading.value).toBe(true) + + // Start sync manually + collection.preload() + + // Trigger the first commit to make collection ready + if (beginFn && commitFn && markReadyFn) { + beginFn() + commitFn() + markReadyFn() + } + + // Insert data + collection.insert({ + id: `1`, + name: `John Doe`, + age: 35, + email: `john.doe@example.com`, + isActive: true, + team: `team1`, + }) + + await waitForVueUpdate() + + expect(isLoading.value).toBe(false) + expect(isReady.value).toBe(true) + + // Wait for collection to become ready + await waitFor(() => { + expect(isLoading.value).toBe(false) + }) + expect(status.value).toBe(`ready`) + }) + it(`should maintain isReady state during live updates`, async () => { const collection = createCollection( mockSyncCollectionOptions({ @@ -1041,17 +1114,20 @@ describe(`Query Collections`, () => { it(`should handle isReady with complex queries including joins`, async () => { let personBeginFn: (() => void) | undefined let personCommitFn: (() => void) | undefined + let personMarkReadyFn: (() => void) | undefined let issueBeginFn: (() => void) | undefined let issueCommitFn: (() => void) | undefined + let issueMarkReadyFn: (() => void) | undefined const personCollection = createCollection({ id: `join-is-ready-persons`, getKey: (person: Person) => person.id, startSync: false, sync: { - sync: ({ begin, commit }) => { + sync: ({ begin, commit, markReady }) => { personBeginFn = begin personCommitFn = commit + personMarkReadyFn = markReady // Don't sync immediately }, }, @@ -1065,9 +1141,10 @@ describe(`Query Collections`, () => { getKey: (issue: Issue) => issue.id, startSync: false, sync: { - sync: ({ begin, commit }) => { + sync: ({ begin, commit, markReady }) => { issueBeginFn = begin issueCommitFn = commit + issueMarkReadyFn = markReady // Don't sync immediately }, }, @@ -1092,13 +1169,15 @@ describe(`Query Collections`, () => { expect(isReady.value).toBe(false) personCollection.preload() issueCollection.preload() - if (personBeginFn && personCommitFn) { + if (personBeginFn && personCommitFn && personMarkReadyFn) { personBeginFn() personCommitFn() + personMarkReadyFn() } - if (issueBeginFn && issueCommitFn) { + if (issueBeginFn && issueCommitFn && issueMarkReadyFn) { issueBeginFn() issueCommitFn() + issueMarkReadyFn() } personCollection.insert({ id: `1`, @@ -1126,9 +1205,12 @@ describe(`Query Collections`, () => { getKey: (person: Person) => person.id, startSync: false, sync: { - sync: ({ begin, commit }) => { + sync: ({ begin, commit, markReady }) => { beginFn = begin - commitFn = commit + commitFn = () => { + commit() + markReady() + } // Don't sync immediately }, }, From 967f9e0d1d302e205683cb2556c3823dbcae1d46 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Sun, 10 Aug 2025 17:53:56 +0100 Subject: [PATCH 5/9] changeset --- .changeset/clever-ducks-strive.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 .changeset/clever-ducks-strive.md diff --git a/.changeset/clever-ducks-strive.md b/.changeset/clever-ducks-strive.md new file mode 100644 index 00000000..5f63228a --- /dev/null +++ b/.changeset/clever-ducks-strive.md @@ -0,0 +1,9 @@ +--- +"@tanstack/svelte-db": patch +"@tanstack/react-db": patch +"@tanstack/solid-db": patch +"@tanstack/vue-db": patch +"@tanstack/db": patch +--- + +Ensure that the ready status is correctly returned from a live query From 902ecaf2e53e18979be3308725a6bd0eafe706ab Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Sun, 10 Aug 2025 21:11:52 +0100 Subject: [PATCH 6/9] ensure that live queries receve a notification of preload when its not empty --- packages/db/src/collection.ts | 3 +- .../tests/query/live-query-collection.test.ts | 111 ++++++++++++++++++ 2 files changed, 112 insertions(+), 2 deletions(-) diff --git a/packages/db/src/collection.ts b/packages/db/src/collection.ts index 0bb3fc14..140f8525 100644 --- a/packages/db/src/collection.ts +++ b/packages/db/src/collection.ts @@ -392,9 +392,8 @@ export class CollectionImpl< this.onFirstReadyCallbacks = [] callbacks.forEach((callback) => callback()) - // If the collection is empty when it becomes ready, emit an empty change event // to notify subscribers (like LiveQueryCollection) that the collection is ready - if (this.size === 0 && this.changeListeners.size > 0) { + if (this.changeListeners.size > 0) { this.emitEmptyReadyEvent() } } diff --git a/packages/db/tests/query/live-query-collection.test.ts b/packages/db/tests/query/live-query-collection.test.ts index 15bd930d..d047c5f8 100644 --- a/packages/db/tests/query/live-query-collection.test.ts +++ b/packages/db/tests/query/live-query-collection.test.ts @@ -3,6 +3,19 @@ import { createCollection } from "../../src/collection.js" import { createLiveQueryCollection, eq } from "../../src/query/index.js" import { Query } from "../../src/query/builder/index.js" import { mockSyncCollectionOptions } from "../utls.js" +import type { ChangeMessage } from "../../src/types.js" + +// This test file includes tests that reproduce a bug where live query collections +// don't properly transition to 'ready' status when source collections are preloaded +// after the live query collection is created. +// +// The issue: When a live query collection is created before its source collections +// are preloaded, the live query gets stuck in 'initialCommit' status instead of +// transitioning to 'ready' status, even though it has the correct data. +// +// This matches the original error report: "if I don't preload the collection before +// rendering, It won't update after being loaded. It'll only update, when some +// mutation/remote update happens" // Sample user type for tests type User = { @@ -189,4 +202,102 @@ describe(`createLiveQueryCollection`, () => { }) expect(liveQuery.isReady()).toBe(false) }) + + it(`should update after source collection is loaded even when not preloaded before rendering - REPRODUCES BUG`, async () => { + // Create a source collection that doesn't start sync immediately + let beginCallback: (() => void) | undefined + let writeCallback: + | ((message: Omit, `key`>) => void) + | undefined + let markReadyCallback: (() => void) | undefined + let commitCallback: (() => void) | undefined + + const sourceCollection = createCollection({ + id: `delayed-source-collection`, + getKey: (user) => user.id, + startSync: false, // Don't start sync immediately + sync: { + sync: ({ begin, commit, write, markReady }) => { + beginCallback = begin + commitCallback = commit + markReadyCallback = markReady + writeCallback = write + return () => {} // cleanup function + }, + }, + onInsert: ({ transaction }) => { + const newItem = transaction.mutations[0].modified + // We need to call begin, write, and commit to properly sync the data + beginCallback!() + writeCallback!({ + type: `insert`, + value: newItem, + }) + commitCallback!() + return Promise.resolve() + }, + onUpdate: () => Promise.resolve(), + onDelete: () => Promise.resolve(), + }) + + // Create a live query collection BEFORE the source collection is preloaded + // This simulates the scenario where the live query is created during rendering + // but the source collection hasn't been preloaded yet + const liveQuery = createLiveQueryCollection((q) => + q + .from({ user: sourceCollection }) + .where(({ user }) => eq(user.active, true)) + ) + + // Initially, the live query should be in idle state (default startSync: false) + expect(liveQuery.status).toBe(`idle`) + expect(liveQuery.size).toBe(0) + + // Now preload the source collection (simulating what happens after rendering) + sourceCollection.preload() + + // Store the promise so we can wait for it later + const preloadPromise = liveQuery.preload() + + // Trigger the initial data load first + if (beginCallback && writeCallback && commitCallback && markReadyCallback) { + beginCallback() + // Write initial data + writeCallback({ + type: `insert`, + value: { id: 1, name: `Alice`, active: true }, + }) + writeCallback({ + type: `insert`, + value: { id: 2, name: `Bob`, active: false }, + }) + writeCallback({ + type: `insert`, + value: { id: 3, name: `Charlie`, active: true }, + }) + commitCallback() + markReadyCallback() + } + + // Wait for the preload to complete + await preloadPromise + + // The live query should be ready and have the initial data + expect(liveQuery.size).toBe(2) // Alice and Charlie are active + expect(liveQuery.get(1)).toEqual({ id: 1, name: `Alice`, active: true }) + expect(liveQuery.get(3)).toEqual({ id: 3, name: `Charlie`, active: true }) + expect(liveQuery.get(2)).toBeUndefined() // Bob is not active + // This test should fail because the live query is stuck in 'initialCommit' status + expect(liveQuery.status).toBe(`ready`) // This should be 'ready' but is currently 'initialCommit' + + // Now add some new data to the source collection (this should work as per the original report) + sourceCollection.insert({ id: 4, name: `David`, active: true }) + + // Wait for the mutation to propagate + await new Promise((resolve) => setTimeout(resolve, 10)) + + // The live query should update to include the new data + expect(liveQuery.size).toBe(3) // Alice, Charlie, and David are active + expect(liveQuery.get(4)).toEqual({ id: 4, name: `David`, active: true }) + }) }) From 257f471777999a02f885dbcd71011812db510c3a Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Sun, 10 Aug 2025 21:13:15 +0100 Subject: [PATCH 7/9] changeset --- .changeset/fast-crabs-change.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/fast-crabs-change.md diff --git a/.changeset/fast-crabs-change.md b/.changeset/fast-crabs-change.md new file mode 100644 index 00000000..0df420b4 --- /dev/null +++ b/.changeset/fast-crabs-change.md @@ -0,0 +1,5 @@ +--- +"@tanstack/db": patch +--- + +Ensure LiveQueryCollections are properly transitioning to ready state when source collections are preloaded after creation of the live query collection From 8b6bfd80506a281df5dd8ad2735aafceb21a4e76 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Sun, 10 Aug 2025 21:15:04 +0100 Subject: [PATCH 8/9] cleanup --- .../db/tests/query/live-query-collection.test.ts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/packages/db/tests/query/live-query-collection.test.ts b/packages/db/tests/query/live-query-collection.test.ts index d047c5f8..23f75d5e 100644 --- a/packages/db/tests/query/live-query-collection.test.ts +++ b/packages/db/tests/query/live-query-collection.test.ts @@ -5,18 +5,6 @@ import { Query } from "../../src/query/builder/index.js" import { mockSyncCollectionOptions } from "../utls.js" import type { ChangeMessage } from "../../src/types.js" -// This test file includes tests that reproduce a bug where live query collections -// don't properly transition to 'ready' status when source collections are preloaded -// after the live query collection is created. -// -// The issue: When a live query collection is created before its source collections -// are preloaded, the live query gets stuck in 'initialCommit' status instead of -// transitioning to 'ready' status, even though it has the correct data. -// -// This matches the original error report: "if I don't preload the collection before -// rendering, It won't update after being loaded. It'll only update, when some -// mutation/remote update happens" - // Sample user type for tests type User = { id: number From 93a23a276356fce5795df9849de3a5b784666f40 Mon Sep 17 00:00:00 2001 From: Sam Willis Date: Mon, 18 Aug 2025 09:42:46 +0100 Subject: [PATCH 9/9] tidy --- packages/db/tests/query/live-query-collection.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/db/tests/query/live-query-collection.test.ts b/packages/db/tests/query/live-query-collection.test.ts index 23f75d5e..2d812a61 100644 --- a/packages/db/tests/query/live-query-collection.test.ts +++ b/packages/db/tests/query/live-query-collection.test.ts @@ -191,7 +191,7 @@ describe(`createLiveQueryCollection`, () => { expect(liveQuery.isReady()).toBe(false) }) - it(`should update after source collection is loaded even when not preloaded before rendering - REPRODUCES BUG`, async () => { + it(`should update after source collection is loaded even when not preloaded before rendering`, async () => { // Create a source collection that doesn't start sync immediately let beginCallback: (() => void) | undefined let writeCallback: