defineRelations metadata, and defines the query shape that type inference, encoding, execution, subscriptions, and sync implement.collection field:123456789101112131415const threads = useQuery({ collection: "threads", select: { id: true, title: true, status: true, updatedAt: true }, where: { status: "active" }, with: { messages: { select: { id: true, body: true, createdAt: true }, orderBy: { createdAt: "desc" }, limit: 1, }, }, orderBy: { updatedAt: "desc" }, limit: 50, offset: 0, })
12const threads = client.query(query) const subscription = client.subscribe(query, callback)
collection is required; omitted options are equivalent to {}.useQuery({ collection: "collection", select, where, with, orderBy, limit, offset }).many-to-one and one-to-many relations through with using the same nested options shape as the root query.many-to-one returns object/null, one-to-many returns array.QueryBuilder public API. Tandem is unreleased, so the object query shape should become canonical.selectselect is an inclusion map of scalar fields on the queried collection:1select: { id: true, title: true }
select means all scalar fields on that collection.true is allowed as a field value.false, exclusion maps, aliases, and computed fields are out of scope.select; they are included only through with.select, but hidden join fields must not appear in the result unless selected.select: "*" is not part of the new public object API. If retained internally during migration, normalize it to omitted select before exposing docs or public types.wherewhere is an AND-only object keyed by scalar fields on the current collection:123456where: { status: "active", done: false, priority: { gt: 2 }, updatedAt: { gte: since, lt: before }, }
eq.eq, gt, lt, gte, and lte.OR, NOT, in, contains, relation-field predicates, and arbitrary predicate functions are out of scope for v1.where clauses target the related collection, not the parent collection.orderByorderBy is an ordered object keyed by scalar fields on the current collection:1orderBy: { updatedAt: "desc", id: "asc" }
"asc" or "desc".orderBy leaves storage scan order unspecified.orderBy clauses target the related collection.limit and offsetlimit and offset are non-negative integers applied after where and orderBy:12limit: 50, offset: 100,
limit means no maximum result count.offset means 0.limit: 0 is valid and returns no rows.NaN, and infinite values are invalid.one-to-many relation includes, limit and offset apply per parent.many-to-one relation includes, limit and offset are invalid because the relation result is already at most one object.withwith is an object keyed by relation names from defineRelations:123456789101112with: { author: true, messages: { select: { id: true, body: true, createdAt: true }, where: { deleted: false }, orderBy: { createdAt: "asc" }, limit: 100, with: { author: { select: { id: true, name: true } }, }, }, }
true means include the relation with all scalar fields and no nested filters, sorting, pagination, or nested relations.select, where, with, orderBy, limit, and offset, scoped to the relation target collection.{ id: true }; selected relation fields must live under select.with recursion is part of the design, even if early execution phases limit depth for simplicity.many-to-one relation result:123type PostWithAuthor = Post & { author: User | null }
null when the parent join field is missing, the target record is missing, or relation-level where filters the target out.many-to-one relation never returns an array.one-to-many relation result:123type ThreadWithMessages = Thread & { messages: Message[] }
[].limit: 1 still returns an array with zero or one item. It does not collapse to a single object.Record<string, any> is not acceptable.collection must be a known collection name from the client schema.select, where, and orderBy keys must be scalar fields on the current collection.where values must be assignable to the field's TypeScript type.with keys must be relation names defined for the current collection.with options must be scoped to the target collection of that relation.1234567891011121314const threads = useQuery({ collection: "threads", select: { id: true, title: true }, where: { status: "active" }, with: { messages: { select: { id: true, body: true }, limit: 1, }, }, }) threads[0].id threads[0].messages[0]?.body
1234useQuery({ collection: "threads", select: { missingField: true } }) useQuery({ collection: "threads", where: { status: 123 } }) useQuery({ collection: "threads", orderBy: { missingField: "asc" } }) useQuery({ collection: "threads", with: { missingRelation: true } })
defineRelations must preserve literal relation names, target collections, and many-to-one/one-to-many relation types. If relation metadata widens too early to AnyRelations<Schema>, with and nested result inference will be weakly typed.query endpoint as long as the endpoint treats client queries as untrusted shape requests, not authorization decisions.12345678910async function handleQuery(request: Request, session: Session) { const query = parseAndValidateQuery(await request.json()) return db .withContext({ tenantId: session.tenantId, userId: session.userId, }) .runQuery(query) }
limit, maximum offset, maximum include depth, and possibly maximum relation fanout.with, relation keys are embedded on the parent row, many-to-one relations produce an object or nullable object, and one-to-many relations produce arrays.1234567891011const threads = await db.query.threads.findMany({ columns: { id: true, title: true, status: true }, where: { status: "active" }, with: { messages: { columns: { id: true, body: true, createdAt: true }, orderBy: { createdAt: "desc" }, limit: 1, }, }, })
123456789101112const threads = useQuery({ collection: "threads", select: { id: true, title: true, status: true }, where: { status: "active" }, with: { messages: { select: { id: true, body: true, createdAt: true }, orderBy: { createdAt: "desc" }, limit: 1, }, }, })
with, relation cardinality, and embedded relation results. Tandem differs by using select instead of columns, a single query-object root API instead of db.query.collection.findMany, and no relation-definition options like optional, alias, through, or predefined relation filters in v1.where, orderBy, limit, and offset.12345678910111213const activeThreads = createLiveQueryCollection({ query: (q) => q .from({ thread: threadsCollection }) .where(({ thread }) => eq(thread.status, "active")) .orderBy(({ thread }) => thread.updatedAt, "desc") .limit(50) .select(({ thread }) => ({ id: thread.id, title: thread.title, status: thread.status, })), })
1234567const activeThreads = useQuery({ collection: "threads", select: { id: true, title: true, status: true }, where: { status: "active" }, orderBy: { updatedAt: "desc" }, limit: 50, })
12345queryFn: async (ctx) => { const { where, orderBy, limit, offset } = ctx.meta.loadSubsetOptions const parsed = parseLoadSubsetOptions({ where, orderBy, limit, offset }) return api.getThreads(parsed) }
defineQuery, validate arguments with a schema, and run the same query definition on client and server through ZQL.1234567891011121314export const queries = defineQueries({ threads: { active: defineQuery( z.object({ limit: z.number().int().nonnegative() }), ({ args: { limit } }) => zql.thread .where("status", "active") .orderBy("updatedAt", "desc") .limit(limit), ), }, }) const [threads] = useQuery(queries.threads.active({ limit: 50 }))
123456const threads = useQuery({ collection: "threads", where: { status: "active" }, orderBy: { updatedAt: "desc" }, limit: 50, })
packages/core/src/schema/Schema.ts: runtime schema and relation helperspackages/core/src/query/Query.ts: query types, result inference, and encodingpackages/core/src/query/executeQuery.ts: query and scan window executionpackages/core/src/TandemClient.ts: public query and subscribepackages/react/src/TandemClientProvider.tsx: useTandemQuery