From 59e291f768953dd1a0bcebb5670f4c9790c15155 Mon Sep 17 00:00:00 2001 From: teleskop150750 Date: Fri, 18 Jul 2025 20:11:44 +0300 Subject: [PATCH 1/6] wip: ref --- packages/vue-db/src/useLiveQuery.ts | 162 ++++------ packages/vue-db/tests/useLiveQuery.test.ts | 336 +++++++++++---------- 2 files changed, 232 insertions(+), 266 deletions(-) diff --git a/packages/vue-db/src/useLiveQuery.ts b/packages/vue-db/src/useLiveQuery.ts index 1339613a..de09899c 100644 --- a/packages/vue-db/src/useLiveQuery.ts +++ b/packages/vue-db/src/useLiveQuery.ts @@ -1,13 +1,12 @@ import { computed, - getCurrentInstance, - onUnmounted, - reactive, + onScopeDispose, ref, + shallowRef, toValue, watchEffect, } from "vue" -import { createLiveQueryCollection } from "@tanstack/db" +import { CollectionImpl, createLiveQueryCollection } from "@tanstack/db" import type { ChangeMessage, Collection, @@ -20,6 +19,10 @@ import type { } from "@tanstack/db" import type { ComputedRef, MaybeRefOrGetter } from "vue" +const isCollection = (v: unknown): v is CollectionImpl => { + return !!v && v instanceof CollectionImpl +} + /** * Return type for useLiveQuery hook * @property state - Reactive Map of query results (key → item) @@ -33,10 +36,10 @@ import type { ComputedRef, MaybeRefOrGetter } from "vue" * @property isCleanedUp - True when query has been cleaned up */ export interface UseLiveQueryReturn { - state: ComputedRef> - data: ComputedRef> - collection: ComputedRef> - status: ComputedRef + state: () => Map + data: () => Array + collection: () => Collection + status: () => CollectionStatus isLoading: ComputedRef isReady: ComputedRef isIdle: ComputedRef @@ -49,10 +52,10 @@ export interface UseLiveQueryReturnWithCollection< TKey extends string | number, TUtils extends Record, > { - state: ComputedRef> - data: ComputedRef> - collection: ComputedRef> - status: ComputedRef + state: () => Map + data: () => Array + collection: () => Collection + status: () => CollectionStatus isLoading: ComputedRef isReady: ComputedRef isIdle: ComputedRef @@ -111,8 +114,7 @@ export interface UseLiveQueryReturnWithCollection< */ // Overload 1: Accept just the query function export function useLiveQuery( - queryFn: (q: InitialQueryBuilder) => QueryBuilder, - deps?: Array> + queryFn: () => (q: InitialQueryBuilder) => QueryBuilder ): UseLiveQueryReturn> /** @@ -149,8 +151,7 @@ export function useLiveQuery( */ // Overload 2: Accept config object export function useLiveQuery( - config: LiveQueryCollectionConfig, - deps?: Array> + config: MaybeRefOrGetter> ): UseLiveQueryReturn> /** @@ -203,150 +204,107 @@ export function useLiveQuery< // Implementation export function useLiveQuery( - configOrQueryOrCollection: any, - deps: Array> = [] + configOrQueryOrCollection: any ): UseLiveQueryReturn | UseLiveQueryReturnWithCollection { const collection = computed(() => { - // First check if the original parameter might be a ref/getter - // by seeing if toValue returns something different than the original - let unwrappedParam = configOrQueryOrCollection - try { - const potentiallyUnwrapped = toValue(configOrQueryOrCollection) - if (potentiallyUnwrapped !== configOrQueryOrCollection) { - unwrappedParam = potentiallyUnwrapped - } - } catch { - // If toValue fails, use original parameter - unwrappedParam = configOrQueryOrCollection - } - - // Check if it's already a collection by checking for specific collection methods - const isCollection = - unwrappedParam && - typeof unwrappedParam === `object` && - typeof unwrappedParam.subscribeChanges === `function` && - typeof unwrappedParam.startSyncImmediate === `function` && - typeof unwrappedParam.id === `string` + const configOrQueryOrCollectionVal = toValue(configOrQueryOrCollection) - if (isCollection) { - // It's already a collection, ensure sync is started for Vue hooks - unwrappedParam.startSyncImmediate() - return unwrappedParam + if (isCollection(configOrQueryOrCollectionVal)) { + configOrQueryOrCollectionVal.startSyncImmediate() + return configOrQueryOrCollectionVal } - // Reference deps to make computed reactive to them - deps.forEach((dep) => toValue(dep)) - // Ensure we always start sync for Vue hooks - if (typeof unwrappedParam === `function`) { + if (typeof configOrQueryOrCollectionVal === `function`) { return createLiveQueryCollection({ - query: unwrappedParam, - startSync: true, - }) - } else { - return createLiveQueryCollection({ - ...unwrappedParam, + query: configOrQueryOrCollectionVal, startSync: true, }) } + + return createLiveQueryCollection({ + ...configOrQueryOrCollectionVal, + startSync: true, + }) }) // Reactive state that gets updated granularly through change events - const state = reactive(new Map()) + const state = ref(new Map()) // Reactive data array that maintains sorted order - const internalData = reactive>([]) - - // Computed wrapper for the data to match expected return type - const data = computed(() => internalData) + const internalData = shallowRef>([]) // Track collection status reactively - const status = ref(collection.value.status) + const status = shallowRef(collection.value.status) // Helper to sync data array from collection in correct order const syncDataFromCollection = ( currentCollection: Collection ) => { - internalData.length = 0 - internalData.push(...Array.from(currentCollection.values())) + internalData.value = Array.from(currentCollection.values()) } // Track current unsubscribe function - let currentUnsubscribe: (() => void) | null = null + let unsub: (() => void) | null = null + const clean = () => { + if (unsub) { + unsub() + unsub = null + } + } - // Watch for collection changes and subscribe to updates - watchEffect((onInvalidate) => { - const currentCollection = collection.value + watchEffect(() => { + clean() - // Update status ref whenever the effect runs - status.value = currentCollection.status + const collectionVal = collection.value - // Clean up previous subscription - if (currentUnsubscribe) { - currentUnsubscribe() - } + // Update status ref whenever the effect runs + status.value = collectionVal.status // Initialize state with current collection data - state.clear() - for (const [key, value] of currentCollection.entries()) { - state.set(key, value) - } + state.value = new Map(collectionVal.entries()) // Initialize data array in correct order - syncDataFromCollection(currentCollection) + syncDataFromCollection(collectionVal) // Subscribe to collection changes with granular updates - currentUnsubscribe = currentCollection.subscribeChanges( + unsub = collectionVal.subscribeChanges( (changes: Array>) => { // Apply each change individually to the reactive state for (const change of changes) { switch (change.type) { case `insert`: case `update`: - state.set(change.key, change.value) + state.value.set(change.key, change.value) break case `delete`: - state.delete(change.key) + state.value.delete(change.key) break } } // Update the data array to maintain sorted order - syncDataFromCollection(currentCollection) + syncDataFromCollection(collectionVal) // Update status ref on every change - status.value = currentCollection.status + status.value = collectionVal.status } ) // Preload collection data if not already started - if (currentCollection.status === `idle`) { - currentCollection.preload().catch(console.error) + if (collectionVal.status === `idle`) { + collectionVal.preload().catch(console.error) } - - // Cleanup when effect is invalidated - onInvalidate(() => { - if (currentUnsubscribe) { - currentUnsubscribe() - currentUnsubscribe = null - } - }) }) // Cleanup on unmount (only if we're in a component context) - const instance = getCurrentInstance() - if (instance) { - onUnmounted(() => { - if (currentUnsubscribe) { - currentUnsubscribe() - } - }) - } + onScopeDispose(clean) return { - state: computed(() => state), - data, - collection: computed(() => collection.value), - status: computed(() => status.value), + state: () => state.value, + data: () => internalData.value, + collection: () => collection.value, + status: () => status.value, + // TODO: () => val isLoading: computed( () => status.value === `loading` || status.value === `initialCommit` ), diff --git a/packages/vue-db/tests/useLiveQuery.test.ts b/packages/vue-db/tests/useLiveQuery.test.ts index 874729d8..88710578 100644 --- a/packages/vue-db/tests/useLiveQuery.test.ts +++ b/packages/vue-db/tests/useLiveQuery.test.ts @@ -108,24 +108,25 @@ describe(`Query Collections`, () => { }) ) - const { state, data } = useLiveQuery((q) => - q - .from({ persons: collection }) - .where(({ persons }) => gt(persons.age, 30)) - .select(({ persons }) => ({ - id: persons.id, - name: persons.name, - age: persons.age, - })) + const { state, data } = useLiveQuery( + () => (q) => + q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + age: persons.age, + })) ) // Wait for Vue reactivity to update await waitForVueUpdate() - expect(state.value.size).toBe(1) // Only John Smith (age 35) - expect(data.value).toHaveLength(1) + expect(state().size).toBe(1) // Only John Smith (age 35) + expect(data()).toHaveLength(1) - const johnSmith = data.value[0] + const johnSmith = data()[0] expect(johnSmith).toMatchObject({ id: `3`, name: `John Smith`, @@ -142,28 +143,29 @@ describe(`Query Collections`, () => { }) ) - const { state, data } = useLiveQuery((q) => - q - .from({ collection }) - .where(({ collection: c }) => gt(c.age, 30)) - .select(({ collection: c }) => ({ - id: c.id, - name: c.name, - })) - .orderBy(({ collection: c }) => c.id, `asc`) + const { state, data } = useLiveQuery( + () => (q) => + q + .from({ collection }) + .where(({ collection: c }) => gt(c.age, 30)) + .select(({ collection: c }) => ({ + id: c.id, + name: c.name, + })) + .orderBy(({ collection: c }) => c.id, `asc`) ) // Wait for collection to sync await waitForVueUpdate() - expect(state.value.size).toBe(1) - expect(state.value.get(`3`)).toMatchObject({ + expect(state().size).toBe(1) + expect(state().get(`3`)).toMatchObject({ id: `3`, name: `John Smith`, }) - expect(data.value.length).toBe(1) - expect(data.value[0]).toMatchObject({ + expect(data().length).toBe(1) + expect(data()[0]).toMatchObject({ id: `3`, name: `John Smith`, }) @@ -185,18 +187,18 @@ describe(`Query Collections`, () => { await waitForVueUpdate() - expect(state.value.size).toBe(2) - expect(state.value.get(`3`)).toMatchObject({ + expect(state().size).toBe(2) + expect(state().get(`3`)).toMatchObject({ id: `3`, name: `John Smith`, }) - expect(state.value.get(`4`)).toMatchObject({ + expect(state().get(`4`)).toMatchObject({ id: `4`, name: `Kyle Doe`, }) - expect(data.value.length).toBe(2) - expect(data.value).toEqual( + expect(data().length).toBe(2) + expect(data()).toEqual( expect.arrayContaining([ expect.objectContaining({ id: `3`, @@ -226,14 +228,14 @@ describe(`Query Collections`, () => { await waitForVueUpdate() - expect(state.value.size).toBe(2) - expect(state.value.get(`4`)).toMatchObject({ + expect(state().size).toBe(2) + expect(state().get(`4`)).toMatchObject({ id: `4`, name: `Kyle Doe 2`, }) - expect(data.value.length).toBe(2) - expect(data.value).toEqual( + expect(data().length).toBe(2) + expect(data()).toEqual( expect.arrayContaining([ expect.objectContaining({ id: `3`, @@ -263,11 +265,11 @@ describe(`Query Collections`, () => { await waitForVueUpdate() - expect(state.value.size).toBe(1) - expect(state.value.get(`4`)).toBeUndefined() + expect(state().size).toBe(1) + expect(state().get(`4`)).toBeUndefined() - expect(data.value.length).toBe(1) - expect(data.value[0]).toMatchObject({ + expect(data().length).toBe(1) + expect(data()[0]).toMatchObject({ id: `3`, name: `John Smith`, }) @@ -292,38 +294,39 @@ describe(`Query Collections`, () => { }) ) - const { state } = useLiveQuery((q) => - q - .from({ issues: issueCollection }) - .join({ persons: personCollection }, ({ issues, persons }) => - eq(issues.userId, persons.id) - ) - .select(({ issues, persons }) => ({ - id: issues.id, - title: issues.title, - name: persons.name, - })) + const { state } = useLiveQuery( + () => (q) => + q + .from({ issues: issueCollection }) + .join({ persons: personCollection }, ({ issues, persons }) => + eq(issues.userId, persons.id) + ) + .select(({ issues, persons }) => ({ + id: issues.id, + title: issues.title, + name: persons.name, + })) ) // Wait for collections to sync await waitForVueUpdate() // Verify that we have the expected joined results - expect(state.value.size).toBe(3) + expect(state().size).toBe(3) - expect(state.value.get(`[1,1]`)).toMatchObject({ + expect(state().get(`[1,1]`)).toMatchObject({ id: `1`, name: `John Doe`, title: `Issue 1`, }) - expect(state.value.get(`[2,2]`)).toMatchObject({ + expect(state().get(`[2,2]`)).toMatchObject({ id: `2`, name: `Jane Doe`, title: `Issue 2`, }) - expect(state.value.get(`[3,1]`)).toMatchObject({ + expect(state().get(`[3,1]`)).toMatchObject({ id: `3`, name: `John Doe`, title: `Issue 3`, @@ -344,8 +347,8 @@ describe(`Query Collections`, () => { await waitForVueUpdate() - expect(state.value.size).toBe(4) - expect(state.value.get(`[4,2]`)).toMatchObject({ + expect(state().size).toBe(4) + expect(state().get(`[4,2]`)).toMatchObject({ id: `4`, name: `Jane Doe`, title: `Issue 4`, @@ -367,7 +370,7 @@ describe(`Query Collections`, () => { await waitForVueUpdate() // The updated title should be reflected in the joined results - expect(state.value.get(`[2,2]`)).toMatchObject({ + expect(state().get(`[2,2]`)).toMatchObject({ id: `2`, name: `Jane Doe`, title: `Updated Issue 2`, @@ -389,8 +392,8 @@ describe(`Query Collections`, () => { await waitForVueUpdate() // After deletion, issue 3 should no longer have a joined result - expect(state.value.get(`[3,1]`)).toBeUndefined() - expect(state.value.size).toBe(3) + expect(state().get(`[3,1]`)).toBeUndefined() + expect(state().size).toBe(3) }) it(`should recompile query when parameters change and change results`, async () => { @@ -405,7 +408,7 @@ describe(`Query Collections`, () => { const minAge = ref(30) const { state } = useLiveQuery( - (q) => + () => (q) => q .from({ collection }) .where(({ collection: c }) => gt(c.age, minAge.value)) @@ -413,16 +416,15 @@ describe(`Query Collections`, () => { id: c.id, name: c.name, age: c.age, - })), - [minAge] + })) ) // Wait for collection to sync await waitForVueUpdate() // Initially should return only people older than 30 - expect(state.value.size).toBe(1) - expect(state.value.get(`3`)).toMatchObject({ + expect(state().size).toBe(1) + expect(state().get(`3`)).toMatchObject({ id: `3`, name: `John Smith`, age: 35, @@ -434,18 +436,18 @@ describe(`Query Collections`, () => { await waitForVueUpdate() // Now should return all people as they're all older than 20 - expect(state.value.size).toBe(3) - expect(state.value.get(`1`)).toMatchObject({ + expect(state().size).toBe(3) + expect(state().get(`1`)).toMatchObject({ id: `1`, name: `John Doe`, age: 30, }) - expect(state.value.get(`2`)).toMatchObject({ + expect(state().get(`2`)).toMatchObject({ id: `2`, name: `Jane Doe`, age: 25, }) - expect(state.value.get(`3`)).toMatchObject({ + expect(state().get(`3`)).toMatchObject({ id: `3`, name: `John Smith`, age: 35, @@ -457,7 +459,7 @@ describe(`Query Collections`, () => { await waitForVueUpdate() // Should now be empty - expect(state.value.size).toBe(0) + expect(state().size).toBe(0) }) it(`should be able to query a result collection with live updates`, async () => { @@ -471,38 +473,40 @@ describe(`Query Collections`, () => { // Initial query const { state: _initialState, collection: initialCollection } = - useLiveQuery((q) => - q - .from({ collection }) - .where(({ collection: c }) => gt(c.age, 30)) - .select(({ collection: c }) => ({ - id: c.id, - name: c.name, - team: c.team, - })) - .orderBy(({ collection: c }) => c.id, `asc`) + useLiveQuery( + () => (q) => + q + .from({ collection }) + .where(({ collection: c }) => gt(c.age, 30)) + .select(({ collection: c }) => ({ + id: c.id, + name: c.name, + team: c.team, + })) + .orderBy(({ collection: c }) => c.id, `asc`) ) // Wait for collection to sync await waitForVueUpdate() // Grouped query derived from initial query - const { state: groupedState } = useLiveQuery((q) => - q - .from({ queryResult: initialCollection.value }) - .groupBy(({ queryResult }) => queryResult.team) - .select(({ queryResult }) => ({ - team: queryResult.team, - count: count(queryResult.id), - })) + const { state: groupedState } = useLiveQuery( + () => (q) => + q + .from({ queryResult: initialCollection() }) + .groupBy(({ queryResult }) => queryResult.team) + .select(({ queryResult }) => ({ + team: queryResult.team, + count: count(queryResult.id), + })) ) // Wait for grouped query to sync await waitForVueUpdate() // Verify initial grouped results - expect(groupedState.value.size).toBe(1) - const teamResult = Array.from(groupedState.value.values())[0] + expect(groupedState().size).toBe(1) + const teamResult = Array.from(groupedState().values())[0] expect(teamResult).toMatchObject({ team: `team1`, count: 1, @@ -537,9 +541,9 @@ describe(`Query Collections`, () => { await waitForVueUpdate() // Verify the grouped results include the new team members - expect(groupedState.value.size).toBe(2) + expect(groupedState().size).toBe(2) - const groupedResults = Array.from(groupedState.value.values()) + const groupedResults = Array.from(groupedState().values()) const team1Result = groupedResults.find((r) => r.team === `team1`) const team2Result = groupedResults.find((r) => r.team === `team2`) @@ -581,17 +585,18 @@ describe(`Query Collections`, () => { ) // Render the hook with a query that joins persons and issues - const queryResult = useLiveQuery((q) => - q - .from({ issues: issueCollection }) - .join({ persons: personCollection }, ({ issues, persons }) => - eq(issues.userId, persons.id) - ) - .select(({ issues, persons }) => ({ - id: issues.id, - title: issues.title, - name: persons.name, - })) + const queryResult = useLiveQuery( + () => (q) => + q + .from({ issues: issueCollection }) + .join({ persons: personCollection }, ({ issues, persons }) => + eq(issues.userId, persons.id) + ) + .select(({ issues, persons }) => ({ + id: issues.id, + title: issues.title, + name: persons.name, + })) ) const { state } = queryResult @@ -599,9 +604,9 @@ describe(`Query Collections`, () => { // Track each state change like React does with useEffect watchEffect(() => { renderStates.push({ - stateSize: state.value.size, - hasTempKey: state.value.has(`[temp-key,1]`), - hasPermKey: state.value.has(`[4,1]`), + stateSize: state().size, + hasTempKey: state().has(`[temp-key,1]`), + hasPermKey: state().has(`[4,1]`), timestamp: Date.now(), }) }) @@ -609,7 +614,7 @@ describe(`Query Collections`, () => { // Wait for collections to sync and verify initial state await waitForVueUpdate() - expect(state.value.size).toBe(3) + expect(state().size).toBe(3) // Reset render states array for clarity in the remaining test renderStates.length = 0 @@ -672,13 +677,13 @@ describe(`Query Collections`, () => { await nextTick() // Verify optimistic state is immediately reflected (should be synchronous) - expect(state.value.size).toBe(4) - expect(state.value.get(`[temp-key,1]`)).toMatchObject({ + expect(state().size).toBe(4) + expect(state().get(`[temp-key,1]`)).toMatchObject({ id: `temp-key`, name: `John Doe`, title: `New Issue`, }) - expect(state.value.get(`[4,1]`)).toBeUndefined() + expect(state().get(`[4,1]`)).toBeUndefined() // Wait for the transaction to be committed await transaction.isPersisted.promise @@ -686,9 +691,9 @@ describe(`Query Collections`, () => { await waitForVueUpdate() // Verify the temporary key is replaced by the permanent one - expect(state.value.size).toBe(4) - expect(state.value.get(`[temp-key,1]`)).toBeUndefined() - expect(state.value.get(`[4,1]`)).toMatchObject({ + expect(state().size).toBe(4) + expect(state().get(`[temp-key,1]`)).toBeUndefined() + expect(state().get(`[4,1]`)).toMatchObject({ id: `4`, name: `John Doe`, title: `New Issue`, @@ -727,10 +732,10 @@ describe(`Query Collections`, () => { // Wait for collection to sync and state to update await waitForVueUpdate() - expect(state.value.size).toBe(1) // Only John Smith (age 35) - expect(data.value).toHaveLength(1) + expect(state().size).toBe(1) // Only John Smith (age 35) + expect(data()).toHaveLength(1) - const johnSmith = data.value[0] + const johnSmith = data()[0] expect(johnSmith).toMatchObject({ id: `3`, name: `John Smith`, @@ -738,7 +743,7 @@ describe(`Query Collections`, () => { }) // Verify that the returned collection is the same instance - expect(returnedCollection.value).toBe(liveQueryCollection) + expect(returnedCollection()).toBe(liveQueryCollection) }) it(`should switch to a different pre-created live query collection when reactive ref changes`, async () => { @@ -808,12 +813,12 @@ describe(`Query Collections`, () => { // Wait for first collection to sync await waitForVueUpdate() - expect(state.value.size).toBe(1) // Only John Smith from collection1 - expect(state.value.get(`3`)).toMatchObject({ + expect(state().size).toBe(1) // Only John Smith from collection1 + expect(state().get(`3`)).toMatchObject({ id: `3`, name: `John Smith`, }) - expect(returnedCollection.value.id).toBe(liveQueryCollection1.id) + expect(returnedCollection().id).toBe(liveQueryCollection1.id) // Switch to the second collection by updating the reactive ref currentCollection.value = liveQueryCollection2 as any @@ -821,19 +826,19 @@ describe(`Query Collections`, () => { // Wait for the reactive change to propagate await waitForVueUpdate() - expect(state.value.size).toBe(2) // Alice and Bob from collection2 - expect(state.value.get(`4`)).toMatchObject({ + expect(state().size).toBe(2) // Alice and Bob from collection2 + expect(state().get(`4`)).toMatchObject({ id: `4`, name: `Alice Cooper`, }) - expect(state.value.get(`5`)).toMatchObject({ + expect(state().get(`5`)).toMatchObject({ id: `5`, name: `Bob Dylan`, }) - expect(returnedCollection.value.id).toBe(liveQueryCollection2.id) + expect(returnedCollection().id).toBe(liveQueryCollection2.id) // Verify we no longer have data from the first collection - expect(state.value.get(`3`)).toBeUndefined() + expect(state().get(`3`)).toBeUndefined() }) describe(`isReady property`, () => { @@ -858,14 +863,15 @@ describe(`Query Collections`, () => { onDelete: () => Promise.resolve(), }) - const { isReady } = useLiveQuery((q) => - q - .from({ persons: collection }) - .where(({ persons }) => gt(persons.age, 30)) - .select(({ persons }) => ({ - id: persons.id, - name: persons.name, - })) + const { isReady } = useLiveQuery( + () => (q) => + q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + })) ) // Initially isReady should be false (collection is in idle state) @@ -972,14 +978,15 @@ describe(`Query Collections`, () => { onDelete: () => Promise.resolve(), }) - const { isReady } = useLiveQuery((q) => - q - .from({ persons: collection }) - .where(({ persons }) => gt(persons.age, 30)) - .select(({ persons }) => ({ - id: persons.id, - name: persons.name, - })) + const { isReady } = useLiveQuery( + () => (q) => + q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + })) ) expect(isReady.value).toBe(false) @@ -1008,14 +1015,15 @@ describe(`Query Collections`, () => { }) ) - const { isReady } = useLiveQuery((q) => - q - .from({ persons: collection }) - .where(({ persons }) => gt(persons.age, 30)) - .select(({ persons }) => ({ - id: persons.id, - name: persons.name, - })) + const { isReady } = useLiveQuery( + () => (q) => + q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + })) ) await waitForVueUpdate() @@ -1076,17 +1084,18 @@ describe(`Query Collections`, () => { onDelete: () => Promise.resolve(), }) - const { isReady } = useLiveQuery((q) => - q - .from({ issues: issueCollection }) - .join({ persons: personCollection }, ({ issues, persons }) => - eq(issues.userId, persons.id) - ) - .select(({ issues, persons }) => ({ - id: issues.id, - title: issues.title, - name: persons.name, - })) + const { isReady } = useLiveQuery( + () => (q) => + q + .from({ issues: issueCollection }) + .join({ persons: personCollection }, ({ issues, persons }) => + eq(issues.userId, persons.id) + ) + .select(({ issues, persons }) => ({ + id: issues.id, + title: issues.title, + name: persons.name, + })) ) expect(isReady.value).toBe(false) @@ -1139,15 +1148,14 @@ describe(`Query Collections`, () => { const minAge = ref(30) const { isReady } = useLiveQuery( - (q) => + () => (q) => q .from({ collection }) .where(({ collection: c }) => gt(c.age, minAge.value)) .select(({ collection: c }) => ({ id: c.id, name: c.name, - })), - [minAge] + })) ) expect(isReady.value).toBe(false) @@ -1205,10 +1213,10 @@ describe(`Query Collections`, () => { // Wait for collection to sync and state to update await waitForVueUpdate() - expect(state.value.size).toBe(1) // Only John Smith (age 35) - expect(data.value).toHaveLength(1) + expect(state().size).toBe(1) // Only John Smith (age 35) + expect(data()).toHaveLength(1) - const johnSmith = data.value[0] + const johnSmith = data()[0] expect(johnSmith).toMatchObject({ id: `3`, name: `John Smith`, From 6c427e3a679ee453c52f426e6a5db622746d3f67 Mon Sep 17 00:00:00 2001 From: teleskop150750 Date: Fri, 18 Jul 2025 21:42:59 +0300 Subject: [PATCH 2/6] wip: --- packages/vue-db/src/useLiveQuery.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/vue-db/src/useLiveQuery.ts b/packages/vue-db/src/useLiveQuery.ts index de09899c..c3629f72 100644 --- a/packages/vue-db/src/useLiveQuery.ts +++ b/packages/vue-db/src/useLiveQuery.ts @@ -1,5 +1,6 @@ import { computed, + getCurrentScope, onScopeDispose, ref, shallowRef, @@ -20,7 +21,7 @@ import type { import type { ComputedRef, MaybeRefOrGetter } from "vue" const isCollection = (v: unknown): v is CollectionImpl => { - return !!v && v instanceof CollectionImpl + return v instanceof CollectionImpl } /** @@ -296,8 +297,10 @@ export function useLiveQuery( } }) - // Cleanup on unmount (only if we're in a component context) - onScopeDispose(clean) + // Cleanup + if (getCurrentScope()) { + onScopeDispose(clean) + } return { state: () => state.value, From 302cd03d522b8d6a71733450672f3c16b7f869c8 Mon Sep 17 00:00:00 2001 From: teleskop150750 Date: Sat, 19 Jul 2025 14:18:04 +0300 Subject: [PATCH 3/6] wip --- packages/vue-db/src/useLiveQuery.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/packages/vue-db/src/useLiveQuery.ts b/packages/vue-db/src/useLiveQuery.ts index c3629f72..c44e468c 100644 --- a/packages/vue-db/src/useLiveQuery.ts +++ b/packages/vue-db/src/useLiveQuery.ts @@ -20,10 +20,6 @@ import type { } from "@tanstack/db" import type { ComputedRef, MaybeRefOrGetter } from "vue" -const isCollection = (v: unknown): v is CollectionImpl => { - return v instanceof CollectionImpl -} - /** * Return type for useLiveQuery hook * @property state - Reactive Map of query results (key → item) @@ -210,7 +206,7 @@ export function useLiveQuery( const collection = computed(() => { const configOrQueryOrCollectionVal = toValue(configOrQueryOrCollection) - if (isCollection(configOrQueryOrCollectionVal)) { + if (configOrQueryOrCollectionVal instanceof CollectionImpl) { configOrQueryOrCollectionVal.startSyncImmediate() return configOrQueryOrCollectionVal } From 24e1e935b29bbf01afbf6a3e7905754a0c68ee92 Mon Sep 17 00:00:00 2001 From: teleskop150750 Date: Sun, 20 Jul 2025 13:49:27 +0300 Subject: [PATCH 4/6] chore: revert query type --- packages/vue-db/src/useLiveQuery.ts | 20 +- packages/vue-db/tests/useLiveQuery.test.ts | 232 ++++++++++----------- 2 files changed, 121 insertions(+), 131 deletions(-) diff --git a/packages/vue-db/src/useLiveQuery.ts b/packages/vue-db/src/useLiveQuery.ts index c44e468c..9db75e34 100644 --- a/packages/vue-db/src/useLiveQuery.ts +++ b/packages/vue-db/src/useLiveQuery.ts @@ -111,7 +111,7 @@ export interface UseLiveQueryReturnWithCollection< */ // Overload 1: Accept just the query function export function useLiveQuery( - queryFn: () => (q: InitialQueryBuilder) => QueryBuilder + queryFn: (q: InitialQueryBuilder) => QueryBuilder ): UseLiveQueryReturn> /** @@ -204,6 +204,16 @@ export function useLiveQuery( configOrQueryOrCollection: any ): UseLiveQueryReturn | UseLiveQueryReturnWithCollection { const collection = computed(() => { + if ( + typeof configOrQueryOrCollection === `function` && + configOrQueryOrCollection.length === 1 + ) { + return createLiveQueryCollection({ + query: configOrQueryOrCollection, + startSync: true, + }) + } + const configOrQueryOrCollectionVal = toValue(configOrQueryOrCollection) if (configOrQueryOrCollectionVal instanceof CollectionImpl) { @@ -211,14 +221,6 @@ export function useLiveQuery( return configOrQueryOrCollectionVal } - // Ensure we always start sync for Vue hooks - if (typeof configOrQueryOrCollectionVal === `function`) { - return createLiveQueryCollection({ - query: configOrQueryOrCollectionVal, - startSync: true, - }) - } - return createLiveQueryCollection({ ...configOrQueryOrCollectionVal, startSync: true, diff --git a/packages/vue-db/tests/useLiveQuery.test.ts b/packages/vue-db/tests/useLiveQuery.test.ts index 88710578..916afcb0 100644 --- a/packages/vue-db/tests/useLiveQuery.test.ts +++ b/packages/vue-db/tests/useLiveQuery.test.ts @@ -108,16 +108,15 @@ describe(`Query Collections`, () => { }) ) - const { state, data } = useLiveQuery( - () => (q) => - q - .from({ persons: collection }) - .where(({ persons }) => gt(persons.age, 30)) - .select(({ persons }) => ({ - id: persons.id, - name: persons.name, - age: persons.age, - })) + const { state, data } = useLiveQuery((q) => + q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + age: persons.age, + })) ) // Wait for Vue reactivity to update @@ -143,16 +142,15 @@ describe(`Query Collections`, () => { }) ) - const { state, data } = useLiveQuery( - () => (q) => - q - .from({ collection }) - .where(({ collection: c }) => gt(c.age, 30)) - .select(({ collection: c }) => ({ - id: c.id, - name: c.name, - })) - .orderBy(({ collection: c }) => c.id, `asc`) + const { state, data } = useLiveQuery((q) => + q + .from({ collection }) + .where(({ collection: c }) => gt(c.age, 30)) + .select(({ collection: c }) => ({ + id: c.id, + name: c.name, + })) + .orderBy(({ collection: c }) => c.id, `asc`) ) // Wait for collection to sync @@ -294,18 +292,17 @@ describe(`Query Collections`, () => { }) ) - const { state } = useLiveQuery( - () => (q) => - q - .from({ issues: issueCollection }) - .join({ persons: personCollection }, ({ issues, persons }) => - eq(issues.userId, persons.id) - ) - .select(({ issues, persons }) => ({ - id: issues.id, - title: issues.title, - name: persons.name, - })) + const { state } = useLiveQuery((q) => + q + .from({ issues: issueCollection }) + .join({ persons: personCollection }, ({ issues, persons }) => + eq(issues.userId, persons.id) + ) + .select(({ issues, persons }) => ({ + id: issues.id, + title: issues.title, + name: persons.name, + })) ) // Wait for collections to sync @@ -407,16 +404,15 @@ describe(`Query Collections`, () => { const minAge = ref(30) - const { state } = useLiveQuery( - () => (q) => - q - .from({ collection }) - .where(({ collection: c }) => gt(c.age, minAge.value)) - .select(({ collection: c }) => ({ - id: c.id, - name: c.name, - age: c.age, - })) + const { state } = useLiveQuery((q) => + q + .from({ collection }) + .where(({ collection: c }) => gt(c.age, minAge.value)) + .select(({ collection: c }) => ({ + id: c.id, + name: c.name, + age: c.age, + })) ) // Wait for collection to sync @@ -473,32 +469,30 @@ describe(`Query Collections`, () => { // Initial query const { state: _initialState, collection: initialCollection } = - useLiveQuery( - () => (q) => - q - .from({ collection }) - .where(({ collection: c }) => gt(c.age, 30)) - .select(({ collection: c }) => ({ - id: c.id, - name: c.name, - team: c.team, - })) - .orderBy(({ collection: c }) => c.id, `asc`) + useLiveQuery((q) => + q + .from({ collection }) + .where(({ collection: c }) => gt(c.age, 30)) + .select(({ collection: c }) => ({ + id: c.id, + name: c.name, + team: c.team, + })) + .orderBy(({ collection: c }) => c.id, `asc`) ) // Wait for collection to sync await waitForVueUpdate() // Grouped query derived from initial query - const { state: groupedState } = useLiveQuery( - () => (q) => - q - .from({ queryResult: initialCollection() }) - .groupBy(({ queryResult }) => queryResult.team) - .select(({ queryResult }) => ({ - team: queryResult.team, - count: count(queryResult.id), - })) + const { state: groupedState } = useLiveQuery((q) => + q + .from({ queryResult: initialCollection() }) + .groupBy(({ queryResult }) => queryResult.team) + .select(({ queryResult }) => ({ + team: queryResult.team, + count: count(queryResult.id), + })) ) // Wait for grouped query to sync @@ -585,18 +579,17 @@ describe(`Query Collections`, () => { ) // Render the hook with a query that joins persons and issues - const queryResult = useLiveQuery( - () => (q) => - q - .from({ issues: issueCollection }) - .join({ persons: personCollection }, ({ issues, persons }) => - eq(issues.userId, persons.id) - ) - .select(({ issues, persons }) => ({ - id: issues.id, - title: issues.title, - name: persons.name, - })) + const queryResult = useLiveQuery((q) => + q + .from({ issues: issueCollection }) + .join({ persons: personCollection }, ({ issues, persons }) => + eq(issues.userId, persons.id) + ) + .select(({ issues, persons }) => ({ + id: issues.id, + title: issues.title, + name: persons.name, + })) ) const { state } = queryResult @@ -863,15 +856,14 @@ describe(`Query Collections`, () => { onDelete: () => Promise.resolve(), }) - const { isReady } = useLiveQuery( - () => (q) => - q - .from({ persons: collection }) - .where(({ persons }) => gt(persons.age, 30)) - .select(({ persons }) => ({ - id: persons.id, - name: persons.name, - })) + const { isReady } = useLiveQuery((q) => + q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + })) ) // Initially isReady should be false (collection is in idle state) @@ -978,15 +970,14 @@ describe(`Query Collections`, () => { onDelete: () => Promise.resolve(), }) - const { isReady } = useLiveQuery( - () => (q) => - q - .from({ persons: collection }) - .where(({ persons }) => gt(persons.age, 30)) - .select(({ persons }) => ({ - id: persons.id, - name: persons.name, - })) + const { isReady } = useLiveQuery((q) => + q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + })) ) expect(isReady.value).toBe(false) @@ -1015,15 +1006,14 @@ describe(`Query Collections`, () => { }) ) - const { isReady } = useLiveQuery( - () => (q) => - q - .from({ persons: collection }) - .where(({ persons }) => gt(persons.age, 30)) - .select(({ persons }) => ({ - id: persons.id, - name: persons.name, - })) + const { isReady } = useLiveQuery((q) => + q + .from({ persons: collection }) + .where(({ persons }) => gt(persons.age, 30)) + .select(({ persons }) => ({ + id: persons.id, + name: persons.name, + })) ) await waitForVueUpdate() @@ -1084,18 +1074,17 @@ describe(`Query Collections`, () => { onDelete: () => Promise.resolve(), }) - const { isReady } = useLiveQuery( - () => (q) => - q - .from({ issues: issueCollection }) - .join({ persons: personCollection }, ({ issues, persons }) => - eq(issues.userId, persons.id) - ) - .select(({ issues, persons }) => ({ - id: issues.id, - title: issues.title, - name: persons.name, - })) + const { isReady } = useLiveQuery((q) => + q + .from({ issues: issueCollection }) + .join({ persons: personCollection }, ({ issues, persons }) => + eq(issues.userId, persons.id) + ) + .select(({ issues, persons }) => ({ + id: issues.id, + title: issues.title, + name: persons.name, + })) ) expect(isReady.value).toBe(false) @@ -1147,15 +1136,14 @@ describe(`Query Collections`, () => { }) const minAge = ref(30) - const { isReady } = useLiveQuery( - () => (q) => - q - .from({ collection }) - .where(({ collection: c }) => gt(c.age, minAge.value)) - .select(({ collection: c }) => ({ - id: c.id, - name: c.name, - })) + const { isReady } = useLiveQuery((q) => + q + .from({ collection }) + .where(({ collection: c }) => gt(c.age, minAge.value)) + .select(({ collection: c }) => ({ + id: c.id, + name: c.name, + })) ) expect(isReady.value).toBe(false) From cb83b7af2784ce9662e340a6f8b63b0d18f9e482 Mon Sep 17 00:00:00 2001 From: teleskop150750 Date: Tue, 22 Jul 2025 08:13:52 +0300 Subject: [PATCH 5/6] add getter --- packages/vue-db/src/useLiveQuery.ts | 85 +++++--- packages/vue-db/tests/useLiveQuery.test.ts | 220 ++++++++++----------- 2 files changed, 161 insertions(+), 144 deletions(-) diff --git a/packages/vue-db/src/useLiveQuery.ts b/packages/vue-db/src/useLiveQuery.ts index 9db75e34..51106756 100644 --- a/packages/vue-db/src/useLiveQuery.ts +++ b/packages/vue-db/src/useLiveQuery.ts @@ -18,7 +18,7 @@ import type { LiveQueryCollectionConfig, QueryBuilder, } from "@tanstack/db" -import type { ComputedRef, MaybeRefOrGetter } from "vue" +import type { MaybeRefOrGetter } from "vue" /** * Return type for useLiveQuery hook @@ -33,15 +33,15 @@ import type { ComputedRef, MaybeRefOrGetter } from "vue" * @property isCleanedUp - True when query has been cleaned up */ export interface UseLiveQueryReturn { - state: () => Map - data: () => Array - collection: () => Collection - status: () => CollectionStatus - isLoading: ComputedRef - isReady: ComputedRef - isIdle: ComputedRef - isError: ComputedRef - isCleanedUp: ComputedRef + state: Map + data: Array + collection: Collection + status: CollectionStatus + isLoading: boolean + isReady: boolean + isIdle: boolean + isError: boolean + isCleanedUp: boolean } export interface UseLiveQueryReturnWithCollection< @@ -49,15 +49,15 @@ export interface UseLiveQueryReturnWithCollection< TKey extends string | number, TUtils extends Record, > { - state: () => Map - data: () => Array - collection: () => Collection - status: () => CollectionStatus - isLoading: ComputedRef - isReady: ComputedRef - isIdle: ComputedRef - isError: ComputedRef - isCleanedUp: ComputedRef + state: Map + data: Array + collection: Collection + status: CollectionStatus + isLoading: boolean + isReady: boolean + isIdle: boolean + isError: boolean + isCleanedUp: boolean } /** @@ -300,18 +300,41 @@ export function useLiveQuery( onScopeDispose(clean) } + const isLoading = computed( + () => status.value === `loading` || status.value === `initialCommit` + ) + const isReady = computed(() => status.value === `ready`) + const isIdle = computed(() => status.value === `idle`) + const isError = computed(() => status.value === `error`) + const isCleanedUp = computed(() => status.value === `cleaned-up`) + return { - state: () => state.value, - data: () => internalData.value, - collection: () => collection.value, - status: () => status.value, - // TODO: () => val - isLoading: computed( - () => status.value === `loading` || status.value === `initialCommit` - ), - isReady: computed(() => status.value === `ready`), - isIdle: computed(() => status.value === `idle`), - isError: computed(() => status.value === `error`), - isCleanedUp: computed(() => status.value === `cleaned-up`), + get state() { + return state.value + }, + get data() { + return internalData.value + }, + get collection() { + return collection.value + }, + get status() { + return status.value + }, + get isLoading() { + return isLoading.value + }, + get isReady() { + return isReady.value + }, + get isIdle() { + return isIdle.value + }, + get isError() { + return isError.value + }, + get isCleanedUp() { + return isCleanedUp.value + }, } } diff --git a/packages/vue-db/tests/useLiveQuery.test.ts b/packages/vue-db/tests/useLiveQuery.test.ts index 916afcb0..0e3e7c60 100644 --- a/packages/vue-db/tests/useLiveQuery.test.ts +++ b/packages/vue-db/tests/useLiveQuery.test.ts @@ -108,7 +108,7 @@ describe(`Query Collections`, () => { }) ) - const { state, data } = useLiveQuery((q) => + const LiveQuery = useLiveQuery((q) => q .from({ persons: collection }) .where(({ persons }) => gt(persons.age, 30)) @@ -122,10 +122,10 @@ describe(`Query Collections`, () => { // Wait for Vue reactivity to update await waitForVueUpdate() - expect(state().size).toBe(1) // Only John Smith (age 35) - expect(data()).toHaveLength(1) + expect(LiveQuery.state.size).toBe(1) // Only John Smith (age 35) + expect(LiveQuery.data).toHaveLength(1) - const johnSmith = data()[0] + const johnSmith = LiveQuery.data[0] expect(johnSmith).toMatchObject({ id: `3`, name: `John Smith`, @@ -142,7 +142,7 @@ describe(`Query Collections`, () => { }) ) - const { state, data } = useLiveQuery((q) => + const LiveQuery = useLiveQuery((q) => q .from({ collection }) .where(({ collection: c }) => gt(c.age, 30)) @@ -156,14 +156,14 @@ describe(`Query Collections`, () => { // Wait for collection to sync await waitForVueUpdate() - expect(state().size).toBe(1) - expect(state().get(`3`)).toMatchObject({ + expect(LiveQuery.state.size).toBe(1) + expect(LiveQuery.state.get(`3`)).toMatchObject({ id: `3`, name: `John Smith`, }) - expect(data().length).toBe(1) - expect(data()[0]).toMatchObject({ + expect(LiveQuery.data.length).toBe(1) + expect(LiveQuery.data[0]).toMatchObject({ id: `3`, name: `John Smith`, }) @@ -185,18 +185,18 @@ describe(`Query Collections`, () => { await waitForVueUpdate() - expect(state().size).toBe(2) - expect(state().get(`3`)).toMatchObject({ + expect(LiveQuery.state.size).toBe(2) + expect(LiveQuery.state.get(`3`)).toMatchObject({ id: `3`, name: `John Smith`, }) - expect(state().get(`4`)).toMatchObject({ + expect(LiveQuery.state.get(`4`)).toMatchObject({ id: `4`, name: `Kyle Doe`, }) - expect(data().length).toBe(2) - expect(data()).toEqual( + expect(LiveQuery.data.length).toBe(2) + expect(LiveQuery.data).toEqual( expect.arrayContaining([ expect.objectContaining({ id: `3`, @@ -226,14 +226,14 @@ describe(`Query Collections`, () => { await waitForVueUpdate() - expect(state().size).toBe(2) - expect(state().get(`4`)).toMatchObject({ + expect(LiveQuery.state.size).toBe(2) + expect(LiveQuery.state.get(`4`)).toMatchObject({ id: `4`, name: `Kyle Doe 2`, }) - expect(data().length).toBe(2) - expect(data()).toEqual( + expect(LiveQuery.data.length).toBe(2) + expect(LiveQuery.data).toEqual( expect.arrayContaining([ expect.objectContaining({ id: `3`, @@ -263,11 +263,11 @@ describe(`Query Collections`, () => { await waitForVueUpdate() - expect(state().size).toBe(1) - expect(state().get(`4`)).toBeUndefined() + expect(LiveQuery.state.size).toBe(1) + expect(LiveQuery.state.get(`4`)).toBeUndefined() - expect(data().length).toBe(1) - expect(data()[0]).toMatchObject({ + expect(LiveQuery.data.length).toBe(1) + expect(LiveQuery.data[0]).toMatchObject({ id: `3`, name: `John Smith`, }) @@ -292,7 +292,7 @@ describe(`Query Collections`, () => { }) ) - const { state } = useLiveQuery((q) => + const LiveQuery = useLiveQuery((q) => q .from({ issues: issueCollection }) .join({ persons: personCollection }, ({ issues, persons }) => @@ -309,21 +309,21 @@ describe(`Query Collections`, () => { await waitForVueUpdate() // Verify that we have the expected joined results - expect(state().size).toBe(3) + expect(LiveQuery.state.size).toBe(3) - expect(state().get(`[1,1]`)).toMatchObject({ + expect(LiveQuery.state.get(`[1,1]`)).toMatchObject({ id: `1`, name: `John Doe`, title: `Issue 1`, }) - expect(state().get(`[2,2]`)).toMatchObject({ + expect(LiveQuery.state.get(`[2,2]`)).toMatchObject({ id: `2`, name: `Jane Doe`, title: `Issue 2`, }) - expect(state().get(`[3,1]`)).toMatchObject({ + expect(LiveQuery.state.get(`[3,1]`)).toMatchObject({ id: `3`, name: `John Doe`, title: `Issue 3`, @@ -344,8 +344,8 @@ describe(`Query Collections`, () => { await waitForVueUpdate() - expect(state().size).toBe(4) - expect(state().get(`[4,2]`)).toMatchObject({ + expect(LiveQuery.state.size).toBe(4) + expect(LiveQuery.state.get(`[4,2]`)).toMatchObject({ id: `4`, name: `Jane Doe`, title: `Issue 4`, @@ -367,7 +367,7 @@ describe(`Query Collections`, () => { await waitForVueUpdate() // The updated title should be reflected in the joined results - expect(state().get(`[2,2]`)).toMatchObject({ + expect(LiveQuery.state.get(`[2,2]`)).toMatchObject({ id: `2`, name: `Jane Doe`, title: `Updated Issue 2`, @@ -389,8 +389,8 @@ describe(`Query Collections`, () => { await waitForVueUpdate() // After deletion, issue 3 should no longer have a joined result - expect(state().get(`[3,1]`)).toBeUndefined() - expect(state().size).toBe(3) + expect(LiveQuery.state.get(`[3,1]`)).toBeUndefined() + expect(LiveQuery.state.size).toBe(3) }) it(`should recompile query when parameters change and change results`, async () => { @@ -404,7 +404,7 @@ describe(`Query Collections`, () => { const minAge = ref(30) - const { state } = useLiveQuery((q) => + const LiveQuery = useLiveQuery((q) => q .from({ collection }) .where(({ collection: c }) => gt(c.age, minAge.value)) @@ -419,8 +419,8 @@ describe(`Query Collections`, () => { await waitForVueUpdate() // Initially should return only people older than 30 - expect(state().size).toBe(1) - expect(state().get(`3`)).toMatchObject({ + expect(LiveQuery.state.size).toBe(1) + expect(LiveQuery.state.get(`3`)).toMatchObject({ id: `3`, name: `John Smith`, age: 35, @@ -432,18 +432,18 @@ describe(`Query Collections`, () => { await waitForVueUpdate() // Now should return all people as they're all older than 20 - expect(state().size).toBe(3) - expect(state().get(`1`)).toMatchObject({ + expect(LiveQuery.state.size).toBe(3) + expect(LiveQuery.state.get(`1`)).toMatchObject({ id: `1`, name: `John Doe`, age: 30, }) - expect(state().get(`2`)).toMatchObject({ + expect(LiveQuery.state.get(`2`)).toMatchObject({ id: `2`, name: `Jane Doe`, age: 25, }) - expect(state().get(`3`)).toMatchObject({ + expect(LiveQuery.state.get(`3`)).toMatchObject({ id: `3`, name: `John Smith`, age: 35, @@ -455,7 +455,7 @@ describe(`Query Collections`, () => { await waitForVueUpdate() // Should now be empty - expect(state().size).toBe(0) + expect(LiveQuery.state.size).toBe(0) }) it(`should be able to query a result collection with live updates`, async () => { @@ -468,26 +468,25 @@ describe(`Query Collections`, () => { ) // Initial query - const { state: _initialState, collection: initialCollection } = - useLiveQuery((q) => - q - .from({ collection }) - .where(({ collection: c }) => gt(c.age, 30)) - .select(({ collection: c }) => ({ - id: c.id, - name: c.name, - team: c.team, - })) - .orderBy(({ collection: c }) => c.id, `asc`) - ) + const InitialLiveQuery = useLiveQuery((q) => + q + .from({ collection }) + .where(({ collection: c }) => gt(c.age, 30)) + .select(({ collection: c }) => ({ + id: c.id, + name: c.name, + team: c.team, + })) + .orderBy(({ collection: c }) => c.id, `asc`) + ) // Wait for collection to sync await waitForVueUpdate() // Grouped query derived from initial query - const { state: groupedState } = useLiveQuery((q) => + const LiveQuery = useLiveQuery((q) => q - .from({ queryResult: initialCollection() }) + .from({ queryResult: InitialLiveQuery.collection }) .groupBy(({ queryResult }) => queryResult.team) .select(({ queryResult }) => ({ team: queryResult.team, @@ -499,8 +498,8 @@ describe(`Query Collections`, () => { await waitForVueUpdate() // Verify initial grouped results - expect(groupedState().size).toBe(1) - const teamResult = Array.from(groupedState().values())[0] + expect(LiveQuery.state.size).toBe(1) + const teamResult = Array.from(LiveQuery.state.values())[0] expect(teamResult).toMatchObject({ team: `team1`, count: 1, @@ -535,9 +534,9 @@ describe(`Query Collections`, () => { await waitForVueUpdate() // Verify the grouped results include the new team members - expect(groupedState().size).toBe(2) + expect(LiveQuery.state.size).toBe(2) - const groupedResults = Array.from(groupedState().values()) + const groupedResults = Array.from(LiveQuery.state.values()) const team1Result = groupedResults.find((r) => r.team === `team1`) const team2Result = groupedResults.find((r) => r.team === `team2`) @@ -592,14 +591,14 @@ describe(`Query Collections`, () => { })) ) - const { state } = queryResult + const LiveQuery = queryResult // Track each state change like React does with useEffect watchEffect(() => { renderStates.push({ - stateSize: state().size, - hasTempKey: state().has(`[temp-key,1]`), - hasPermKey: state().has(`[4,1]`), + stateSize: LiveQuery.state.size, + hasTempKey: LiveQuery.state.has(`[temp-key,1]`), + hasPermKey: LiveQuery.state.has(`[4,1]`), timestamp: Date.now(), }) }) @@ -607,7 +606,7 @@ describe(`Query Collections`, () => { // Wait for collections to sync and verify initial state await waitForVueUpdate() - expect(state().size).toBe(3) + expect(LiveQuery.state.size).toBe(3) // Reset render states array for clarity in the remaining test renderStates.length = 0 @@ -670,13 +669,13 @@ describe(`Query Collections`, () => { await nextTick() // Verify optimistic state is immediately reflected (should be synchronous) - expect(state().size).toBe(4) - expect(state().get(`[temp-key,1]`)).toMatchObject({ + expect(LiveQuery.state.size).toBe(4) + expect(LiveQuery.state.get(`[temp-key,1]`)).toMatchObject({ id: `temp-key`, name: `John Doe`, title: `New Issue`, }) - expect(state().get(`[4,1]`)).toBeUndefined() + expect(LiveQuery.state.get(`[4,1]`)).toBeUndefined() // Wait for the transaction to be committed await transaction.isPersisted.promise @@ -684,9 +683,9 @@ describe(`Query Collections`, () => { await waitForVueUpdate() // Verify the temporary key is replaced by the permanent one - expect(state().size).toBe(4) - expect(state().get(`[temp-key,1]`)).toBeUndefined() - expect(state().get(`[4,1]`)).toMatchObject({ + expect(LiveQuery.state.size).toBe(4) + expect(LiveQuery.state.get(`[temp-key,1]`)).toBeUndefined() + expect(LiveQuery.state.get(`[4,1]`)).toMatchObject({ id: `4`, name: `John Doe`, title: `New Issue`, @@ -716,19 +715,15 @@ describe(`Query Collections`, () => { startSync: true, }) - const { - state, - data, - collection: returnedCollection, - } = useLiveQuery(liveQueryCollection) + const LiveQuery = useLiveQuery(liveQueryCollection) // Wait for collection to sync and state to update await waitForVueUpdate() - expect(state().size).toBe(1) // Only John Smith (age 35) - expect(data()).toHaveLength(1) + expect(LiveQuery.state.size).toBe(1) // Only John Smith (age 35) + expect(LiveQuery.data).toHaveLength(1) - const johnSmith = data()[0] + const johnSmith = LiveQuery.data[0] expect(johnSmith).toMatchObject({ id: `3`, name: `John Smith`, @@ -736,7 +731,7 @@ describe(`Query Collections`, () => { }) // Verify that the returned collection is the same instance - expect(returnedCollection()).toBe(liveQueryCollection) + expect(LiveQuery.collection).toBe(liveQueryCollection) }) it(`should switch to a different pre-created live query collection when reactive ref changes`, async () => { @@ -800,18 +795,17 @@ describe(`Query Collections`, () => { // Use a reactive ref that can change - this is the proper Vue pattern const currentCollection = ref(liveQueryCollection1 as any) - const { state, collection: returnedCollection } = - useLiveQuery(currentCollection) + const LiveQuery = useLiveQuery(currentCollection) // Wait for first collection to sync await waitForVueUpdate() - expect(state().size).toBe(1) // Only John Smith from collection1 - expect(state().get(`3`)).toMatchObject({ + expect(LiveQuery.state.size).toBe(1) // Only John Smith from collection1 + expect(LiveQuery.state.get(`3`)).toMatchObject({ id: `3`, name: `John Smith`, }) - expect(returnedCollection().id).toBe(liveQueryCollection1.id) + expect(LiveQuery.collection.id).toBe(liveQueryCollection1.id) // Switch to the second collection by updating the reactive ref currentCollection.value = liveQueryCollection2 as any @@ -819,19 +813,19 @@ describe(`Query Collections`, () => { // Wait for the reactive change to propagate await waitForVueUpdate() - expect(state().size).toBe(2) // Alice and Bob from collection2 - expect(state().get(`4`)).toMatchObject({ + expect(LiveQuery.state.size).toBe(2) // Alice and Bob from collection2 + expect(LiveQuery.state.get(`4`)).toMatchObject({ id: `4`, name: `Alice Cooper`, }) - expect(state().get(`5`)).toMatchObject({ + expect(LiveQuery.state.get(`5`)).toMatchObject({ id: `5`, name: `Bob Dylan`, }) - expect(returnedCollection().id).toBe(liveQueryCollection2.id) + expect(LiveQuery.collection.id).toBe(liveQueryCollection2.id) // Verify we no longer have data from the first collection - expect(state().get(`3`)).toBeUndefined() + expect(LiveQuery.state.get(`3`)).toBeUndefined() }) describe(`isReady property`, () => { @@ -856,7 +850,7 @@ describe(`Query Collections`, () => { onDelete: () => Promise.resolve(), }) - const { isReady } = useLiveQuery((q) => + const LiveQuery = useLiveQuery((q) => q .from({ persons: collection }) .where(({ persons }) => gt(persons.age, 30)) @@ -867,7 +861,7 @@ describe(`Query Collections`, () => { ) // Initially isReady should be false (collection is in idle state) - expect(isReady.value).toBe(false) + expect(LiveQuery.isReady).toBe(false) // Start sync manually collection.preload() @@ -888,7 +882,7 @@ describe(`Query Collections`, () => { team: `team1`, }) - await waitFor(() => expect(isReady.value).toBe(true)) + await waitFor(() => expect(LiveQuery.isReady).toBe(true)) }) it(`should be true for pre-created collections that are already syncing`, async () => { @@ -914,8 +908,8 @@ describe(`Query Collections`, () => { }) await waitForVueUpdate() - const { isReady } = useLiveQuery(liveQueryCollection) - expect(isReady.value).toBe(true) + const LiveQuery = useLiveQuery(liveQueryCollection) + expect(LiveQuery.isReady).toBe(true) }) it(`should be false for pre-created collections that are not syncing`, () => { @@ -946,8 +940,8 @@ describe(`Query Collections`, () => { startSync: false, // Not syncing }) - const { isReady } = useLiveQuery(liveQueryCollection) - expect(isReady.value).toBe(false) + const LiveQuery = useLiveQuery(liveQueryCollection) + expect(LiveQuery.isReady).toBe(false) }) it(`should update isReady when collection status changes`, async () => { @@ -970,7 +964,7 @@ describe(`Query Collections`, () => { onDelete: () => Promise.resolve(), }) - const { isReady } = useLiveQuery((q) => + const LiveQuery = useLiveQuery((q) => q .from({ persons: collection }) .where(({ persons }) => gt(persons.age, 30)) @@ -980,7 +974,7 @@ describe(`Query Collections`, () => { })) ) - expect(isReady.value).toBe(false) + expect(LiveQuery.isReady).toBe(false) collection.preload() if (beginFn && commitFn) { beginFn() @@ -994,7 +988,7 @@ describe(`Query Collections`, () => { isActive: true, team: `team1`, }) - await waitFor(() => expect(isReady.value).toBe(true)) + await waitFor(() => expect(LiveQuery.isReady).toBe(true)) }) it(`should maintain isReady state during live updates`, async () => { @@ -1006,7 +1000,7 @@ describe(`Query Collections`, () => { }) ) - const { isReady } = useLiveQuery((q) => + const LiveQuery = useLiveQuery((q) => q .from({ persons: collection }) .where(({ persons }) => gt(persons.age, 30)) @@ -1017,7 +1011,7 @@ describe(`Query Collections`, () => { ) await waitForVueUpdate() - const initialIsReady = isReady.value + const initialIsReady = LiveQuery.isReady collection.utils.begin() collection.utils.write({ type: `insert`, @@ -1032,8 +1026,8 @@ describe(`Query Collections`, () => { }) collection.utils.commit() await waitForVueUpdate() - expect(isReady.value).toBe(true) - expect(isReady.value).toBe(initialIsReady) + expect(LiveQuery.isReady).toBe(true) + expect(LiveQuery.isReady).toBe(initialIsReady) }) it(`should handle isReady with complex queries including joins`, async () => { @@ -1074,7 +1068,7 @@ describe(`Query Collections`, () => { onDelete: () => Promise.resolve(), }) - const { isReady } = useLiveQuery((q) => + const LiveQuery = useLiveQuery((q) => q .from({ issues: issueCollection }) .join({ persons: personCollection }, ({ issues, persons }) => @@ -1087,7 +1081,7 @@ describe(`Query Collections`, () => { })) ) - expect(isReady.value).toBe(false) + expect(LiveQuery.isReady).toBe(false) personCollection.preload() issueCollection.preload() if (personBeginFn && personCommitFn) { @@ -1112,7 +1106,7 @@ describe(`Query Collections`, () => { description: `Issue 1 description`, userId: `1`, }) - await waitFor(() => expect(isReady.value).toBe(true)) + await waitFor(() => expect(LiveQuery.isReady).toBe(true)) }) it(`should handle isReady with parameterized queries`, async () => { @@ -1136,7 +1130,7 @@ describe(`Query Collections`, () => { }) const minAge = ref(30) - const { isReady } = useLiveQuery((q) => + const LiveQuery = useLiveQuery((q) => q .from({ collection }) .where(({ collection: c }) => gt(c.age, minAge.value)) @@ -1146,7 +1140,7 @@ describe(`Query Collections`, () => { })) ) - expect(isReady.value).toBe(false) + expect(LiveQuery.isReady).toBe(false) collection.preload() if (beginFn && commitFn) { beginFn() @@ -1168,9 +1162,9 @@ describe(`Query Collections`, () => { isActive: true, team: `team2`, }) - await waitFor(() => expect(isReady.value).toBe(true)) + await waitFor(() => expect(LiveQuery.isReady).toBe(true)) minAge.value = 25 - await waitFor(() => expect(isReady.value).toBe(true)) + await waitFor(() => expect(LiveQuery.isReady).toBe(true)) }) }) @@ -1194,17 +1188,17 @@ describe(`Query Collections`, () => { age: persons.age, })) - const { state, data } = useLiveQuery({ + const LiveQuery = useLiveQuery({ query: queryBuilder, }) // Wait for collection to sync and state to update await waitForVueUpdate() - expect(state().size).toBe(1) // Only John Smith (age 35) - expect(data()).toHaveLength(1) + expect(LiveQuery.state.size).toBe(1) // Only John Smith (age 35) + expect(LiveQuery.data).toHaveLength(1) - const johnSmith = data()[0] + const johnSmith = LiveQuery.data[0] expect(johnSmith).toMatchObject({ id: `3`, name: `John Smith`, From 9a6b07c193fbc00c32e8caef303194b3329a6149 Mon Sep 17 00:00:00 2001 From: teleskop150750 Date: Wed, 23 Jul 2025 18:15:41 +0300 Subject: [PATCH 6/6] refactor cleanup --- packages/vue-db/src/useLiveQuery.ts | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/packages/vue-db/src/useLiveQuery.ts b/packages/vue-db/src/useLiveQuery.ts index 51106756..4fbe099e 100644 --- a/packages/vue-db/src/useLiveQuery.ts +++ b/packages/vue-db/src/useLiveQuery.ts @@ -20,6 +20,8 @@ import type { } from "@tanstack/db" import type { MaybeRefOrGetter } from "vue" +const NOOP = () => {} + /** * Return type for useLiveQuery hook * @property state - Reactive Map of query results (key → item) @@ -244,16 +246,9 @@ export function useLiveQuery( } // Track current unsubscribe function - let unsub: (() => void) | null = null - const clean = () => { - if (unsub) { - unsub() - unsub = null - } - } - + let cleanup: () => void = NOOP watchEffect(() => { - clean() + cleanup() const collectionVal = collection.value @@ -267,7 +262,7 @@ export function useLiveQuery( syncDataFromCollection(collectionVal) // Subscribe to collection changes with granular updates - unsub = collectionVal.subscribeChanges( + cleanup = collectionVal.subscribeChanges( (changes: Array>) => { // Apply each change individually to the reactive state for (const change of changes) { @@ -297,7 +292,7 @@ export function useLiveQuery( // Cleanup if (getCurrentScope()) { - onScopeDispose(clean) + onScopeDispose(cleanup) } const isLoading = computed(