From 500d024463e98db7f2198f5498e0f6805b6ddf22 Mon Sep 17 00:00:00 2001 From: Daniel Roe Date: Wed, 26 Aug 2026 22:55:31 +0000 Subject: [PATCH] feat!: scope cleanup to the effect scope, share views, and add a Vue plugin --- package.json | 1 + pnpm-lock.yaml | 56 +++++++- src/composables.test.ts | 187 ++++++++++++++++++++++++ src/connection.ts | 67 +++++++++ src/context.ts | 72 ++++++++++ src/create-zero-composables.test.ts | 124 +++++++++++++++- src/create-zero-composables.ts | 212 ++++++++++++++++++---------- src/index.ts | 18 ++- src/lifecycle.test.ts | 156 ++++++++++++++++++++ src/mutation.ts | 115 +++++++++++++++ src/plugin.test.ts | 82 +++++++++++ src/plugin.ts | 40 ++++++ src/preload.ts | 128 +++++++++++++++++ src/query-target.ts | 46 ++++++ src/query.ts | 147 +++++++++++-------- src/suspense.ts | 98 +++++++++++++ src/ttl.test.ts | 36 +++++ src/ttl.ts | 47 ++++++ src/types.ts | 25 ++++ src/view-store.test.ts | 92 ++++++++++++ src/view-store.ts | 110 +++++++++++++++ test/fixture.ts | 63 +++++++++ test/fixtures/vue/src/app.vue | 3 +- test/fixtures/vue/src/main.ts | 5 +- test/fixtures/vue/src/zero.ts | 4 +- 25 files changed, 1791 insertions(+), 143 deletions(-) create mode 100644 src/composables.test.ts create mode 100644 src/connection.ts create mode 100644 src/context.ts create mode 100644 src/lifecycle.test.ts create mode 100644 src/mutation.ts create mode 100644 src/plugin.test.ts create mode 100644 src/plugin.ts create mode 100644 src/preload.ts create mode 100644 src/query-target.ts create mode 100644 src/suspense.ts create mode 100644 src/ttl.test.ts create mode 100644 src/ttl.ts create mode 100644 src/types.ts create mode 100644 src/view-store.test.ts create mode 100644 src/view-store.ts create mode 100644 test/fixture.ts diff --git a/package.json b/package.json index 03ebf0d..dc3743a 100644 --- a/package.json +++ b/package.json @@ -71,6 +71,7 @@ "@types/pg": "8.23.1", "@vitest/coverage-v8": "4.1.11", "eslint": "10.9.1", + "happy-dom": "20.10.6", "installed-check": "11.0.0", "knip": "6.32.2", "nano-staged": "1.0.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 419927a..f680e4f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: eslint: specifier: 10.9.1 version: 10.9.1(jiti@2.7.0)(supports-color@10.2.2) + happy-dom: + specifier: 20.10.6 + version: 20.10.6 installed-check: specifier: 11.0.0 version: 11.0.0 @@ -64,7 +67,7 @@ importers: version: 6.0.3 vitest: specifier: 4.1.11 - version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.11.0)(@vitest/coverage-v8@4.1.11)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.11.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0)) + version: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.11.0)(@vitest/coverage-v8@4.1.11)(happy-dom@20.10.6)(vite@8.2.1(@types/node@24.11.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0)) vue: specifier: 3.5.41 version: 3.5.41(typescript@6.0.3) @@ -4322,6 +4325,10 @@ packages: h3@1.15.11: resolution: {integrity: sha512-L3THSe2MPeBwgIZVSH5zLdBBU90TOxarvhK9d04IDY2AmVS8j2Jz2LIWtwsGOU3lu2I5jCN7FNvVfY2+XyF+mg==} + happy-dom@20.10.6: + resolution: {integrity: sha512-6QD0ilzDDt93tX44y8tbmZdAcdTRYDhUP+Asgi6pC8Pp5IA3cvaZGyoVN/EGtlq9ziT65iPuBBn3ASLr6hCgVw==} + engines: {node: '>=20.0.0'} + happy-dom@20.11.2: resolution: {integrity: sha512-7MB+bJLkxu3SowAfBJbjW+c55kNz5tkR45gu2qzrxznezhLeN5YIlJbwUgSzlGc+qWoZ8Ykg71H5ezz69xixrw==} engines: {node: '>=20.0.0'} @@ -9892,7 +9899,7 @@ snapshots: obug: 2.1.4 std-env: 4.2.0 tinyrainbow: 3.1.1 - vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.11.0)(@vitest/coverage-v8@4.1.11)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.11.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0)) + vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.11.0)(@vitest/coverage-v8@4.1.11)(happy-dom@20.10.6)(vite@8.2.1(@types/node@24.11.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0)) '@vitest/eslint-plugin@1.6.27(@typescript-eslint/eslint-plugin@8.67.0(@typescript-eslint/parser@8.67.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3)(vitest@4.1.11)': dependencies: @@ -9902,7 +9909,7 @@ snapshots: optionalDependencies: '@typescript-eslint/eslint-plugin': 8.67.0(@typescript-eslint/parser@8.67.0(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3))(eslint@10.9.1(jiti@2.7.0)(supports-color@10.2.2))(supports-color@10.2.2)(typescript@6.0.3) typescript: 6.0.3 - vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.11.0)(@vitest/coverage-v8@4.1.11)(happy-dom@20.11.2)(vite@8.2.1(@types/node@24.11.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0)) + vitest: 4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.11.0)(@vitest/coverage-v8@4.1.11)(happy-dom@20.10.6)(vite@8.2.1(@types/node@24.11.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0)) transitivePeerDependencies: - supports-color @@ -11585,6 +11592,19 @@ snapshots: ufo: 1.6.4 uncrypto: 0.1.3 + happy-dom@20.10.6: + dependencies: + '@types/node': 24.11.0 + '@types/whatwg-mimetype': 3.0.2 + '@types/ws': 8.18.1 + buffer-image-size: 0.6.4 + entities: 7.0.1 + whatwg-mimetype: 3.0.0 + ws: 8.21.3 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + happy-dom@20.11.2: dependencies: '@types/node': 24.11.0 @@ -14457,6 +14477,36 @@ snapshots: - vitest - webpack + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.11.0)(@vitest/coverage-v8@4.1.11)(happy-dom@20.10.6)(vite@8.2.1(@types/node@24.11.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0)): + dependencies: + '@vitest/expect': 4.1.11 + '@vitest/mocker': 4.1.11(vite@8.2.1(@types/node@24.11.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0)) + '@vitest/pretty-format': 4.1.11 + '@vitest/runner': 4.1.11 + '@vitest/snapshot': 4.1.11 + '@vitest/spy': 4.1.11 + '@vitest/utils': 4.1.11 + es-module-lexer: 2.3.2 + expect-type: 1.4.0 + magic-string: 0.30.21 + obug: 2.1.4 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 4.2.0 + tinybench: 2.9.0 + tinyexec: 1.3.0 + tinyglobby: 0.2.17 + tinyrainbow: 3.1.1 + vite: 8.2.1(@types/node@24.11.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0) + why-is-node-running: 2.3.0 + optionalDependencies: + '@opentelemetry/api': 1.9.1 + '@types/node': 24.11.0 + '@vitest/coverage-v8': 4.1.11(vitest@4.1.11) + happy-dom: 20.10.6 + transitivePeerDependencies: + - msw + vitest@4.1.11(@opentelemetry/api@1.9.1)(@types/node@24.11.0)(@vitest/coverage-v8@4.1.11)(happy-dom@20.11.2)(vite@8.1.3(@types/node@24.11.0)(esbuild@0.28.2)(jiti@2.7.0)(terser@5.50.0)(yaml@2.9.0)): dependencies: '@vitest/expect': 4.1.11 diff --git a/src/composables.test.ts b/src/composables.test.ts new file mode 100644 index 0000000..79cec22 --- /dev/null +++ b/src/composables.test.ts @@ -0,0 +1,187 @@ +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import { effectScope, nextTick, ref } from 'vue' +import { createTestZero, mutators, queries } from '../test/fixture' +import { useConnection } from './connection' +import { useMutation } from './mutation' +import { usePreload } from './preload' +import { useSuspenseQuery } from './suspense' + +function withZero() { + const zero = createTestZero() + onTestFinished(async () => { + await zero.close() + }) + return zero +} + +describe('usePreload', () => { + it('should preload the query and clean up when the scope is disposed', () => { + const zero = withZero() + const cleanup = vi.fn() + const preload = vi.spyOn(zero, 'preload').mockReturnValue({ cleanup, complete: Promise.resolve() }) + + const scope = effectScope() + scope.run(() => { + usePreload(zero, () => queries.table(), { ttl: '1m' }) + }) + + expect(preload).toHaveBeenCalledExactlyOnceWith(expect.any(Object), { ttl: '1m' }) + + scope.stop() + expect(cleanup).toHaveBeenCalledTimes(1) + }) + + it('should resolve `complete` when the preload has synced', async () => { + const zero = withZero() + vi.spyOn(zero, 'preload').mockReturnValue({ cleanup: () => {}, complete: Promise.resolve() }) + + const scope = effectScope() + const result = scope.run(() => usePreload(zero, () => queries.table()))! + + expect(result.complete.value).toBe(false) + await nextTick() + expect(result.complete.value).toBe(true) + + scope.stop() + }) + + it('should not preload while disabled', async () => { + const zero = withZero() + const cleanup = vi.fn() + const preload = vi.spyOn(zero, 'preload').mockReturnValue({ cleanup, complete: Promise.resolve() }) + const enabled = ref(false) + + const scope = effectScope() + scope.run(() => { + usePreload(zero, () => queries.table(), () => ({ enabled: enabled.value })) + }) + + expect(preload).not.toHaveBeenCalled() + + enabled.value = true + await nextTick() + + expect(preload).toHaveBeenCalledTimes(1) + + enabled.value = false + await nextTick() + + expect(cleanup).toHaveBeenCalledTimes(1) + scope.stop() + }) +}) + +describe('useMutation', () => { + it('should track pending state and apply the mutation', async () => { + const zero = withZero() + const { mutate, pending, inFlight } = useMutation(zero, mutators.table.insert) + + expect(pending.value).toBe(false) + + const result = mutate({ a: 1, b: 'one' }) + expect(pending.value).toBe(true) + expect(inFlight.value).toBe(1) + + const details = await result + + expect(details.type).toBe('success') + expect(pending.value).toBe(false) + expect(await zero.run(queries.table())).toMatchObject([{ a: 1, b: 'one' }]) + }) + + it('should surface errors without throwing, and reset them', async () => { + const zero = withZero() + vi.spyOn(zero, 'mutate').mockImplementation(() => { + throw new Error('nope') + }) + + const { mutate, error, pending, reset } = useMutation(zero, mutators.table.insert) + const details = await mutate({ a: 1, b: 'one' }) + + expect(details).toEqual({ type: 'error', error: { type: 'zero', message: 'nope' } }) + expect(error.value).toEqual({ type: 'zero', message: 'nope' }) + expect(pending.value).toBe(false) + + reset() + expect(error.value).toBeUndefined() + }) + + it('should reject anything that is not a mutator', () => { + const zero = withZero() + // @ts-expect-error not a mutator + expect(() => useMutation(zero, { nope: true })).toThrow(/expects a mutator/) + }) +}) + +describe('useSuspenseQuery', () => { + it('should resolve once there are local rows', async () => { + const zero = withZero() + await zero.mutate(mutators.table.insert({ a: 1, b: 'one' })).client + + const scope = effectScope() + const result = await scope.run(() => useSuspenseQuery(zero, () => queries.table()))! + + expect(result.data.value).toMatchObject([{ a: 1, b: 'one' }]) + scope.stop() + }) + + it('should wait for the query to settle when there are no local rows', async () => { + const zero = withZero() + const scope = effectScope() + + let resolved = false + const promise = scope.run(() => useSuspenseQuery(zero, () => queries.byId(42)))! + .then((result) => { + resolved = true + return result + }) + + await nextTick() + expect(resolved).toBe(false) + + await zero.mutate(mutators.table.insert({ a: 42, b: 'answer' })).client + const result = await promise + + expect(resolved).toBe(true) + expect(result.data.value).toMatchObject([{ a: 42, b: 'answer' }]) + scope.stop() + }) +}) + +describe('useConnection', () => { + it('should expose derived connection helpers', () => { + const zero = withZero() + const scope = effectScope() + const connection = scope.run(() => useConnection(zero))! + + expect(connection.state.value).toBeDefined() + expect(typeof connection.online.value).toBe('boolean') + expect(connection.needsAuth.value).toBe(false) + + scope.stop() + }) + + it('should reconnect with a new token without recreating the instance', async () => { + const zero = withZero() + const connect = vi.spyOn(zero.connection, 'connect').mockResolvedValue() + const scope = effectScope() + const connection = scope.run(() => useConnection(zero))! + + await connection.reconnect('token-2') + expect(connect).toHaveBeenCalledExactlyOnceWith({ auth: 'token-2' }) + + scope.stop() + }) + + it('should unsubscribe when the scope is disposed', () => { + const zero = withZero() + const unsubscribe = vi.fn() + vi.spyOn(zero.connection.state, 'subscribe').mockReturnValue(unsubscribe) + + const scope = effectScope() + scope.run(() => useConnection(zero)) + scope.stop() + + expect(unsubscribe).toHaveBeenCalledTimes(1) + }) +}) diff --git a/src/connection.ts b/src/connection.ts new file mode 100644 index 0000000..242f2f7 --- /dev/null +++ b/src/connection.ts @@ -0,0 +1,67 @@ +import type { ConnectionState, CustomMutatorDefs, Schema } from '@rocicorp/zero' +import type { ComputedRef, DeepReadonly, Ref } from 'vue' +import type { ZeroSource } from './types' +import { computed, effectScope, onWatcherCleanup, readonly, ref, watch } from 'vue' +import { readZero, resolveZeroArgs } from './context' + +export interface ZeroConnection { + /** The raw connection state, as reported by Zero. */ + state: DeepReadonly> + /** Whether Zero currently has a connection to zero-cache. */ + online: ComputedRef + /** + * Whether Zero has stopped retrying because the auth token was rejected. + * Call {@linkcode ZeroConnection.reconnect} with a fresh token to resume. + */ + needsAuth: ComputedRef + /** + * Resume connecting, optionally with a new auth token. + * + * Unlike changing `auth` in the options passed to `createZeroComposables`, + * this refreshes the token in place and does not drop the local replica. + */ + reconnect: (auth?: string) => Promise +} + +/** + * Tracks the connection state of a Zero instance, with helpers for the + * "token expired, sign in again" flow. + */ +export function useConnection< + TSchema extends Schema, + MD extends CustomMutatorDefs | undefined, + TContext, +>(zero?: ZeroSource): ZeroConnection { + const { zero: source } = resolveZeroArgs(zero === undefined ? [] : [zero]) + + const state = ref() as Ref + const scope = effectScope() + + scope.run(() => { + watch(() => readZero(source), (instance) => { + if (!instance) { + return + } + state.value = instance.connection.state.current + onWatcherCleanup(instance.connection.state.subscribe((next) => { + state.value = next + })) + }, { immediate: true }) + }) + + return { + state: readonly(state), + online: computed(() => state.value?.name === 'connected'), + needsAuth: computed(() => state.value?.name === 'needs-auth'), + reconnect: auth => readZero(source).connection.connect(auth === undefined ? undefined : { auth }), + } +} + +/** The connection state of a Zero instance. */ +export function useConnectionState< + TSchema extends Schema, + MD extends CustomMutatorDefs | undefined, + TContext, +>(zero?: ZeroSource): DeepReadonly> { + return useConnection(zero).state +} diff --git a/src/context.ts b/src/context.ts new file mode 100644 index 0000000..5245204 --- /dev/null +++ b/src/context.ts @@ -0,0 +1,72 @@ +import type { CustomMutatorDefs, DefaultContext, DefaultSchema, Schema, Zero } from '@rocicorp/zero' +import type { InjectionKey, Ref, ShallowRef } from 'vue' +import type { AnyZeroComposables } from './create-zero-composables' +import type { AnyZero } from './types' +import { hasInjectionContext, inject, isRef } from 'vue' +import { isZeroLike } from './types' + +/** + * Injection key under which {@linkcode createZeroPlugin} provides its + * composables. Exported so that advanced setups can provide their own bundle + * (for example one per route, or one per test). + */ +export const zeroInjectionKey: InjectionKey = Symbol.for('zero-vue') + +/** + * The composables provided by {@linkcode createZeroPlugin}, or `undefined` when + * there is no injection context or no plugin installed. + */ +export function injectZeroComposables(): AnyZeroComposables | undefined { + return hasInjectionContext() ? inject(zeroInjectionKey, undefined) : undefined +} + +/** + * The Zero instance provided by {@linkcode createZeroPlugin}, as a ref (it is + * replaced when the options change). + * + * Type it globally by augmenting Zero's `DefaultTypes`, or reach for the + * `useZero` returned by `createZeroPlugin`/`createZeroComposables`, which is + * typed from the options you passed. + */ +export function useZero< + TSchema extends Schema = DefaultSchema, + MD extends CustomMutatorDefs | undefined = undefined, + TContext = DefaultContext, +>(): ShallowRef> { + return requireInjectedZero() as ShallowRef> +} + +const NO_ZERO = '[zero-vue] No Zero instance available. Install the plugin with `app.use(createZeroPlugin(options))`, use the composables returned by `createZeroComposables(options)`, or pass a Zero instance (or a ref holding one) as the first argument.' + +/** + * Splits an optional leading Zero source off a composable's arguments, falling + * back to the injected instance. + * + * A getter is never treated as a Zero source, because it is indistinguishable + * from a query getter without calling it; pass a ref or the instance itself. + */ +export function resolveZeroArgs(args: unknown[]): { zero: Ref | AnyZero, rest: A } { + const [first, ...rest] = args + if (isZeroSourceArg(first)) { + return { zero: first as Ref | AnyZero, rest: rest as A } + } + return { zero: requireInjectedZero(), rest: args as A } +} + +/** Whether an argument is a Zero instance, or a ref holding one. */ +export function isZeroSourceArg(value: unknown): boolean { + return isZeroLike(value) || (isRef(value) && isZeroLike(value.value)) +} + +function requireInjectedZero(): Ref { + const composables = injectZeroComposables() + if (!composables) { + throw new Error(NO_ZERO) + } + return composables.useZero() +} + +/** Reads a Zero source inside a reactive effect. */ +export function readZero(source: Ref | AnyZero): AnyZero { + return isRef(source) ? source.value : source +} diff --git a/src/create-zero-composables.test.ts b/src/create-zero-composables.test.ts index 3966422..5d71eb4 100644 --- a/src/create-zero-composables.test.ts +++ b/src/create-zero-composables.test.ts @@ -10,7 +10,7 @@ import { table, Zero, } from '@rocicorp/zero' -import { assert, describe, expect, it } from 'vitest' +import { assert, describe, expect, it, vi } from 'vitest' import { computed, nextTick, ref } from 'vue' import z from 'zod' import { createZeroComposables } from './create-zero-composables' @@ -207,3 +207,125 @@ describe('createZeroComposables', () => { expect(zeroAccessCount).toBe(accessCountPerCreation) }) }) + +describe('createZeroComposables lifecycle', () => { + it('refreshes auth in place instead of recreating the instance', async () => { + const auth = ref('token-1') + const { useZero } = createZeroComposables(() => ({ + userID: 'test-user', + server: null, + auth: auth.value, + schema: testSchema, + kvStore: 'mem' as const, + })) + + const zero = useZero() + const instance = zero.value + const connect = vi.spyOn(instance.connection, 'connect').mockResolvedValue() + + auth.value = 'token-2' + await nextTick() + + expect(zero.value).toBe(instance) + expect(instance.closed).toBe(false) + expect(connect).toHaveBeenCalledExactlyOnceWith({ auth: 'token-2' }) + }) + + it('recreates the instance when signing out', async () => { + const auth = ref('token-1') + const { useZero } = createZeroComposables(() => ({ + userID: 'test-user', + server: null, + auth: auth.value, + schema: testSchema, + kvStore: 'mem' as const, + })) + + const zero = useZero() + const instance = zero.value + + auth.value = undefined + await nextTick() + + expect(zero.value).not.toBe(instance) + }) + + it('recreates the instance when another option changes alongside auth', async () => { + const auth = ref('token-1') + const userID = ref('test-user') + const { useZero } = createZeroComposables(() => ({ + userID: userID.value, + server: null, + auth: auth.value, + schema: testSchema, + kvStore: 'mem' as const, + })) + + const zero = useZero() + const instance = zero.value + + auth.value = 'token-2' + userID.value = 'other-user' + await nextTick() + + expect(zero.value).not.toBe(instance) + expect(zero.value.userID).toBe('other-user') + }) + + it('unsubscribes from the previous connection state when the instance changes', async () => { + const userID = ref('test-user') + const { useZero, useConnectionState } = createZeroComposables(() => ({ + userID: userID.value, + server: null, + schema: testSchema, + kvStore: 'mem' as const, + })) + + const zero = useZero() + const unsubscribe = vi.fn() + const subscribe = vi.spyOn(zero.value.connection.state, 'subscribe').mockReturnValue(unsubscribe) + + useConnectionState() + expect(subscribe).toHaveBeenCalledTimes(1) + + userID.value = 'test-user-2' + await nextTick() + + expect(unsubscribe).toHaveBeenCalledTimes(1) + }) + + it('dispose closes the instance and stops watching the options', async () => { + const userID = ref('test-user') + const composables = createZeroComposables(() => ({ + userID: userID.value, + server: null, + schema: testSchema, + kvStore: 'mem' as const, + })) + + const zero = composables.useZero() + const instance = zero.value + + composables.dispose() + await new Promise(resolve => setTimeout(resolve, 1)) + + expect(instance.closed).toBe(true) + + userID.value = 'test-user-2' + await nextTick() + + expect(zero.value).toBe(instance) + }) + + it('dispose is idempotent', () => { + const composables = createZeroComposables({ + userID: 'test-user', + server: null, + schema: testSchema, + kvStore: 'mem' as const, + }) + composables.useZero() + composables.dispose() + expect(() => composables.dispose()).not.toThrow() + }) +}) diff --git a/src/create-zero-composables.ts b/src/create-zero-composables.ts index cd365ce..9b7d4be 100644 --- a/src/create-zero-composables.ts +++ b/src/create-zero-composables.ts @@ -11,16 +11,32 @@ import type { ZeroOptions, } from '@rocicorp/zero' import type { DeepReadonly, MaybeRefOrGetter, Ref, ShallowRef } from 'vue' +import type { ZeroConnection } from './connection' +import type { AnyMutatorFn, MutationHandle, UseMutationOptions } from './mutation' +import type { PreloadResult, UsePreloadOptions } from './preload' import type { MaybeQueryResult, QueryResult, UseQueryOptions } from './query' +import type { UseSuspenseQueryOptions } from './suspense' import { Zero } from '@rocicorp/zero' -import { getCurrentInstance, onUnmounted, readonly, ref, shallowRef, toValue, watch } from 'vue' +import { effectScope, shallowRef, toValue, watch } from 'vue' +import { useConnection } from './connection' +import { useMutation as _useMutation } from './mutation' +import { usePreload as _usePreload } from './preload' import { useQuery as _useQuery } from './query' +import { useSuspenseQuery as _useSuspenseQuery } from './suspense' + +/** Options accepted by {@linkcode createZeroComposables}. */ +export type ZeroComposableOptions< + TSchema extends Schema, + MD extends CustomMutatorDefs | undefined, + TContext, +> = MaybeRefOrGetter | { zero: Zero }> export interface ZeroComposables< TSchema extends Schema, MD extends CustomMutatorDefs | undefined, TContext, > { + /** The current Zero instance. Replaced if the options change. */ useZero: () => ShallowRef> useQuery: { < @@ -42,108 +58,162 @@ export interface ZeroComposables< options?: MaybeRefOrGetter, ): MaybeQueryResult } + useSuspenseQuery: < + TTable extends keyof TSchema['tables'] & string, + TInput extends ReadonlyJSONValue | undefined, + TOutput extends ReadonlyJSONValue | undefined, + TReturn = PullRow, + >( + query: MaybeRefOrGetter>, + options?: MaybeRefOrGetter, + ) => Promise> + usePreload: < + TTable extends keyof TSchema['tables'] & string, + TInput extends ReadonlyJSONValue | undefined, + TOutput extends ReadonlyJSONValue | undefined, + TReturn = PullRow, + >( + query: MaybeRefOrGetter | Falsy>, + options?: MaybeRefOrGetter, + ) => PreloadResult + useMutation: ( + mutator: TFn, + options?: UseMutationOptions, + ) => MutationHandle> useConnectionState: () => DeepReadonly> + useConnection: () => ZeroConnection + /** + * Stops every watcher owned by this bundle and closes the current Zero + * instance. Call this when the bundle's lifetime ends: after an SSR + * response has rendered, or when the scope that created it is disposed. + */ + dispose: () => void } +/** A composables bundle with its schema, mutators and context unconstrained. */ + +export type AnyZeroComposables = ZeroComposables + export function createZeroComposables< TSchema extends Schema = DefaultSchema, MD extends CustomMutatorDefs | undefined = undefined, TContext = DefaultContext, >( - optsOrZero: MaybeRefOrGetter | { zero: Zero }>, + optsOrZero: ZeroComposableOptions, ): ZeroComposables { - let z: ShallowRef> - let connectionState: Ref - let unsubscribe: () => void + type ZeroInstance = Zero - function useZero(): ShallowRef> { - if (!z) { - z = shallowRef() as ShallowRef> - } + const scope = effectScope(true) + const z = shallowRef() as ShallowRef - if (z.value) { + let started = false + let connection: ZeroConnection | undefined + let disposed = false + + function useZero(): ShallowRef { + if (started) { return z } + started = true + + scope.run(() => { + watch(() => toValue(optsOrZero), (opts, previousOpts) => { + const current = z.value - watch(() => toValue(optsOrZero), (opts) => { - if (z.value && !z.value.closed) { - void z.value.close() - } + if (current && !current.closed && previousOpts && isAuthOnlyChange(previousOpts, opts)) { + void current.connection.connect({ auth: (opts as ZeroOptions).auth as string }) + return + } + + if (current && !current.closed) { + void current.close() + } - z.value = 'zero' in opts ? opts.zero : new Zero(opts) - }, { - deep: 1, - immediate: true, + z.value = 'zero' in opts ? opts.zero : new Zero(opts) + }, { + deep: 1, + immediate: true, + }) }) return z } - function useQuery< - TTable extends keyof TSchema['tables'] & string, - TInput extends ReadonlyJSONValue | undefined, - TOutput extends ReadonlyJSONValue | undefined, - TReturn = PullRow, - >( - query: MaybeRefOrGetter>, - options?: MaybeRefOrGetter, - ): QueryResult - - function useQuery< - TTable extends keyof TSchema['tables'] & string, - TInput extends ReadonlyJSONValue | undefined, - TOutput extends ReadonlyJSONValue | undefined, - TReturn = PullRow, - >( - query: MaybeRefOrGetter | Falsy>, - options?: MaybeRefOrGetter, - ): MaybeQueryResult - - function useQuery< - TTable extends keyof TSchema['tables'] & string, - TInput extends ReadonlyJSONValue | undefined, - TOutput extends ReadonlyJSONValue | undefined, - TReturn = PullRow, - >( - query: MaybeRefOrGetter | Falsy>, - options?: MaybeRefOrGetter, - ): QueryResult | MaybeQueryResult { - const zero = useZero() - return _useQuery(zero, query, options) + function useQuery(query: never, options?: never) { + return _useQuery(useZero(), query, options) } - function useConnectionState(): DeepReadonly> { - if (!connectionState) { - useZero() - - connectionState = ref() as Ref - - watch(z, (zero) => { - if (!zero) { - return - } + function useSuspenseQuery(query: never, options?: never) { + return _useSuspenseQuery(useZero(), query, options) + } - connectionState.value = zero.connection.state.current - unsubscribe = zero.connection.state.subscribe((state) => { - connectionState.value = state - }) - }, { immediate: true }) - } + function usePreload(query: never, options?: never) { + return _usePreload(useZero(), query, options) + } - return readonly(connectionState) + function useMutation(mutator: never, options?: never) { + return _useMutation(useZero(), mutator, options) } - function cleanup() { - unsubscribe?.() + function getConnection(): ZeroConnection { + if (!connection) { + const zero = useZero() + connection = scope.run(() => useConnection(zero))! + } + return connection } - if (getCurrentInstance()) { - onUnmounted(cleanup) + function dispose() { + if (disposed) { + return + } + disposed = true + scope.stop() + const current = z.value + if (current && !current.closed) { + void current.close() + } } return { useZero, useQuery, - useConnectionState, + useSuspenseQuery, + usePreload, + useMutation, + useConnectionState: () => getConnection().state, + useConnection: getConnection, + dispose, + } as ZeroComposables +} + +/** + * Zero refreshes its server-side auth context without reconnecting, so a token + * refresh should update the existing instance rather than recreate it (which + * would drop the local replica). Transitions to or from logged-out still need + * a new instance, per Zero's own guidance. + */ +function isAuthOnlyChange(previous: object, next: object): boolean { + if ('zero' in previous || 'zero' in next) { + return false } + + const previousAuth = (previous as { auth?: unknown }).auth + const nextAuth = (next as { auth?: unknown }).auth + + if (typeof previousAuth !== 'string' || typeof nextAuth !== 'string' || previousAuth === nextAuth) { + return false + } + + const keys = new Set([...Object.keys(previous), ...Object.keys(next)]) + for (const key of keys) { + if (key === 'auth') { + continue + } + if (!Object.is((previous as Record)[key], (next as Record)[key])) { + return false + } + } + + return true } diff --git a/src/index.ts b/src/index.ts index 641be1c..08adbdc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,19 @@ +export { useConnection, useConnectionState } from './connection' +export type { ZeroConnection } from './connection' +export { injectZeroComposables, useZero, zeroInjectionKey } from './context' export { createZeroComposables } from './create-zero-composables' -export type { ZeroComposables } from './create-zero-composables' +export type { AnyZeroComposables, ZeroComposableOptions, ZeroComposables } from './create-zero-composables' export { defineZeroOptions } from './define-zero-options' -export type { MaybeQueryResult, QueryResult, UseQueryOptions } from './query' +export { useMutation } from './mutation' +export type { AnyMutatorFn, MutationError, MutationHandle, UseMutationOptions } from './mutation' +export { createZeroPlugin } from './plugin' +export type { ZeroPlugin } from './plugin' +export { usePreload } from './preload' +export type { PreloadResult, UsePreloadOptions } from './preload' export { useQuery } from './query' +export type { MaybeQueryResult, QueryResult, UseQueryOptions } from './query' +export { useSuspenseQuery } from './suspense' +export type { UseSuspenseQueryOptions } from './suspense' +export type { AnyZero, ZeroSource } from './types' +export { VueView, vueViewFactory } from './view' +export type { QueryError, QueryStatus } from './view' diff --git a/src/lifecycle.test.ts b/src/lifecycle.test.ts new file mode 100644 index 0000000..3e8b224 --- /dev/null +++ b/src/lifecycle.test.ts @@ -0,0 +1,156 @@ +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import { effectScope, nextTick, ref } from 'vue' +import { createTestZero, queries } from '../test/fixture' +import { useQuery } from './query' +import { activeViewCountForTesting } from './view-store' + +function withZero() { + const zero = createTestZero() + onTestFinished(async () => { + await zero.close() + }) + return zero +} + +describe('useQuery lifecycle', () => { + it('should destroy its view when the surrounding effect scope is disposed', async () => { + const zero = withZero() + const scope = effectScope() + + scope.run(() => { + useQuery(zero, () => queries.table()) + }) + + expect(activeViewCountForTesting(zero)).toBe(1) + + scope.stop() + await nextTick() + + expect(activeViewCountForTesting(zero)).toBe(0) + }) + + it('should stop its watchers when the surrounding effect scope is disposed', async () => { + const zero = withZero() + const scope = effectScope() + const a = ref(1) + const queryGetter = vi.fn(() => queries.byId(a.value)) + + scope.run(() => { + useQuery(zero, queryGetter) + }) + expect(queryGetter).toHaveBeenCalledTimes(1) + + scope.stop() + a.value = 2 + await nextTick() + + expect(queryGetter).toHaveBeenCalledTimes(1) + }) + + it('should share a single view between consumers of the same query', async () => { + const zero = withZero() + const materializeSpy = vi.spyOn(zero, 'materialize') + const scope = effectScope() + + let first: ReturnType | undefined + const inner = effectScope() + + scope.run(() => { + first = useQuery(zero, () => queries.table()) + }) + inner.run(() => { + useQuery(zero, () => queries.table()) + }) + + expect(materializeSpy).toHaveBeenCalledTimes(1) + expect(activeViewCountForTesting(zero)).toBe(1) + + inner.stop() + await nextTick() + + expect(activeViewCountForTesting(zero)).toBe(1) + expect(first!.status.value).not.toBe('disabled') + + scope.stop() + await nextTick() + + expect(activeViewCountForTesting(zero)).toBe(0) + }) + + it('should not share views between different queries', () => { + const zero = withZero() + const scope = effectScope() + + scope.run(() => { + useQuery(zero, () => queries.byId(1)) + useQuery(zero, () => queries.byId(2)) + }) + + expect(activeViewCountForTesting(zero)).toBe(2) + scope.stop() + }) + + it('should give a shared view the longest requested ttl', async () => { + const zero = withZero() + const materializeSpy = vi.spyOn(zero, 'materialize') + const scope = effectScope() + + scope.run(() => { + useQuery(zero, () => queries.table(), { ttl: '1m' }) + }) + + const view = materializeSpy.mock.results[0]!.value + const updateTTL = vi.spyOn(view, 'updateTTL') + + const inner = effectScope() + inner.run(() => { + useQuery(zero, () => queries.table(), { ttl: '10m' }) + }) + + expect(materializeSpy).toHaveBeenCalledTimes(1) + expect(updateTTL).toHaveBeenCalledExactlyOnceWith('10m') + + inner.stop() + await nextTick() + + expect(updateTTL).toHaveBeenLastCalledWith('1m') + scope.stop() + }) + + it('should tear down the view when disabled and rebuild it when re-enabled', async () => { + const zero = withZero() + const enabled = ref(true) + const scope = effectScope() + + let result!: ReturnType + scope.run(() => { + result = useQuery(zero, () => queries.table(), () => ({ enabled: enabled.value })) + }) + + expect(activeViewCountForTesting(zero)).toBe(1) + expect(result.status.value).not.toBe('disabled') + + enabled.value = false + await nextTick() + + expect(activeViewCountForTesting(zero)).toBe(0) + expect(result.status.value).toBe('disabled') + expect(result.data.value).toBeUndefined() + + enabled.value = true + await nextTick() + + expect(activeViewCountForTesting(zero)).toBe(1) + expect(result.status.value).not.toBe('disabled') + + scope.stop() + }) + + it('should throw a helpful error when there is no Zero instance to use', () => { + const zero = withZero() + const scope = effectScope() + + expect(() => scope.run(() => useQuery(() => zero as never))).toThrow(/No Zero instance available/) + scope.stop() + }) +}) diff --git a/src/mutation.ts b/src/mutation.ts new file mode 100644 index 0000000..bf4cab9 --- /dev/null +++ b/src/mutation.ts @@ -0,0 +1,115 @@ +import type { + CustomMutatorDefs, + DefaultContext, + DefaultSchema, + MutatorResult, + MutatorResultDetails, + MutatorResultErrorDetails, + Schema, +} from '@rocicorp/zero' +import type { ComputedRef, Ref } from 'vue' +import type { ZeroSource } from './types' +import { computed, readonly, ref, shallowRef } from 'vue' +import { readZero, resolveZeroArgs } from './context' + +export type MutationError = MutatorResultErrorDetails['error'] + +export interface UseMutationOptions { + /** + * Which stage `mutate` awaits, and therefore how long `pending` stays true: + * the optimistic client-side apply, or the server's authoritative result. + * + * @default 'client' + */ + awaitResult?: 'client' | 'server' +} + +export interface MutationHandle { + /** + * Runs the mutator. Resolves with Zero's result details rather than + * throwing, so `mutate` is safe to call from an event handler. + */ + mutate: (...args: TArgs) => Promise + /** Whether at least one mutation is in flight. */ + pending: ComputedRef + /** How many mutations are in flight. */ + inFlight: Readonly> + /** The error from the most recent failed mutation, if any. */ + error: Readonly> + /** Clears {@linkcode MutationHandle.error}. */ + reset: () => void +} + +export type AnyMutatorFn = (...args: any[]) => unknown + +/** + * Wraps a mutator in the pending/error state a form or button needs, so that + * call sites do not have to track `zero.mutate(...)` promises by hand. + * + * @example + * ```ts + * const { mutate: addMessage, pending } = useMutation(mutators.message.insert) + * await addMessage({ id, body }) + * ``` + */ +export function useMutation( + mutator: TFn, + options?: UseMutationOptions, +): MutationHandle> + +export function useMutation< + TFn extends AnyMutatorFn, + TSchema extends Schema = DefaultSchema, + MD extends CustomMutatorDefs | undefined = undefined, + TContext = DefaultContext, +>( + zero: ZeroSource, + mutator: TFn, + options?: UseMutationOptions, +): MutationHandle> + +export function useMutation(...args: unknown[]): MutationHandle { + const { zero, rest } = resolveZeroArgs<[AnyMutatorFn, UseMutationOptions?]>(args) + const [mutator, options] = rest + + if (typeof mutator !== 'function') { + throw new TypeError('[zero-vue] `useMutation` expects a mutator, for example `mutators.message.insert`.') + } + + const inFlight = ref(0) + const error = shallowRef(undefined) + + async function mutate(...args: unknown[]): Promise { + const instance = readZero(zero) + inFlight.value++ + try { + const result = instance.mutate(mutator(...args) as never) as MutatorResult + const details = await (options?.awaitResult === 'server' ? result.server : result.client) + if (details.type === 'error') { + error.value = details.error + } + return details + } + catch (cause) { + const details: MutatorResultDetails = { + type: 'error', + error: { type: 'zero', message: cause instanceof Error ? cause.message : String(cause) }, + } + error.value = details.type === 'error' ? details.error : undefined + return details + } + finally { + inFlight.value-- + } + } + + return { + mutate, + pending: computed(() => inFlight.value > 0), + inFlight: readonly(inFlight), + error: readonly(error) as Readonly>, + reset: () => { + error.value = undefined + }, + } +} diff --git a/src/plugin.test.ts b/src/plugin.test.ts new file mode 100644 index 0000000..ce096cf --- /dev/null +++ b/src/plugin.test.ts @@ -0,0 +1,82 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from 'vitest' +import { createApp, effectScope } from 'vue' +import { mutators, queries, zeroOptions } from '../test/fixture' +import { useConnectionState } from './connection' +import { useMutation } from './mutation' +import { createZeroPlugin } from './plugin' +import { useQuery } from './query' + +function createTestApp() { + const zero = createZeroPlugin(zeroOptions()) + const app = createApp({ render: () => null }) + app.use(zero) + return { app, zero } +} + +describe('createZeroPlugin', () => { + it('provides the composables to the app', () => { + const { app, zero } = createTestApp() + + app.runWithContext(() => { + const scope = effectScope() + scope.run(() => { + const { data, status } = useQuery(() => queries.table()) + expect(data.value).toEqual([]) + expect(status.value).not.toBe('disabled') + expect(useConnectionState().value).toBeDefined() + }) + scope.stop() + }) + + zero.dispose() + }) + + it('exposes the bundle directly as well as through injection', () => { + const { app, zero } = createTestApp() + + expect(zero.useZero().value.userID).toBe('test-user') + app.runWithContext(() => { + expect(useQuery(() => queries.table()).data.value).toEqual([]) + }) + + zero.dispose() + }) + + it('disposes the composables when the app unmounts', async () => { + const { app, zero } = createTestApp() + + const instance = zero.useZero().value + app.mount(document.createElement('div')) + app.unmount() + await new Promise(resolve => setTimeout(resolve, 1)) + + expect(instance.closed).toBe(true) + }) + + it('throws a helpful error when a query getter returns a Zero instance', () => { + const { app, zero } = createTestApp() + + app.runWithContext(() => { + expect(() => useQuery(() => zero.useZero().value as never)).toThrow(/Pass the instance/) + }) + + zero.dispose() + }) + + it('resolves the instance for mutations too', async () => { + const { app, zero } = createTestApp() + + await app.runWithContext(async () => { + const { mutate, pending, error } = useMutation(mutators.table.insert) + expect(pending.value).toBe(false) + const result = mutate({ a: 1, b: 'one' }) + expect(pending.value).toBe(true) + await result + expect(pending.value).toBe(false) + expect(error.value).toBeUndefined() + }) + + zero.dispose() + }) +}) diff --git a/src/plugin.ts b/src/plugin.ts new file mode 100644 index 0000000..0eedec6 --- /dev/null +++ b/src/plugin.ts @@ -0,0 +1,40 @@ +import type { CustomMutatorDefs, DefaultContext, DefaultSchema, Schema } from '@rocicorp/zero' +import type { App, Plugin } from 'vue' +import type { ZeroComposableOptions, ZeroComposables } from './create-zero-composables' +import { zeroInjectionKey } from './context' +import { createZeroComposables } from './create-zero-composables' + +export type ZeroPlugin< + TSchema extends Schema, + MD extends CustomMutatorDefs | undefined, + TContext, +> = Plugin<[]> & ZeroComposables + +/** + * Creates a Zero composables bundle and provides it to the app, so that the + * composables exported from `zero-vue` can be used without passing a Zero + * instance around. + * + * @example + * ```ts + * const zero = createZeroPlugin({ userID, schema, mutators, kvStore: 'mem' }) + * createApp(App).use(zero).mount('#app') + * ``` + */ +export function createZeroPlugin< + TSchema extends Schema = DefaultSchema, + MD extends CustomMutatorDefs | undefined = undefined, + TContext = DefaultContext, +>( + optsOrZero: ZeroComposableOptions, +): ZeroPlugin { + const composables = createZeroComposables(optsOrZero) + + return { + ...composables, + install(app: App) { + app.provide(zeroInjectionKey, composables) + app.onUnmount?.(() => composables.dispose()) + }, + } +} diff --git a/src/preload.ts b/src/preload.ts new file mode 100644 index 0000000..2b2f5f9 --- /dev/null +++ b/src/preload.ts @@ -0,0 +1,128 @@ +import type { + CustomMutatorDefs, + DefaultContext, + DefaultSchema, + Falsy, + PullRow, + QueryOrQueryRequest, + ReadonlyJSONValue, + Schema, + TTL, +} from '@rocicorp/zero' +import type { MaybeRefOrGetter, Ref } from 'vue' +import type { ZeroSource } from './types' +import { DEFAULT_TTL_MS } from '@rocicorp/zero/bindings' +import { computed, effectScope, onScopeDispose, readonly, ref, shallowRef, toValue, watch } from 'vue' +import { resolveZeroArgs } from './context' +import { useQueryTarget } from './query-target' + +export interface UsePreloadOptions { + /** + * How long Zero keeps the preloaded rows after the preload is cleaned up. + * + * @default '5m' + */ + ttl?: TTL | undefined + /** + * Set to `false` to skip (and tear down) the preload. Reactive. + * + * @default true + */ + enabled?: boolean | undefined +} + +export interface PreloadResult { + /** Whether the current preload has finished syncing from zero-cache. */ + complete: Readonly> + /** Rejection from `zero.preload`, if the preload failed. */ + error: Readonly> + /** Cancel the preload early. Called automatically when the scope is disposed. */ + cleanup: () => void +} + +/** + * Warms Zero's client cache with a query's rows without materializing a view, + * cancelling the preload when the current scope (component, `effectScope`, + * Pinia store) is disposed. + */ +export function usePreload< + TTable extends keyof TSchema['tables'] & string, + TInput extends ReadonlyJSONValue | undefined, + TOutput extends ReadonlyJSONValue | undefined, + TSchema extends Schema = DefaultSchema, + TReturn = PullRow, + TContext = DefaultContext, +>( + query: MaybeRefOrGetter | Falsy>, + options?: MaybeRefOrGetter, +): PreloadResult + +export function usePreload< + TTable extends keyof TSchema['tables'] & string, + TInput extends ReadonlyJSONValue | undefined, + TOutput extends ReadonlyJSONValue | undefined, + TSchema extends Schema = DefaultSchema, + TReturn = PullRow, + TContext = DefaultContext, + MD extends CustomMutatorDefs | undefined = undefined, +>( + zero: ZeroSource, + query: MaybeRefOrGetter | Falsy>, + options?: MaybeRefOrGetter, +): PreloadResult + +export function usePreload(...args: unknown[]): PreloadResult { + const { zero, rest } = resolveZeroArgs<[MaybeRefOrGetter, MaybeRefOrGetter?]>(args) + const [query, options] = rest + + const ttl = computed(() => toValue(options)?.ttl ?? DEFAULT_TTL_MS) + const enabled = computed(() => toValue(options)?.enabled ?? true) + + const complete = ref(false) + const error = shallowRef(undefined) + const active = shallowRef<{ cleanup: () => void } | null>(null) + + function cleanup() { + active.value?.cleanup() + active.value = null + complete.value = false + } + + const scope = effectScope() + + scope.run(() => { + const target = useQueryTarget(zero, query, enabled, 'usePreload') + + watch( + [() => target.value?.zero, () => target.value?.key, ttl], + () => { + cleanup() + const current = target.value + if (!current) { + return + } + error.value = undefined + const preload = current.zero.preload(current.query as never, { ttl: ttl.value }) + active.value = preload + preload.complete.then(() => { + if (active.value === preload) { + complete.value = true + } + }).catch((cause: unknown) => { + if (active.value === preload) { + error.value = cause + } + }) + }, + { immediate: true }, + ) + + onScopeDispose(cleanup) + }) + + return { + complete: readonly(complete), + error: readonly(error), + cleanup, + } +} diff --git a/src/query-target.ts b/src/query-target.ts new file mode 100644 index 0000000..fd5fd53 --- /dev/null +++ b/src/query-target.ts @@ -0,0 +1,46 @@ +import type { ComputedRef, MaybeRefOrGetter, Ref } from 'vue' +import type { AnyZero } from './types' +import { addContextToQuery, asQueryInternals } from '@rocicorp/zero/bindings' +import { computed, toValue } from 'vue' +import { readZero } from './context' +import { isZeroLike } from './types' + +export interface QueryTarget { + readonly zero: AnyZero + readonly query: unknown + /** Stable identity of the query, including its output format. */ + readonly key: string +} + +/** + * Resolves a reactive query and Zero source into the query object to + * materialize and a stable key to watch, so that a query getter re-running + * without changing the query does not tear anything down. + */ +export function useQueryTarget( + zero: Ref | AnyZero, + query: MaybeRefOrGetter, + enabled: Ref | ComputedRef, + composable: string, +): ComputedRef { + return computed(() => { + if (!enabled.value) { + return undefined + } + const instance = readZero(zero) + const value = toValue(query) + if (!instance || !value) { + return undefined + } + if (isZeroLike(value)) { + throw new TypeError(`[zero-vue] \`${composable}\` was passed a Zero instance where a query was expected. Pass the instance (or a ref holding it) as the first argument.`) + } + const contextual = addContextToQuery(value as never, instance.context) + const internals = asQueryInternals(contextual) + return { + zero: instance, + query: contextual, + key: internals.hash() + JSON.stringify(internals.format), + } + }) +} diff --git a/src/query.ts b/src/query.ts index a47ef7c..352b27b 100644 --- a/src/query.ts +++ b/src/query.ts @@ -11,24 +11,39 @@ import type { ReadonlyJSONValue, Schema, TTL, - Zero, } from '@rocicorp/zero' import type { ComputedRef, MaybeRefOrGetter } from 'vue' -import type { QueryError, QueryStatus, VueView } from './view' +import type { ZeroSource } from './types' +import type { QueryError, QueryStatus } from './view' +import type { SharedView } from './view-store' -import { addContextToQuery, asQueryInternals, DEFAULT_TTL_MS } from '@rocicorp/zero/bindings' +import { DEFAULT_TTL_MS } from '@rocicorp/zero/bindings' import { computed, - getCurrentInstance, - onUnmounted, + effectScope, + onScopeDispose, shallowRef, toValue, watch, } from 'vue' -import { vueViewFactory } from './view' +import { resolveZeroArgs } from './context' +import { useQueryTarget } from './query-target' +import { acquireView } from './view-store' export interface UseQueryOptions { + /** + * How long Zero keeps syncing the query after the last consumer goes away. + * + * @default '5m' + */ ttl?: TTL | undefined + /** + * Set to `false` to tear down the query's view without unmounting the + * component. Equivalent to passing a falsy query, and reactive. + * + * @default true + */ + enabled?: boolean | undefined } export interface QueryResult { @@ -43,7 +58,12 @@ export interface MaybeQueryResult { error: ComputedRef void } | undefined> } -// Overload 1: Query +type AnyQueryArg = MaybeRefOrGetter< + + QueryOrQueryRequest | Falsy +> + +// Overload 1: query, using the Zero instance provided by `createZeroPlugin` export function useQuery< TTable extends keyof TSchema['tables'] & string, TInput extends ReadonlyJSONValue | undefined, @@ -51,14 +71,12 @@ export function useQuery< TSchema extends Schema = DefaultSchema, TReturn = PullRow, TContext = DefaultContext, - MD extends CustomMutatorDefs | undefined = undefined, >( - z: MaybeRefOrGetter>, query: MaybeRefOrGetter>, options?: MaybeRefOrGetter, ): QueryResult -// Overload 2: Maybe query +// Overload 2: maybe query, using the Zero instance provided by `createZeroPlugin` export function useQuery< TTable extends keyof TSchema['tables'] & string, TInput extends ReadonlyJSONValue | undefined, @@ -66,14 +84,27 @@ export function useQuery< TSchema extends Schema = DefaultSchema, TReturn = PullRow, TContext = DefaultContext, - MD extends CustomMutatorDefs | undefined = undefined, >( - z: MaybeRefOrGetter>, query: MaybeRefOrGetter | Falsy>, options?: MaybeRefOrGetter, ): MaybeQueryResult -// Implementation +// Overload 3: explicit Zero instance + query +export function useQuery< + TTable extends keyof TSchema['tables'] & string, + TInput extends ReadonlyJSONValue | undefined, + TOutput extends ReadonlyJSONValue | undefined, + TSchema extends Schema = DefaultSchema, + TReturn = PullRow, + TContext = DefaultContext, + MD extends CustomMutatorDefs | undefined = undefined, +>( + zero: ZeroSource, + query: MaybeRefOrGetter>, + options?: MaybeRefOrGetter, +): QueryResult + +// Overload 4: explicit Zero instance + maybe query export function useQuery< TTable extends keyof TSchema['tables'] & string, TInput extends ReadonlyJSONValue | undefined, @@ -83,64 +114,56 @@ export function useQuery< TContext = DefaultContext, MD extends CustomMutatorDefs | undefined = undefined, >( - z: MaybeRefOrGetter>, + zero: ZeroSource, query: MaybeRefOrGetter | Falsy>, options?: MaybeRefOrGetter, -): QueryResult | MaybeQueryResult { +): MaybeQueryResult + +// Implementation +export function useQuery(...args: unknown[]): QueryResult | MaybeQueryResult { + const { zero, rest } = resolveZeroArgs<[AnyQueryArg, MaybeRefOrGetter?]>(args) + const [query, options] = rest + const ttl = computed(() => toValue(options)?.ttl ?? DEFAULT_TTL_MS) - const view = shallowRef(null) - const refetchKey = shallowRef(0) - - const q = shallowRef() - watch( - [ - () => toValue(query), - () => toValue(z), - ], - ([query, z]) => { - q.value = query ? addContextToQuery(toValue(query), toValue(z).context) : undefined - }, - { immediate: true }, - ) - - const qi = computed(() => q.value ? asQueryInternals(q.value) : undefined) - const hash = computed(() => qi.value ? qi.value.hash() + JSON.stringify(qi.value.format) : undefined) - - watch( - [ - () => toValue(z), - hash, - refetchKey, - ], - ([z]) => { - view.value?.destroy() - if (!hash.value) { - view.value = null - } - else { - view.value = z.materialize(toValue(q), vueViewFactory, { ttl: toValue(ttl) }) - } - }, - { immediate: true }, - ) - - watch(ttl, (ttl) => { - toValue(view)?.updateTTL(ttl) + const enabled = computed(() => toValue(options)?.enabled ?? true) + + const scope = effectScope() + const shared = shallowRef(null) + + scope.run(() => { + const target = useQueryTarget(zero, query, enabled, 'useQuery') + + watch( + [() => target.value?.zero, () => target.value?.key], + () => { + shared.value?.release() + shared.value = target.value + ? acquireView(target.value.zero, target.value.query, target.value.key, ttl.value) + : null + }, + { immediate: true }, + ) + + watch(ttl, ttl => shared.value?.setTTL(ttl)) + + onScopeDispose(() => { + shared.value?.release() + shared.value = null + }) }) - if (getCurrentInstance()) { - onUnmounted(() => view.value?.destroy()) + const view = computed(() => shared.value?.view.value) + + function retry() { + shared.value?.retry() } return { data: computed(() => view.value?.data as HumanReadable), status: computed(() => view.value?.status ?? 'disabled'), - error: computed(() => view.value?.error - ? { - retry: () => { refetchKey.value++ }, - ...view.value.error, - } - : undefined, - ), + error: computed(() => { + const error = view.value?.error + return error ? { ...error, retry } : undefined + }), } } diff --git a/src/suspense.ts b/src/suspense.ts new file mode 100644 index 0000000..2925e9c --- /dev/null +++ b/src/suspense.ts @@ -0,0 +1,98 @@ +import type { + CustomMutatorDefs, + DefaultContext, + DefaultSchema, + PullRow, + QueryOrQueryRequest, + ReadonlyJSONValue, + Schema, +} from '@rocicorp/zero' +import type { MaybeRefOrGetter } from 'vue' +import type { QueryResult, UseQueryOptions } from './query' +import type { ZeroSource } from './types' +import { toValue, watch } from 'vue' +import { isZeroSourceArg } from './context' +import { useQuery } from './query' + +export interface UseSuspenseQueryOptions extends UseQueryOptions { + /** + * How much of the result to wait for: + * + * - `partial`: resolve as soon as there are local rows, or as soon as the + * query has finished loading from the server (which may mean no rows). + * - `complete`: resolve only once the server has confirmed the full result. + * + * @default 'partial' + */ + suspendUntil?: 'partial' | 'complete' +} + +/** + * A `useQuery` whose promise resolves once the query has results, for use with + * `` or an `async setup()`. + * + * @example + * ```ts + * const { data: users } = await useSuspenseQuery(() => queries.users.all()) + * ``` + */ +export function useSuspenseQuery< + TTable extends keyof TSchema['tables'] & string, + TInput extends ReadonlyJSONValue | undefined, + TOutput extends ReadonlyJSONValue | undefined, + TSchema extends Schema = DefaultSchema, + TReturn = PullRow, + TContext = DefaultContext, +>( + query: MaybeRefOrGetter>, + options?: MaybeRefOrGetter, +): Promise> + +export function useSuspenseQuery< + TTable extends keyof TSchema['tables'] & string, + TInput extends ReadonlyJSONValue | undefined, + TOutput extends ReadonlyJSONValue | undefined, + TSchema extends Schema = DefaultSchema, + TReturn = PullRow, + TContext = DefaultContext, + MD extends CustomMutatorDefs | undefined = undefined, +>( + zero: ZeroSource, + query: MaybeRefOrGetter>, + options?: MaybeRefOrGetter, +): Promise> + +export function useSuspenseQuery(...args: unknown[]): Promise> { + const result = (useQuery as any)(...args) as QueryResult + const rest = isZeroSourceArg(args[0]) ? args.slice(1) : args + const options = toValue(rest[1] as MaybeRefOrGetter | undefined) + + return whenSettled(result, options?.suspendUntil ?? 'partial').then(() => result) +} + +function whenSettled(result: QueryResult, until: 'partial' | 'complete'): Promise { + const settled = () => { + const status = result.status.value + if (status === 'complete' || status === 'error') { + return true + } + return until === 'partial' && hasRows(result.data.value) + } + + if (settled()) { + return Promise.resolve() + } + + const { promise, resolve } = Promise.withResolvers() + const stop = watch(settled, (done) => { + if (done) { + stop() + resolve() + } + }) + return promise +} + +function hasRows(data: unknown): boolean { + return Array.isArray(data) ? data.length > 0 : data !== undefined +} diff --git a/src/ttl.test.ts b/src/ttl.test.ts new file mode 100644 index 0000000..ecb65d9 --- /dev/null +++ b/src/ttl.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vitest' +import { maxTTL, ttlToMs } from './ttl' + +describe('ttlToMs', () => { + it('parses zero ttl values', () => { + expect(ttlToMs('none')).toBe(0) + expect(ttlToMs('30s')).toBe(30_000) + expect(ttlToMs('5m')).toBe(300_000) + expect(ttlToMs('2h')).toBe(7_200_000) + expect(ttlToMs('1d')).toBe(86_400_000) + expect(ttlToMs('1y')).toBe(31_536_000_000) + expect(ttlToMs(1234)).toBe(1234) + }) + + it('treats forever, negative and infinite ttls as unbounded', () => { + expect(ttlToMs('forever')).toBe(Number.POSITIVE_INFINITY) + expect(ttlToMs(-1)).toBe(Number.POSITIVE_INFINITY) + expect(ttlToMs(Number.POSITIVE_INFINITY)).toBe(Number.POSITIVE_INFINITY) + }) + + it('treats NaN as no ttl', () => { + expect(ttlToMs(Number.NaN)).toBe(0) + }) +}) + +describe('maxTTL', () => { + it('returns the longest ttl in its original form', () => { + expect(maxTTL(['30s', '10m', '5m'])).toBe('10m') + expect(maxTTL(['10m', 'forever'])).toBe('forever') + expect(maxTTL([600_000, '5m'])).toBe(600_000) + }) + + it('returns undefined when there are no ttls', () => { + expect(maxTTL([])).toBeUndefined() + }) +}) diff --git a/src/ttl.ts b/src/ttl.ts new file mode 100644 index 0000000..18c728c --- /dev/null +++ b/src/ttl.ts @@ -0,0 +1,47 @@ +import type { TTL } from '@rocicorp/zero' + +const MULTIPLIER: Record = { + s: 1000, + m: 60 * 1000, + h: 3600 * 1000, + d: 24 * 3600 * 1000, + y: 365 * 24 * 3600 * 1000, +} + +/** + * Zero's `TTL` union in milliseconds, with `forever` (and negative numbers) + * mapped to `Infinity` so that TTLs can be compared numerically. + */ +export function ttlToMs(ttl: TTL): number { + if (typeof ttl === 'number') { + if (Number.isNaN(ttl)) { + return 0 + } + return !Number.isFinite(ttl) || ttl < 0 ? Number.POSITIVE_INFINITY : ttl + } + if (ttl === 'none') { + return 0 + } + if (ttl === 'forever') { + return Number.POSITIVE_INFINITY + } + const unit = ttl.at(-1)! + return Number(ttl.slice(0, -1)) * (MULTIPLIER[unit] ?? 1) +} + +/** + * The longest-lived of the given TTLs, returned as the caller's original value + * so that it round-trips through Zero unchanged. + */ +export function maxTTL(ttls: Iterable): TTL | undefined { + let max: TTL | undefined + let maxMs = -1 + for (const ttl of ttls) { + const ms = ttlToMs(ttl) + if (ms > maxMs) { + maxMs = ms + max = ttl + } + } + return max +} diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..f510c46 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,25 @@ +import type { CustomMutatorDefs, Schema, Zero } from '@rocicorp/zero' +import type { Ref } from 'vue' + +/** A Zero instance with its schema, mutators and context left unconstrained. */ + +export type AnyZero = Zero + +/** + * Where a composable gets its Zero instance from: the instance itself, or a ref + * holding it (as returned by `useZero()`, which swaps the instance out when the + * options change). + */ +export type ZeroSource< + TSchema extends Schema, + MD extends CustomMutatorDefs | undefined, + TContext, +> = Zero | Ref> + +export function isZeroLike(value: unknown): value is AnyZero { + return !!value + && typeof value === 'object' + && typeof (value as AnyZero).materialize === 'function' + && typeof (value as AnyZero).mutate === 'function' + && 'connection' in value +} diff --git a/src/view-store.test.ts b/src/view-store.test.ts new file mode 100644 index 0000000..666cc1c --- /dev/null +++ b/src/view-store.test.ts @@ -0,0 +1,92 @@ +import { describe, expect, it, onTestFinished, vi } from 'vitest' +import { createTestZero, queries } from '../test/fixture' +import { acquireView, activeViewCountForTesting } from './view-store' + +function withZero() { + const zero = createTestZero() + onTestFinished(async () => { + await zero.close() + }) + return zero +} + +const KEY = 'test-key' + +describe('acquireView', () => { + it('materializes once for several holders and destroys on last release', () => { + const zero = withZero() + const materialize = vi.spyOn(zero, 'materialize') + + const first = acquireView(zero, queries.table(), KEY, '1m') + const second = acquireView(zero, queries.table(), KEY, '1m') + + expect(materialize).toHaveBeenCalledTimes(1) + expect(first.view.value).toBe(second.view.value) + + const destroy = vi.spyOn(first.view.value, 'destroy') + + first.release() + expect(destroy).not.toHaveBeenCalled() + expect(activeViewCountForTesting(zero)).toBe(1) + + second.release() + expect(destroy).toHaveBeenCalledTimes(1) + expect(activeViewCountForTesting(zero)).toBe(0) + }) + + it('hands every holder the new view after a retry', () => { + const zero = withZero() + const materialize = vi.spyOn(zero, 'materialize') + + const first = acquireView(zero, queries.table(), KEY, '1m') + const second = acquireView(zero, queries.table(), KEY, '1m') + const original = first.view.value + const destroy = vi.spyOn(original, 'destroy') + + first.retry() + + expect(destroy).toHaveBeenCalledTimes(1) + expect(materialize).toHaveBeenCalledTimes(2) + expect(first.view.value).not.toBe(original) + expect(second.view.value).toBe(first.view.value) + + first.release() + second.release() + }) + + it('tracks the longest requested ttl', () => { + const zero = withZero() + + const first = acquireView(zero, queries.table(), KEY, '1m') + const updateTTL = vi.spyOn(first.view.value, 'updateTTL') + + const second = acquireView(zero, queries.table(), KEY, '30s') + expect(updateTTL).not.toHaveBeenCalled() + + const third = acquireView(zero, queries.table(), KEY, 'forever') + expect(updateTTL).toHaveBeenLastCalledWith('forever') + + third.release() + expect(updateTTL).toHaveBeenLastCalledWith('1m') + + second.setTTL('10m') + expect(updateTTL).toHaveBeenLastCalledWith('10m') + + first.release() + second.release() + }) + + it('ignores operations after release', () => { + const zero = withZero() + const handle = acquireView(zero, queries.table(), KEY, '1m') + const view = handle.view.value + + handle.release() + handle.release() + handle.setTTL('10m') + handle.retry() + + expect(handle.view.value).toBe(view) + expect(activeViewCountForTesting(zero)).toBe(0) + }) +}) diff --git a/src/view-store.ts b/src/view-store.ts new file mode 100644 index 0000000..5611fda --- /dev/null +++ b/src/view-store.ts @@ -0,0 +1,110 @@ +import type { TTL } from '@rocicorp/zero' +import type { ShallowRef } from 'vue' +import type { AnyZero } from './types' +import type { VueView } from './view' +import { shallowRef } from 'vue' +import { maxTTL, ttlToMs } from './ttl' +import { vueViewFactory } from './view' + +interface StoreEntry { + readonly view: ShallowRef + readonly ttls: Map + ttl: TTL + materialize: () => VueView +} + +/** + * Materialized views, shared per Zero instance and keyed by query identity, so + * that N components observing the same query cost one view rather than N. + */ +const stores = new WeakMap>() + +export interface SharedView { + /** The current view. Replaced (not mutated) by {@linkcode SharedView.retry}. */ + readonly view: ShallowRef + /** Update this holder's requested TTL; the view uses the longest requested. */ + setTTL: (ttl: TTL) => void + /** Destroy and re-materialize the shared view, for every holder. */ + retry: () => void + /** Drop this holder. The view is destroyed once the last holder releases. */ + release: () => void +} + +export function acquireView(zero: AnyZero, query: unknown, key: string, ttl: TTL): SharedView { + let entries = stores.get(zero) + if (!entries) { + entries = new Map() + stores.set(zero, entries) + } + + const token = Symbol('zero-vue:view') + let entry = entries.get(key) + + if (!entry) { + const ttls = new Map([[token, ttl]]) + const view = shallowRef() as ShallowRef + const created: StoreEntry = { + view, + ttls, + ttl, + materialize: () => zero.materialize(query as never, vueViewFactory, { ttl: created.ttl }), + } + view.value = created.materialize() + entries.set(key, created) + entry = created + } + else { + entry.ttls.set(token, ttl) + applyTTL(entry) + } + + const held = entry + let released = false + + return { + view: held.view, + setTTL(ttl) { + if (released) { + return + } + held.ttls.set(token, ttl) + applyTTL(held) + }, + retry() { + if (released) { + return + } + held.view.value.destroy() + held.view.value = held.materialize() + }, + release() { + if (released) { + return + } + released = true + held.ttls.delete(token) + if (held.ttls.size > 0) { + applyTTL(held) + return + } + if (entries.get(key) === held) { + entries.delete(key) + } + held.view.value.destroy() + }, + } +} + +function applyTTL(entry: StoreEntry): void { + const ttl = maxTTL(entry.ttls.values()) + if (ttl === undefined || ttlToMs(ttl) === ttlToMs(entry.ttl)) { + return + } + entry.ttl = ttl + entry.view.value.updateTTL(ttl) +} + +/** @internal */ +export function activeViewCountForTesting(zero: AnyZero): number { + return stores.get(zero)?.size ?? 0 +} diff --git a/test/fixture.ts b/test/fixture.ts new file mode 100644 index 0000000..68c7fa5 --- /dev/null +++ b/test/fixture.ts @@ -0,0 +1,63 @@ +import { + createBuilder, + createSchema, + defineMutatorsWithType, + defineMutatorWithType, + defineQueriesWithType, + defineQuery, + number, + string, + table, + Zero, +} from '@rocicorp/zero' +import z from 'zod' + +export const schema = createSchema({ + tables: [ + table('table') + .columns({ + a: number(), + b: string(), + }) + .primaryKey('a'), + ], +}) + +const defineMutators = defineMutatorsWithType() +const defineMutator = defineMutatorWithType() + +export const mutators = defineMutators({ + table: { + insert: defineMutator( + z.object({ a: z.number(), b: z.string() }), + async ({ tx, args: { a, b } }) => { + return tx.mutate.table.insert({ a, b }) + }, + ), + }, +}) + +const zql = createBuilder(schema) +const defineQueries = defineQueriesWithType() + +export const queries = defineQueries({ + table: defineQuery(() => zql.table), + byId: defineQuery( + z.number(), + ({ args: a }) => zql.table.where('a', a), + ), +}) + +export function zeroOptions() { + return { + userID: 'test-user', + server: null, + schema, + mutators, + kvStore: 'mem', + } as const +} + +export function createTestZero() { + return new Zero(zeroOptions()) +} diff --git a/test/fixtures/vue/src/app.vue b/test/fixtures/vue/src/app.vue index 22615d9..494baa1 100644 --- a/test/fixtures/vue/src/app.vue +++ b/test/fixtures/vue/src/app.vue @@ -2,11 +2,12 @@ import { useCookies } from '@vueuse/integrations/useCookies' import { computed, ref } from 'vue' +import { useQuery, useZero } from 'zero-vue' import { useInterval } from '#fx/composables/use-interval' import { randomMessage } from '#fx/db/data/test-data' import { formatDate } from '#fx/utils/date' import { randInt } from '#fx/utils/rand' -import { mutators, queries, useQuery, useZero } from './zero' +import { mutators, queries } from './zero' const cookies = useCookies() diff --git a/test/fixtures/vue/src/main.ts b/test/fixtures/vue/src/main.ts index 029d146..07af84e 100644 --- a/test/fixtures/vue/src/main.ts +++ b/test/fixtures/vue/src/main.ts @@ -1,6 +1,9 @@ import { createApp } from 'vue' import App from './app.vue' +import { zero } from './zero' import './main.css' -createApp(App).mount('#app') +const app = createApp(App).use(zero) + +app.mount('#app') diff --git a/test/fixtures/vue/src/zero.ts b/test/fixtures/vue/src/zero.ts index 76f2f99..81a6017 100644 --- a/test/fixtures/vue/src/zero.ts +++ b/test/fixtures/vue/src/zero.ts @@ -1,6 +1,6 @@ import { useCookies } from '@vueuse/integrations/useCookies' import { decodeJwt } from 'jose' -import { createZeroComposables } from 'zero-vue' +import { createZeroPlugin } from 'zero-vue' import { mutators, schema } from '#fx/db/schema' @@ -8,7 +8,7 @@ export { mutators, queries } from '#fx/db/schema' const cookies = useCookies() -export const { useZero, useQuery } = createZeroComposables(() => { +export const zero = createZeroPlugin(() => { const encodedJWT = cookies.get('jwt') const decodedJWT = encodedJWT && decodeJwt(encodedJWT) const userID = typeof decodedJWT?.sub === 'string' ? decodedJWT.sub : undefined -- 2.51.2