From c16cb8f7a4f194a03bbb7ddd22b7953f7025f500 Mon Sep 17 00:00:00 2001 From: Gerben Mulder Date: Sat, 25 Oct 2025 15:55:49 +0200 Subject: [PATCH 1/5] feat: add error to query to forward zero errors --- src/query.ts | 27 +++++++++++++--- src/view.ts | 90 +++++++++++++++++++++++++++++++++++++++------------- 2 files changed, 91 insertions(+), 26 deletions(-) diff --git a/src/query.ts b/src/query.ts index 116abcd..d519827 100644 --- a/src/query.ts +++ b/src/query.ts @@ -1,13 +1,14 @@ // based on https://github.com/rocicorp/mono/tree/main/packages/zero-solid -import type { CustomMutatorDefs, HumanReadable, Query, ResultType, Schema, TTL, Zero } from '@rocicorp/zero' +import type { CustomMutatorDefs, HumanReadable, Query, Schema, TTL, Zero } from '@rocicorp/zero' import type { ComputedRef, MaybeRefOrGetter } from 'vue' -import type { VueView } from './view' +import type { QueryErrorDetails, QueryStatus, VueView } from './view' import { computed, getCurrentInstance, onUnmounted, + ref, shallowRef, toValue, watch, @@ -20,9 +21,15 @@ export interface UseQueryOptions { ttl?: TTL | undefined } +export interface QueryError { + refetch: () => void + details: QueryErrorDetails +} + export interface QueryResult { data: ComputedRef> - status: ComputedRef + status: ComputedRef + error: ComputedRef } /** @@ -60,9 +67,14 @@ export function useQueryWithZero< return toValue(options)?.ttl ?? DEFAULT_TTL_MS }) const view = shallowRef> | null>(null) + const refetchKey = ref(0) watch( - [() => toValue(query), () => toValue(z)], + [ + () => toValue(query), + () => toValue(z), + refetchKey, + ], ([q, z]) => { view.value?.destroy() @@ -88,5 +100,12 @@ export function useQueryWithZero< return { data: computed(() => view.value!.data), status: computed(() => view.value!.status), + error: computed(() => view.value!.error + ? { + refetch: () => { refetchKey.value++ }, + details: view.value!.error, + } + : undefined, + ), } } diff --git a/src/view.ts b/src/view.ts index 132d88f..8545b27 100644 --- a/src/view.ts +++ b/src/view.ts @@ -8,22 +8,43 @@ import type { Input, Output, Query, - ResultType, + ReadonlyJSONValue, Schema, TTL, ViewFactory, } from '@rocicorp/zero' +import type { Ref } from 'vue' import { applyChange } from '@rocicorp/zero' -import { reactive } from 'vue' - -interface QueryResultDetails { - readonly type: ResultType +import { ref } from 'vue' + +// zero does not export this type +type ErroredQuery = { + error: 'app' + queryName: string + details: ReadonlyJSONValue +} | { + error: 'zero' + queryName: string + details: ReadonlyJSONValue +} | { + error: 'http' + queryName: string + status: number + details: ReadonlyJSONValue } -type State = [Entry, QueryResultDetails] - -const complete = { type: 'complete' } as const -const unknown = { type: 'unknown' } as const +export type QueryStatus = 'complete' | 'unknown' | 'error' + +export type QueryErrorDetails = { + type: 'app' + queryName: string + details: ReadonlyJSONValue +} | { + type: 'http' + queryName: string + status: number + details: ReadonlyJSONValue +} export class VueView implements Output { readonly #input: Input @@ -31,52 +52,77 @@ export class VueView implements Output { readonly #onDestroy: () => void readonly #updateTTL: (ttl: TTL) => void - #state: State + #data: Ref + #status: Ref + #error: Ref constructor( input: Input, onTransactionCommit: (cb: () => void) => void, - format: Format = { singular: false, relationships: {} }, + format: Format, onDestroy: () => void = () => {}, - queryComplete: true | Promise, + queryComplete: true | ErroredQuery | Promise, updateTTL: (ttl: TTL) => void, ) { this.#input = input this.#format = format this.#onDestroy = onDestroy this.#updateTTL = updateTTL - this.#state = reactive([ - { '': format.singular ? undefined : [] }, - queryComplete === true ? complete : unknown, - ]) + this.#data = ref({ '': format.singular ? undefined : [] }) + this.#status = ref(queryComplete === true ? 'complete' : 'error' in queryComplete ? 'error' : 'unknown') + // @ts-expect-error - queryComplete is a weird union type causing stack depth limit error + this.#error = ref(queryComplete !== true && 'error' in queryComplete ? this.#makeError(queryComplete) : undefined) + input.setOutput(this) for (const node of input.fetch({})) { this.#applyChange({ type: 'add', node }) } - if (queryComplete !== true) { + if (queryComplete !== true && !('error' in queryComplete)) { void queryComplete.then(() => { - this.#state[1] = complete + this.#status.value = 'complete' + }).catch((error: ErroredQuery) => { + this.#status.value = 'error' + this.#error.value = this.#makeError(error) }) } } get data() { - return this.#state[0][''] as V + return this.#data.value[''] as V } get status() { - return this.#state[1].type + return this.#status.value + } + + get error() { + return this.#error.value } destroy() { this.#onDestroy() } + #makeError(error: ErroredQuery): QueryErrorDetails { + return error.error === 'app' || error.error === 'zero' + ? { + type: 'app', + queryName: error.queryName, + details: error.details, + } + : { + type: 'http', + queryName: error.queryName, + status: error.status, + details: error.details, + } + } + #applyChange(change: Change): void { applyChange( - this.#state[0], + this.#data.value, change, this.#input.getSchema(), '', @@ -103,7 +149,7 @@ export function vueViewFactory< format: Format, onDestroy: () => void, onTransactionCommit: (cb: () => void) => void, - queryComplete: true | Promise, + queryComplete: true | ErroredQuery | Promise, updateTTL?: (ttl: TTL) => void, ) { interface UpdateTTL { From 3815d09caa889c37d72c8204e18e38fdd6dc5456 Mon Sep 17 00:00:00 2001 From: Gerben Mulder Date: Sat, 25 Oct 2025 16:22:35 +0200 Subject: [PATCH 2/5] fix: export QueryError type --- src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 696b550..ccbd10f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,3 @@ export { createZeroComposables } from './create-zero-composables' -export type { QueryResult, UseQueryOptions } from './query' +export type { QueryError, QueryResult, UseQueryOptions } from './query' export { useQuery, useQueryWithZero } from './query' From 7f6a7706fe59d19fd2ab9a98cc02de3de5766e3f Mon Sep 17 00:00:00 2001 From: Gerben Mulder Date: Mon, 27 Oct 2025 13:13:50 +0100 Subject: [PATCH 3/5] fix: suggestions --- src/view.ts | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/src/view.ts b/src/view.ts index 8545b27..7f30f04 100644 --- a/src/view.ts +++ b/src/view.ts @@ -70,8 +70,7 @@ export class VueView implements Output { this.#updateTTL = updateTTL this.#data = ref({ '': format.singular ? undefined : [] }) this.#status = ref(queryComplete === true ? 'complete' : 'error' in queryComplete ? 'error' : 'unknown') - // @ts-expect-error - queryComplete is a weird union type causing stack depth limit error - this.#error = ref(queryComplete !== true && 'error' in queryComplete ? this.#makeError(queryComplete) : undefined) + this.#error = ref(queryComplete !== true && 'error' in queryComplete ? makeError(queryComplete) : undefined) as Ref input.setOutput(this) @@ -82,9 +81,10 @@ export class VueView implements Output { if (queryComplete !== true && !('error' in queryComplete)) { void queryComplete.then(() => { this.#status.value = 'complete' + this.#error.value = undefined }).catch((error: ErroredQuery) => { this.#status.value = 'error' - this.#error.value = this.#makeError(error) + this.#error.value = makeError(error) }) } } @@ -105,21 +105,6 @@ export class VueView implements Output { this.#onDestroy() } - #makeError(error: ErroredQuery): QueryErrorDetails { - return error.error === 'app' || error.error === 'zero' - ? { - type: 'app', - queryName: error.queryName, - details: error.details, - } - : { - type: 'http', - queryName: error.queryName, - status: error.status, - details: error.details, - } - } - #applyChange(change: Change): void { applyChange( this.#data.value, @@ -139,6 +124,21 @@ export class VueView implements Output { } } +function makeError(error: ErroredQuery): QueryErrorDetails { + return error.error === 'app' || error.error === 'zero' + ? { + type: 'app', + queryName: error.queryName, + details: error.details, + } + : { + type: 'http', + queryName: error.queryName, + status: error.status, + details: error.details, + } +} + export function vueViewFactory< TSchema extends Schema, TTable extends keyof TSchema['tables'] & string, From 1e4b6178a1b2e82a6b1642270f83bba0cac1c2e4 Mon Sep 17 00:00:00 2001 From: Gerben Mulder Date: Sat, 1 Nov 2025 16:42:54 +0100 Subject: [PATCH 4/5] fix: use shallowRef --- src/query.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/query.ts b/src/query.ts index d519827..88ce20b 100644 --- a/src/query.ts +++ b/src/query.ts @@ -8,7 +8,6 @@ import { computed, getCurrentInstance, onUnmounted, - ref, shallowRef, toValue, watch, @@ -67,7 +66,7 @@ export function useQueryWithZero< return toValue(options)?.ttl ?? DEFAULT_TTL_MS }) const view = shallowRef> | null>(null) - const refetchKey = ref(0) + const refetchKey = shallowRef(0) watch( [ From f0dfdc93e0f52ab719aa92ce6e0d41d868173fa7 Mon Sep 17 00:00:00 2001 From: Gerben Mulder Date: Sat, 1 Nov 2025 16:57:14 +0100 Subject: [PATCH 5/5] feat: flatten error object to avoid `error.details.details` and convenience --- src/index.ts | 2 +- src/query.ts | 11 +++-------- src/view.ts | 8 ++++---- 3 files changed, 8 insertions(+), 13 deletions(-) diff --git a/src/index.ts b/src/index.ts index ccbd10f..696b550 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,3 @@ export { createZeroComposables } from './create-zero-composables' -export type { QueryError, QueryResult, UseQueryOptions } from './query' +export type { QueryResult, UseQueryOptions } from './query' export { useQuery, useQueryWithZero } from './query' diff --git a/src/query.ts b/src/query.ts index 88ce20b..ee39082 100644 --- a/src/query.ts +++ b/src/query.ts @@ -2,7 +2,7 @@ import type { CustomMutatorDefs, HumanReadable, Query, Schema, TTL, Zero } from '@rocicorp/zero' import type { ComputedRef, MaybeRefOrGetter } from 'vue' -import type { QueryErrorDetails, QueryStatus, VueView } from './view' +import type { QueryError, QueryStatus, VueView } from './view' import { computed, @@ -20,15 +20,10 @@ export interface UseQueryOptions { ttl?: TTL | undefined } -export interface QueryError { - refetch: () => void - details: QueryErrorDetails -} - export interface QueryResult { data: ComputedRef> status: ComputedRef - error: ComputedRef + error: ComputedRef void } | undefined> } /** @@ -102,7 +97,7 @@ export function useQueryWithZero< error: computed(() => view.value!.error ? { refetch: () => { refetchKey.value++ }, - details: view.value!.error, + ...view.value!.error, } : undefined, ), diff --git a/src/view.ts b/src/view.ts index 7f30f04..4bcd59b 100644 --- a/src/view.ts +++ b/src/view.ts @@ -35,7 +35,7 @@ type ErroredQuery = { export type QueryStatus = 'complete' | 'unknown' | 'error' -export type QueryErrorDetails = { +export type QueryError = { type: 'app' queryName: string details: ReadonlyJSONValue @@ -54,7 +54,7 @@ export class VueView implements Output { #data: Ref #status: Ref - #error: Ref + #error: Ref constructor( input: Input, @@ -70,7 +70,7 @@ export class VueView implements Output { this.#updateTTL = updateTTL this.#data = ref({ '': format.singular ? undefined : [] }) this.#status = ref(queryComplete === true ? 'complete' : 'error' in queryComplete ? 'error' : 'unknown') - this.#error = ref(queryComplete !== true && 'error' in queryComplete ? makeError(queryComplete) : undefined) as Ref + this.#error = ref(queryComplete !== true && 'error' in queryComplete ? makeError(queryComplete) : undefined) as Ref input.setOutput(this) @@ -124,7 +124,7 @@ export class VueView implements Output { } } -function makeError(error: ErroredQuery): QueryErrorDetails { +function makeError(error: ErroredQuery): QueryError { return error.error === 'app' || error.error === 'zero' ? { type: 'app',