Imported from nipakke/zero-vue (
AGENTS.md). Install upstream withnpx skills add nipakke/zero-vue. Copyright stays with the author.
Repository Guidelines
Project Overview
@nipakke/zero-vue is a thin Vue 3 reactivity adapter over the Zerodotdev/Zero sync engine (@rocicorp/zero). It exposes composables (useQuery, useMutator, useConnectionState, createBindings) and a VueView class that wrap Zero's materialized views, mutations, and connection state as Vue reactive refs, so a Vue app can query and mutate local/offline-first data declaratively.
This is a library repo with a bundled demo app. Public surface is exported from src/index.ts; Zero types/values are imported directly from @rocicorp/zero.
Architecture & Data Flow
Vue component
│ calls composables
▼
useQuery / useMutator / useConnectionState / createBindings (src/)
│ wrap Zero materialized views + connection state
▼
Zero instance (offline-first: IDB/in-memory, optional sync server)
└─ @rocicorp/zero ── imported directly by src/ and consumers
Data flow: a query signal is turned into a Zero Query, materialized via zero.materialize(query, vueViewFactory, {ttl}), which produces a VueView implementing Zero's IVM view contract (input.fetch() + incremental push() + transaction-commit flush). useQuery holds that view in a single shallowRef and derives read-only computeds from it — data, status ('complete' | 'unknown' | 'error' | 'disabled', 'disabled' when the query is off), and error ({ retry, type, message, details? } or undefined). Reactivity is driven by watch, and views tear down when the enclosing scope is disposed (component unmount or effectScope stop) or when the query hash/zero changes.
Key modules:
src/index.ts— public barrel. ExportsuseConnectionState,createBindings,useQuery,useMutator(+ typesQueryResult,MaybeQueryResult,QueryError,QueryStatus,UseQueryOptions,UseMutatorOptions,MutationResult),VueView,MutationTimeoutError,MutationError,DEFAULT_MUTATION_TIMEOUT_MS.src/query.ts— the primaryuseQuery(zero, querySignal, options?)composable. Returns{ data, error, status }as read-onlycomputeds derived from a materialized-viewshallowRef(a.k.a.QueryResult;MaybeQueryResultallowsundefineddata). Reactivewatch([zero, hash, refetchKey])materializes aVueViewper query via the factory overload; watches TTL separately; cleans up when the enclosing scope is disposed (guarded bygetCurrentScope()). A query that cannot be hashed (noqueryInternalstag) surfaces asstatus: 'error'with type'InvalidQuery', not'disabled'.src/mutation.ts— theuseMutator(zero, mutatorGetter, options?)composable.mutatorGetterreturns aMutatorreference from a registered custom mutator (e.g.() => mutators.addItem) — not a call — so the mutator's argument type is inferred ontomutate; the composable executes it against the current zero andmutatereturns the resultingMutatorResult({client, server}) for independent awaiting. TracksisPending/errorcomputed refs, races the tracked promise (awaitMode:'client'default,'server') againstoptions.timeout(DEFAULT_MUTATION_TIMEOUT_MS= 5s,Infinitydisables) with aMutationTimeoutErroron timeout, ignores stale in-flight callbacks via a mutation id, andreset()clears state.mutateaccepts a trailingMutationCallOptions({timeout?, throwOnTimeout?, throwOnError?}) per call that overrides the composable options (detected strictly: only objects consisting solely of those keys with matching value types count as options);throwOnTimeout(defaultfalse) /throwOnError(defaulttrue— the call's promise rejects when the mutation fails) make the call's tracked promise reject on timeout / error details instead of just reporting viaerror. Failures are branded —MutationTimeoutError(timeout) vsMutationError(mutation failed;causecarries Zero's raw error details) — andoptions.onMutationError(error: Error)observes every failure as a pure listener, as does thecreateBindings-level callback (local fires first).src/vue-view.ts—VueViewclass (produced by the exportedvueViewFactory) implementing Zero'sOutputview contract: readsinput.fetch(), appliespush()changes, flushes on transaction commit, and resolvesqueryCompleteintostatus/error. Exposes reactivedata/status/errorrefs plusupdateTTL(ttl)/destroy(). Also defines the publicQueryStatus/QueryErrortypes.src/connection-state.ts—useConnectionState(zero)→Ref<ConnectionState>; reactively watches aMaybeRefOrGetter<Zero>, subscribing toconnection.stateand tearing down/re-subscribing on swap. Unsubscribes when the enclosing scope is disposed.src/create-bindings.ts—createBindings(zero, { queries?, mutators?, onMutationError? })returns{useQuery, useMutator, useConnectionState, useZero}with a shared reactive zero (computed(() => toValue(zero))) pre-bound, so the zero is passed once per app. Thequeries/mutatorsregistries (fromdefineQueries/defineMutators) are injected into the bound getters; the mutator registry must be the same one passed to theZeroconstructor. The bindings-levelonMutationErrorobserver is composed into the bounduseMutator's options so it fires after the per-composable callback. BounduseConnectionState/useZeroexpose the shared zero's connection state and value. Swapping a reactive zero tears down and re-materializes all bound views.
Key Directories
| Path | Purpose |
|---|---|
src/ |
Library source; public API lives here |
test/ |
Vitest unit tests (*.test.ts) |
.playground/ |
Demo app (workspace package @zero-vue/playground) exercising the API against an offline Zero |
coverage/ |
Test coverage output (generated) |
docs/ |
User-facing guides — getting started, queries, mutations, bindings (linked from the README) |
Development Commands
Run via pnpm at repo root:
pnpm test # run tests once (vp test run)
pnpm test:watch # watch mode (vp test)
pnpm check-types # vue-tsc --noEmit typecheck
pnpm lint # lint with oxc (vp lint)
pnpm format # format with oxc (vp fmt)
pnpm playground # run the demo app (pnpm --filter @zero-vue/playground dev)
pnpm check:playground # typecheck the playground (vue-tsc)
vite-plus (aliased vp) is the unified build/test runner (wrapper over Vite + Vitest + oxlint). pnpm build (vp pack) produces the distributable (dist/), gated in CI by publint + attw checks. pnpm check-types typechecks src/ + test/; pnpm check:playground builds the lib, then typechecks the demo app.
Code Conventions & Common Patterns
- Runtime/build: ESM only (
"type": "module"), TypeScript strict (strict,noUncheckedIndexedAccess,noUnusedLocals,noUnusedParameters).moduleResolution: "bundler". - Import convention: Local imports use explicit
.tsextensions (import {useQuery} from './query.ts').allowImportingTsExtensionsis on. - Zero types: Always import Zero types/values directly from
@rocicorp/zero, never through a re-export shim. - Vue reactivity:
shallowReffor data payloads (avoid deep reactivity on large query results),reffor scalar status,computedfor derived zero/options/ttl and for the read-onlydata/status/errorsurface,watchfor query lifecycle,toValueto normalizeMaybeRefOrGetter. VueView swaps itsshallowRefwholesale on each flush. - Composable signature:
zeroandoptionsacceptMaybeRefOrGetter;querySignalis a getter function() => QueryOrQueryRequest | Falsy(falsy disables the query →undefineddata /'disabled'status).useMutator'smutatorGetteris a getter returning aMutatorreference, not a call:() => mutators.x(core, closing over your own registry) or(mutators) => mutators.x(bound form, the registry is injected). The selected mutator's argument type is inferred ontomutate, which executes it viazero.mutate(mutator(...args)); legacy CRUD (zero.mutate.item.*) is not supported through the getter. - TTL: queries default to
DEFAULT_TTL_MS(5 min); overridable viaUseQueryOptions.ttlorVueView.updateTTL. Tests use e.g.'10m'. - Error handling:
useQueryexposes completeness viastatus('complete' | 'unknown' | 'error', plus'disabled'when the query is off) and the error payload viaerror(aQueryErrorwith{retry, type, message, details?}, orundefinedwhen not errored;retryre-materializes by bumpingrefetchKey).VueViewresolves Zero'squeryCompletesignal (true | ErroredQuery | Promise<true>) into itsstatus/errorrefs;useQuerymaps those to the publicQueryStatus/QueryErrorshapes and addsretry/'disabled'. - State management: No external store. State lives in Zero (client-side, offline-first) and flows through the composables into Vue refs.
createBindingsshares one reactive Zero across all bound queries.
Important Files
| File | Why it matters |
|---|---|
src/index.ts |
Package entry (exports map . → ./src/index.ts) |
src/query.ts |
Core query composable; primary public read API |
src/mutation.ts |
Core mutation composable (useMutator); timeout race + isPending/error tracking |
src/vue-view.ts |
VueView factory view + vueViewFactory; IVM view lifecycle + reactivity |
package.json |
Scripts, exports map, peer vue ^3.5, dep @rocicorp/zero ^1.8.0 |
pnpm-workspace.yaml |
Workspace (packages: [.playground]), catalog + overrides for vite/vitest/vite-plus |
tsconfig.json |
TS strictness, allowImportingTsExtensions, types: ["vitest/globals"] |
vitest.config.ts |
environment: 'jsdom', include: ['test/**/*.test.ts'] |
vite.config.ts |
vite-plus config: fmt, lint (oxlint plugin, vite-plus/prefer-vite-plus-imports error) |
.playground/src/bindings.ts |
Canonical usage: new Zero({server: null, mutators, ...}) + createBindings(zero, {mutators}) |
Runtime/Tooling Preferences
- Runtime: Node (browser-targeted library; jsdom for tests).
- Package manager: pnpm 11.21.0 (enforced via
devEngines.packageManager; auto-download on mismatch). - Workspace: pnpm monorepo with a single
@zero-vue/playgroundpackage.catalog:+overrides:pinvite/vitest/vite-plus(via@voidzero-dev/vite-plus-core). - Build/test runner:
vite-plus(thevpbinary) — used for test, lint, format, dev. - Linting: ESLint + the
vite-plus/oxlint-plugin(type-aware); rulevite-plus/prefer-vite-plus-imports: errorenforces importing vite-plus instead of raw vite/vitest. - Formatting: Prettier (
pnpm formattargetssrc/). - CI:
.github/workflows/ci.yml— lint,vue-tsctypecheck, tests,vp pack(publint + attw gates) on push/PR;release.yml— changesets-driven publish onmain. - Versioning: Changesets (
.changeset/,CHANGELOG.md). Each user-facing change ships as its own changeset:patch= bug fix,minor= new feature,major= breaking.pnpm version/pnpm publishapply them. - Docs:
README.mdis the entry point; per-topic guides live indocs/(getting started, queries, mutations, bindings) with runnable examples.
Testing & QA
- Framework: Vitest 4 via
vite-plus, jsdom environment,test/**/*.test.ts. Globals are declared in tsconfig types but each test file importsdescribe/expect/testfromvite-plus/test. - Running:
pnpm test(once) /pnpm test:watch. Coverage via@vitest/coverage-v8+@vitest/ui(output incoverage/). - Pattern — real Zero, no mocks: Zero is not faked/stubbed. Tests construct a real client with
new Zero({server: null, userID: 'test', schema, kvStore: 'mem'})and write data viaawait z.mutate.item.insert({...})or registry mutators (new Zero({..., mutators})+z.mutate(registry.x(...))). Withserver: null, sync never completes, sostatusstays'unknown'andMutatorResult.serverpromises never settle (documented behavior). A small shared schema is defined per file:createSchema({ tables: [table('item').columns({id: number(), name: string()}).primaryKey('id')], enableLegacyMutators: true }). - Vue reactivity: driven with
nextTick;@vue/test-utilsmountis used only inconnection-state.test.ts(viadefineComponent+watchEffect). No fake timers, no setup files. - Assertions: deep-compare query rows via a
rows = (d) => JSON.parse(JSON.stringify(d))helper (stripsSymbol(rc)row-context symbols);toMatchInlineSnapshotfor snapshots. - What's covered:
VueViewviaz.materialize(query, vueViewFactory)(initial state, reactive updates,destroy()stops updates, TTL, singular.one()vs plural, empty singular →undefined);useQuery/createBindings(row delivery, reactive re-materialization on swapped reactive zero, falsy/disabled →'disabled'status, unhashable query →'error'status,effectScopedisposal tears views down);useMutator/bounduseMutator(isPending transitions, resolved-error-details vs timeout-rejection error surfacing, timeout race →MutationTimeoutError,reset(), registry injection, type-level rejection of unboundmutators, payload pass-through when it merely contains option-named keys, local + bindings-levelonMutationErrorfiring with branded errors);useConnectionState(state ref after mount).