-
-
Notifications
You must be signed in to change notification settings - Fork 5
feat: support Synced Queries #119
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
f6fa84a to
e5137fc
Compare
arv
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM
|
@danielroe good to merge? |
|
apologies for the delay. I want to review with an eye to how we would implement in nuxt (where provide/inject is often not the best pattern). ideally something that would support lazy creation of the stateful singleton (ie on first access) |
|
I'm doing this. A global variable and checking if it exist on each access. I watch the user session but most of the time changing the value comes with navigation/reload so not sure how necessary it is. import { Zero } from "@rocicorp/zero";
import { schema, clientMutators } from "~~/zero";
import type { AuthData } from "~~/zero";
import { decodeJwt } from "jose";
let client: Zero<typeof schema, ReturnType<typeof clientMutators>>;
export function useZero() {
const { session } = useUserSession();
const config = useRuntimeConfig().public.zero;
const decodedJWT = computed<AuthData | undefined>(() => session.value?.token ? decodeJwt<AuthData>(session.value.token) : undefined);
const userID = computed(() => decodedJWT.value?.sub ?? 'anon');
watch([userID], () => {
if (client && client.userID === userID.value)
return;
if (client && !client.closed) {
client.close();
}
client = new Zero({
userID: userID.value,
auth: () => session.value?.token,
mutators: clientMutators(decodedJWT.value),
server: import.meta.client ? config.server : undefined,
schema,
});
}, { immediate: true });
return client;
} |
|
@danielroe would doing something along the lines of what Pinia does: export let activeZero: Zero<any, any> | undefined
export function setActiveZero(zero: Zero<any, any>) {
activeZero = zero
}
export function useZero() {
return ((hasInjectionContext() && inject(zeroSymbol)) || shallowRef(activeZero))
}in combination with the plugin proposed in this PR and a to-be-created Nuxt module be a step in the right direction? While not lazy-creation, it would make sure that Zero is available during SSR. You also keep the benefit of Although I need to add that I don't have a lot of experience with Nuxt, so if you know of any libraries that have a more 'nuxt-native' implementation that would be very welcome. |
47bcfea to
ccdf981
Compare
|
@danielroe could you put in a review? |
|
All alerts resolved. Learn more about Socket for GitHub. This PR previously contained dependency changes with security issues that have been resolved, removed, or ignored. |
|
The problem I have when looking at this solution is that I cannot run multiple zero sessions side by side (something I'm doing atm). Can you think of a way that provides the instance a different way? Right now that won't work What I tried for example: export function createZero() {
let zero;
function useZero() {
// init & watch options etc...
return zero
}
function useQuery() {
// wrap the useQuery composable and pass the zero instance
}
return {
useZero,
useQuery,
}
}And then the user can create their own instance composable: export { useZero, useQuery } = createZero<Schema, Mutators>()Not sure about the UX though |
@Gerbuuun This is very interesting, seems like the way to go.
I think this would actually be better, since this PR already introduces the I'm not sure how well this can be adapted to work in Nuxt, @Gerbuuun are you able to weigh in on that as well? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This createZero function is now a composable. This means it does not work out of the vue context. z is not shared and every time you need to call createZero.
What I meant with my example is to call createZero top-level which then creates the composables useZero and useQuery. That way each time you call the imported composable useZero, you get the already existing instance.
I haven't fully worked it out from there because of reactivity problems. I'll clean up my code and share the branch
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What I meant with my example is to call createZero top-level which then creates the composables useZero and useQuery
These changes achieve exactly that right? The concept is that you call createZero once for each zero instance/session you have in your application. The useZero and useQuery composables can then be exposed to the rest of the application.
This createZero function is now a composable. This means it does not work out of the vue context.
Could you elaborate what doesn't work outside of the vue context?
|
I have created a draft PR #137 with my idea and a working playground so you can check it out @maxstevens-nl Please let me know what you think. |
@Gerbuuun left some comments on that PR. I changed this PR to add lazy init like you did in #137, and updated the playground with the correct usage. Does this PR now reflect what you had in mind? |
src/create-zero.ts
Outdated
| export function createZero<S extends Schema = Schema, MD extends CustomMutatorDefs | undefined = undefined>(optsOrZero: MaybeRefOrGetter<ZeroOptions<S, MD> | { zero: Zero<S, MD> }>) { | ||
| let z: ShallowRef<Zero<S, MD>> | ||
|
|
||
| function useZero(): ShallowRef<Zero<S, MD>> { | ||
| if (!z) { | ||
| z = shallowRef() as ShallowRef<Zero<S, MD>> | ||
| } | ||
|
|
||
| if (z.value) { | ||
| return z | ||
| } | ||
|
|
||
| watch(() => toValue(optsOrZero), (opts) => { | ||
| if (z.value && !z.value.closed) { | ||
| const cleanupZeroPromise = z.value.close() | ||
| zeroCleanups.add(cleanupZeroPromise) | ||
| cleanupZeroPromise.finally(() => { | ||
| zeroCleanups.delete(cleanupZeroPromise) | ||
| }) | ||
| } | ||
|
|
||
| z.value = 'zero' in opts ? opts.zero : new Zero(opts) | ||
| }, { deep: true, immediate: true }) | ||
|
|
||
| return z | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is my final suggestion. This is basically writing a wrapped composable that is only called on init. Also, previously watching the options directly, people could use composables inside the getter function. If they register watchers, these would be added each time the getter is called. This way we don't have that problem.
| export function createZero<S extends Schema = Schema, MD extends CustomMutatorDefs | undefined = undefined>(optsOrZero: MaybeRefOrGetter<ZeroOptions<S, MD> | { zero: Zero<S, MD> }>) { | |
| let z: ShallowRef<Zero<S, MD>> | |
| function useZero(): ShallowRef<Zero<S, MD>> { | |
| if (!z) { | |
| z = shallowRef() as ShallowRef<Zero<S, MD>> | |
| } | |
| if (z.value) { | |
| return z | |
| } | |
| watch(() => toValue(optsOrZero), (opts) => { | |
| if (z.value && !z.value.closed) { | |
| const cleanupZeroPromise = z.value.close() | |
| zeroCleanups.add(cleanupZeroPromise) | |
| cleanupZeroPromise.finally(() => { | |
| zeroCleanups.delete(cleanupZeroPromise) | |
| }) | |
| } | |
| z.value = 'zero' in opts ? opts.zero : new Zero(opts) | |
| }, { deep: true, immediate: true }) | |
| return z | |
| } | |
| export function createZero<S extends Schema, MD extends CustomMutatorDefs | undefined = undefined>(opts: () => MaybeRefOrGetter<ZeroOptions<S, MD>>) { | |
| let z: ShallowRef<Zero<S, MD>> | |
| function useZero() { | |
| if (!z) { | |
| z = shallowRef(undefined!) | |
| } | |
| if (z.value) { | |
| return z.value | |
| } | |
| const options = opts() | |
| watch(() => toValue(options), (opts) => { | |
| if (z.value && !z.value.closed) { | |
| const cleanupZeroPromise = z.value.close() | |
| zeroCleanups.add(cleanupZeroPromise) | |
| cleanupZeroPromise.finally(() => { | |
| zeroCleanups.delete(cleanupZeroPromise) | |
| }) | |
| } | |
| z.value = new Zero(opts) | |
| }, { deep: true, immediate: true }) | |
| return z.value | |
| } |
It feels a bit over engineered at this point though...
I'm also not quite sure what the cleanupPromise code is for?
Can't you just call:
z.value?.close();There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah you can just call z.value.close(). The intent was to prevent memory leaks, but it turns out it's fine to clean up like you say. I changed it back to the simpler variant.
I'm not sure about the other changes you propose here. Would say it's the responsibility of the caller to make sure they cleanup watchers? Using MaybeRefOrGetter as type for the props is a common pattern in libraries, used all over VueUse for example. I would say keeping it that way is preferable.
Regarding the props not being reactive like you mentioned in the other PR, I tried to reproduce it in a test but couldn't get it to break. Could you provide an example of a test for this that doesn't pass? The following does pass for example:
const session = ref<{ id: string | null }>({ id: null })
function useSession(id: MaybeRefOrGetter<string>) {
async function fetch() {
session.value.id = toValue(id)
}
async function clear() {
session.value.id = null
}
return {
session,
fetch,
clear,
}
}
it('works with options getter', async () => {
const { useZero } = createZero(() => {
const { session } = useSession('test-user')
const zeroOptions = {
userID: session.value?.id ?? 'anon',
server: null,
schema: testSchema,
kvStore: 'mem' as const,
}
return zeroOptions
})
const { fetch, clear } = useSession(() => 'test-user')
await fetch()
const zero = useZero()
assert(zero.value)
expect(zero.value.userID).toEqual('test-user')
await clear()
expect(zero.value.userID).toEqual('anon')
await fetch()
expect(zero.value.userID).toEqual('test-user')
})152e90c to
760ca29
Compare
|
@danielroe in my opinion this is ready now, do you want to take a look? NB I also have a PR for a |
src/create-zero.ts
Outdated
| import { shallowRef, toValue, watch } from 'vue' | ||
| import { useQueryWithZero } from './query' | ||
|
|
||
| export function createZero<S extends Schema = Schema, MD extends CustomMutatorDefs | undefined = undefined>(optsOrZero: MaybeRefOrGetter<ZeroOptions<S, MD> | { zero: Zero<S, MD> }>) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this should be something like: createSyncedZeroComposables (or a better name?) as semantically it does not create a zero instance.
Another approach would be, allowing passing a zero shallowRef as a first argument to useQuery, and then creating a 'bound' version like this:
const zero = createZero() // the same utility as `createZero` here, which lazily initialises zero on access
const useQuery = _useQuery.bind(zero)
// or
nuxtApp.provide('zero', zero)
function useQuery (opts) {
const { $zero } = useNuxtApp()
return _useQuery($zero, opts)
}
// or any number of other patternsThere was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Agreed regarding the name, will change.
I'll also export useQueryWithZero and swap the params so that zero will be first. This will allow a user to create a bound version of useQuery if they need the extra control over the Zero instance.
danielroe
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
thank you for your good work on this, both of you ❤️
and my apologies for slow reviewing 🙏
|
Huge thanks for this PR! @danielroe Is there something else blocking a release? Or could we get this released? 🙏🏼 |
|
we could indeed! |
This PR adds support for Synced Queries in a backwards compatible way. This is done by introducing a new
createZerocomposable, which manages the lifecycle of a zero instance, and returnsuseZeroanduseQuerycomposables.To maintain backwards compatibility,
useQueryis still exported but marked deprecated.