Skip to content
Closed
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions packages/vue-db/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,12 @@
],
"packageManager": "pnpm@10.5.2",
"dependencies": {
"@tanstack/db": "workspace:*",
"@tanstack/vue-store": "^0.7.0"
"@tanstack/db": "workspace:*"
},
"devDependencies": {
"@electric-sql/client": "1.0.0",
"@vitest/coverage-istanbul": "^3.0.9",
"@vitejs/plugin-vue": "^5.2.4",
"@vitest/coverage-istanbul": "^3.0.9",
"vue": "^3.5.13"
},
"exports": {
Expand Down
72 changes: 47 additions & 25 deletions packages/vue-db/src/useLiveQuery.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { computed, toValue, watch } from "vue"
import { useStore } from "@tanstack/vue-store"
import { onWatcherCleanup, shallowRef, toValue, watchEffect } from "vue"
import { compileQuery, queryBuilder } from "@tanstack/db"
import { shallow } from "./useStore"
import type {
Collection,
Context,
Expand All @@ -9,12 +9,12 @@ import type {
ResultsFromContext,
Schema,
} from "@tanstack/db"
import type { ComputedRef, MaybeRefOrGetter } from "vue"
import type { Ref } from "vue"

export interface UseLiveQueryReturn<T extends object> {
state: ComputedRef<Map<string, T>>
data: ComputedRef<Array<T>>
collection: ComputedRef<Collection<T>>
state: Readonly<Ref<Map<string, T>>>
data: Readonly<Ref<Array<T>>>
collection: Readonly<Ref<Collection<T>>>
}

export function useLiveQuery<
Expand All @@ -23,38 +23,60 @@ export function useLiveQuery<
queryFn: (
q: InitialQueryBuilder<Context<Schema>>
) => QueryBuilder<TResultContext>,
deps: Array<MaybeRefOrGetter<unknown>> = []
deps: () => Array<unknown> = () => []
): UseLiveQueryReturn<ResultsFromContext<TResultContext>> {
const compiledQuery = computed(() => {
// Just reference deps to make computed reactive to them
deps.forEach((dep) => toValue(dep))
const results = shallowRef() as Ref<
ReturnType<typeof compileQuery<TResultContext>>[`results`]
>

const state = shallowRef() as Ref<
Map<string, ResultsFromContext<TResultContext>>
>
const data = shallowRef() as Ref<Array<ResultsFromContext<TResultContext>>>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any reason on why we are not passing the types to the primitive instead?

like

const results = shallowRef<ReturnType<typeof compileQuery<TResultContext>>[`results`]>()

Copy link
Author

@teleskop150750 teleskop150750 May 15, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For

const foo = shallowRef<T>()
// foo: Ref<T | undefined>

The output will actually be Ref <T | undefined> because we didn't pass a value


watchEffect(() => {
toValue(deps)

const query = queryFn(queryBuilder())
const compiled = compileQuery(query)
compiled.start()
return compiled
})

const state = computed(() => {
return useStore(compiledQuery.value.results.derivedState).value
})
const data = computed(() => {
return useStore(compiledQuery.value.results.derivedArray).value
})
const resultsRef = compiled.results
results.value = resultsRef

const derivedState = resultsRef.derivedState
const derivedArray = resultsRef.derivedArray
let stateRef = derivedState.state
let dataRef = derivedArray.state
state.value = stateRef
data.value = dataRef

watch(compiledQuery, (newQuery, oldQuery, onInvalidate) => {
if (newQuery.state === `stopped`) {
newQuery.start()
}
const unsubDerivedState = derivedState.subscribe(() => {
const newValue = derivedState.state
if (shallow(stateRef, newValue)) return

stateRef = newValue
state.value = newValue
})

const unsubDerivedArray = derivedArray.subscribe(() => {
const newValue = derivedArray.state
if (shallow(dataRef, newValue)) return

dataRef = newValue
data.value = newValue
})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think @tanstack/store handles this already?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Vue and tanstack libraries unfortunately have a very difficult fate (((

TanStack/store#179

  1. the first reason is the reactors of the library @tanstack/store (drop vue2, refactor unsub)
  2. the current implementation of @tanstack/store uses a deep link, which makes no sense since the entire object gets replaced. Unfortunately, vue primitives are woefully behind solidjs.

https://github.com/TanStack/store/blob/cf37b85ddecdcb6f52ad930dcd53e294fb4b03a7/packages/solid-store/src/index.tsx#L31

Solid js uses reconcile for fine-grained updating. If desired, you can try writing an analogue for vue


onInvalidate(() => {
oldQuery.stop()
onWatcherCleanup(() => {
compiled.stop()
unsubDerivedState()
unsubDerivedArray()
})
})

return {
state,
data,
collection: computed(() => compiledQuery.value.results),
collection: results,
}
}
49 changes: 49 additions & 0 deletions packages/vue-db/src/useStore.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/**
* @see https://github.com/TanStack/store/blob/cf37b85ddecdcb6f52ad930dcd53e294fb4b03a7/packages/vue-store/src/index.ts#L47
*/

export function shallow<T>(objA: T, objB: T) {
if (Object.is(objA, objB)) {
return true
}

if (
typeof objA !== `object` ||
objA === null ||
typeof objB !== `object` ||
objB === null
) {
return false
}

if (objA instanceof Map && objB instanceof Map) {
if (objA.size !== objB.size) return false
for (const [k, v] of objA) {
if (!objB.has(k) || !Object.is(v, objB.get(k))) return false
}
return true
}

if (objA instanceof Set && objB instanceof Set) {
if (objA.size !== objB.size) return false
for (const v of objA) {
if (!objB.has(v)) return false
}
return true
}

const keysA = Object.keys(objA)
if (keysA.length !== Object.keys(objB).length) {
return false
}

for (const keyA of keysA) {
if (
!Object.prototype.hasOwnProperty.call(objB, keyA) ||
!Object.is(objA[keyA as keyof T], objB[keyA as keyof T])
) {
return false
}
}
return true
}
43 changes: 23 additions & 20 deletions packages/vue-db/tests/useLiveQuery.test.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import { describe, expect, it, vi } from "vitest"
import mitt from "mitt"
import { Collection, createTransaction } from "@tanstack/db"
import { computed, onUnmounted, ref, watch, watchEffect } from "vue"
import { useLiveQuery } from "../src/useLiveQuery"
import type { Ref } from "vue"
import { onWatcherCleanup, ref, watchEffect } from "vue"
import { useLiveQuery } from "../src/index"
import type {
Context,
InitialQueryBuilder,
Expand Down Expand Up @@ -411,15 +410,20 @@ describe(`Query Collections`, () => {
}))
)

await waitForChanges()

const minAge = ref(30)

const { state } = useLiveQuery((q) => {
return q
.from({ collection })
.where(`@age`, `>`, minAge.value)
.keyBy(`@id`)
.select(`@id`, `@name`, `@age`)
})
const { state } = useLiveQuery(
(q) => {
return q
.from({ collection })
.where(`@age`, `>`, minAge.value)
.keyBy(`@id`)
.select(`@id`, `@name`, `@age`)
},
() => [minAge.value]
)

// Initially should return only people older than 30
expect(state.value.size).toBe(1)
Expand Down Expand Up @@ -495,18 +499,17 @@ describe(`Query Collections`, () => {
// Add a custom hook that wraps useLiveQuery to log when queries are created and stopped
function useTrackedLiveQuery<T>(
queryFn: (q: InitialQueryBuilder<Context<Schema>>) => any,
deps: Array<Ref<unknown>>
deps: () => Array<unknown>
): T {
const result = useLiveQuery(queryFn, deps)

watch(
() => deps.map((dep) => dep.value).join(`,`),
(updatedDeps, _, fn) => {
console.log(`Creating new query with deps`, updatedDeps)
fn(() => console.log(`Stopping query with deps`, updatedDeps))
},
{ immediate: true }
)
watchEffect(() => {
const updatedDeps = deps().join(`,`)
console.log(`Creating new query with deps`, updatedDeps)
onWatcherCleanup(() =>
console.log(`Stopping query with deps`, updatedDeps)
)
})

return result as T
}
Expand All @@ -529,7 +532,7 @@ describe(`Query Collections`, () => {
.where(`@age`, `>`, minAge.value)
.keyBy(`@id`)
.select(`@id`, `@name`),
[minAge]
() => [minAge.value]
)

// Initial query should be created
Expand Down
1 change: 0 additions & 1 deletion packages/vue-db/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"paths": {
"@tanstack/store": ["../store/src"],
"@tanstack/db": ["../db/src"]
}
},
Expand Down
33 changes: 0 additions & 33 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.