Guides
Valtio Sync
Check Valtio Sync fields against Qubu tables and apply each client mutation with its sync event in one Qubu-owned transaction.
Install the optional integration beside Qubu, Valtio Sync, and Zod:
pnpm add qubu @qubu/valtio-sync valtio-sync zod
Check synced fields against Qubu tables
Use the integration's schema wrappers instead of valtio-sync/schema when a
definition corresponds to a Qubu table. Every selected table field must appear
in fields. Mark persistence-only or server-controlled fields with
serverOnly() so they are excluded from Valtio Sync validation and records.
import { $type, defineCollection, serverOnly } from "@qubu/valtio-sync"
import { boolean, integer, table, text } from "qubu"
import { z } from "zod"
const todosTable = table("todos", {
ownerId: integer(),
id: text(),
title: text(),
done: boolean(),
})
export const todos = defineCollection({
dbType: $type<typeof todosTable>(),
fields: {
ownerId: serverOnly(),
id: z.string(),
title: z.string().default(""),
done: z.boolean().default(false),
},
})
The Zod output for each synced field must be assignable to its Qubu selected
value. Narrow schemas are allowed, such as a Zod enum for a Qubu text field.
Missing fields, extra fields, and wider outputs fail type checking. The same
rules apply to defineAccount().
Apply mutations in transactions
applyOpsWithQubu() converts Qubu-aware mutation handlers into the public
ServerHandlers contract accepted by valtio-sync/server. Supply a bound Qubu
client whose adapter supports transactions:
import { applyOpsWithQubu } from "@qubu/valtio-sync"
import { and, eq, insertInto, returning, update, values, where } from "qubu"
import { valtioSync } from "valtio-sync/server"
type SyncContext = { user: { id: number } }
const handlers = applyOpsWithQubu<SyncContext>({
db,
syncEvents: {
write: async ({ tx, ctx, collection, recordId, op }) => {
const [event] = await tx.rows(
insertInto(
syncEvents,
values({ userId: ctx.user.id, collection, recordId, op }),
returning({ seq: syncEvents.seq }),
),
)
return event.seq
},
},
authorize: ({ ctx, collection, op }) => assertCanSync(ctx.user, collection, op),
checkConflict: ({ tx, ctx, collection, op }) =>
assertFreshBaseVersion(tx, ctx.user.id, collection, op),
handlers: {
todos: {
readChanges: ({ ctx, since }) => readTodoChanges(ctx.user.id, since),
create: async ({ tx, ctx, record }) => {
const value = todos.recordSchema.parse(record)
await tx.execute(insertInto(todosTable, values({ ...value, ownerId: ctx.user.id })))
return {}
},
update: async ({ tx, ctx, op, patch }) => {
const value = todos.recordSchema.partial().parse(patch)
await tx.execute(
update(
todosTable,
value,
where(and(eq(todosTable.id, op.id), eq(todosTable.ownerId, ctx.user.id))),
),
)
return {}
},
},
},
})
export const sync = valtioSync({ schema: { todos }, handlers })
Authorization, conflict checks, the application mutation, and
syncEvents.write() run in that order inside one Qubu transaction. If any step
fails, the adapter rolls the transaction back. The event sequence becomes
serverVersion unless the mutation handler returns an explicit version. Read
handlers pass through unchanged.
The integration does not define persistence tables, import Drizzle, or execute driver APIs. The application owns table design, authorization, conflict policy, event retention, and every query issued through the supplied Qubu transaction.