@lunora/server
Authoring API — defineSchema, query, mutation, action, validators.
@lunora/server is the authoring-side package. It provides defineSchema,
defineTable, the v.* validators, and initLunora. The typed
query / mutation / action builders are emitted by codegen, so you import
them from the generated _generated/server:
// schema + validators from the package:
import { defineSchema, defineTable, v } from "@lunora/server";
// typed builders from codegen:
import { action, mutation, query } from "@/lunora/_generated/server";Codegen also re-exports v from _generated/server with v.id(...) narrowed
to your real table names. Use the package's v in schema.ts (the table names
aren't known there yet) and the generated v in your function files to get
table-name autocomplete. The two are identical at runtime.
defineSchema(tables)
Build the application schema. Returns a Schema<T> object consumed by
codegen and the runtime.
defineTable(shape)
Create a TableBuilder. Fluent methods:
.global({ backend? })— store in D1 (cross-shard) instead of a Durable Object.shardBy(field)— partition by the named field.source({ binding, query, tenantBy?, idColumn?, map?, columns?, refresh?, mode? })— materialize this table from an external Postgres/MySQL behind Cloudflare Hyperdrive: a system poll loop reads the (tenant-scoped) slice and lands it in the DO's SQLite, thendefineShapecarries it to clients. Implies.externallyManaged(); under.shardBy(),tenantByis mandatory (the tenant-isolation boundary, enforced by theexternal_source_unscopedadvisor lint).index(name, fields, { unique? })— secondary index.searchIndex(name, { field, filterFields?, language?, staged? })— full-text index overfield(a dot path reads a nested field);filterFields(≤16) are the columns.eq()may narrow by inside the search;languageadds that language's stopwords to the always-on accent folding (de/en/es/fr/it/nl/pt);staged: trueskips the migration-time backfill on a large table;strategy: "native"uses the engine's own full-text index where it has one (Postgrestsvector+ GIN) — faster on large corpora, but the engine ranks, so ordering no longer matches the other backends.public()— exempt this table from.rls("required")(when secure-by-default is on).softDelete({ field? })— soft delete:delete()flips a marker column (defaultdeletedAt) instead of removing the row, and list reads hide soft-deleted rows
Validators (v.*)
v.string();
v.number();
v.boolean();
v.null();
v.bytes();
v.literal("admin");
v.id("users");
v.array(v.string());
v.object({ k: v.number() });
v.union(v.string(), v.number());
v.optional(v.string());A validator throws ValidationError on mismatch. The runtime wraps the error
with an args.<field> path so it points at the field that failed.
query / mutation / action
export const send = mutation.input({ channelId: v.id("channels"), text: v.string() }).mutation(async ({ ctx, args }) => {
/* ... */
});Each returns a RegisteredQuery | RegisteredMutation | RegisteredAction
with a uniform { kind, args, handler } shape consumed by codegen.
ctx shape
QueryCtx, MutationCtx, and ActionCtx share auth, scheduler, and
storage. MutationCtx.db extends QueryCtx.db with mutating methods;
ActionCtx drops db entirely — actions must read/write via mutations or
queries called through ctx.runQuery / ctx.runMutation.
Client-supplied ids
By default ctx.db.insert(table, doc) mints a fresh row id and ignores any
client-chosen _id. To let the caller choose it — which an optimistic client
needs so it can key a row before the server responds — pass a UUID via the
options:
ctx.db.insert("messages", doc, { clientId });clientId is validated for shape (a v1–v8 UUID) and still subject to the
primary-key uniqueness constraint, so a client can't collide with, overwrite, or
forge a peer row. This is the mechanism @lunora/db
uses to reconcile an optimistic row with its persisted server row.
Per-table accessor (ctx.db.<table>)
Each table exposes a typed delegate with the common read/write helpers:
findMany(args)/findFirst(args)—argstakeswhere,orderBy,with(relation loading),select(column projection, top-level and per-relation viawith: { rel: { select: [...] } }),cursor/limit, andincludeDeleted.exists(where?)— boolean existence check (reusesfindFirst, no count scan).insert(doc, { skipDuplicates? })—skipDuplicates: trueresolves tonullon a unique conflict instead of throwing.upsert({ target, create, update? })/upsertMany({ target, rows })— insert-or-update keyed by a.unique()column (or tuple); returns{ id, created }.patch/replace/delete(plus the*Manybatch forms).
On a .softDelete() table, delete(id) flips the marker (cascading as a soft
delete), restore(id) clears it, and hardDelete(id) physically removes the row
(real cascade). List reads hide soft-deleted rows; pass
findMany({ includeDeleted: true }) to include them.
Transactions & atomicity
There is no explicit transaction() API — a mutation is a transaction.
Every ctx.db write in a single mutation commits atomically and rolls back
together if the handler throws. To run a side effect only after the write
commits, schedule it: ctx.scheduler.runAfter(0, internalFn, args) — the
deterministic equivalent of an afterCommit hook (a registered function with
serializable args, not an inline closure).
defineShape(definition) — partial replication
Declare a shape: a named, partial replication of a table for the
local-first sync engine. A client subscribes by
name + validated args; the DO resolves the predicate server-side and
AND-composes it with the table's RLS read base-where. Declared in
lunora/shapes.ts.
import { defineShape, v } from "@lunora/server";
export const messagesByChannel = defineShape({
table: "messages",
args: { channelId: v.id("channels") }, // optional — omit for a parameterless shape
where: (ctx, { channelId }) => ({ channelId }), // runs on the DO with a trusted ctx
columns: ["text", "authorId", "channelId"], // optional projection; _id/_creationTime always included
});where returns the same WhereInput the RLS DSL uses, so there is no second
predicate implementation. Because it runs with an identity the client can't
forge, a shape is a read-as-permission — see
Local-first sync.
defineMutator(definition) — custom mutators
Declare a custom mutator: a server implementation (authoritative, runs in
the shard DO) paired with an optional client twin (optimistic, runs in the
browser). Declared in lunora/mutators.ts.
// Prefer the generated re-export: same runtime, but `ctx` is your project's typed
// `MutationCtx` (schema-checked `ctx.db`) instead of the untyped base context.
import { defineMutator, v } from "./_generated/server";
export const sendMessage = defineMutator({
args: { channelId: v.id("channels"), text: v.string() },
server: (ctx, { channelId, text }) => ctx.db.insert("messages", { channelId, text, authorId: ctx.auth.userId }),
});Codegen emits each mutator as a typed api.mutators.<name> reference, so the
browser-side twin binds to it (serverRef: api.mutators.sendMessage) and infers
its args from these validators.
Add owner: "<column>" to owner-scope the write: the mutator then requires a
verified identity, rejects a client-supplied owner that disagrees with it, and sets
the column to the verified value before server runs — so the impl never repeats
an ownership check by hand. It takes the column name rather than a shape's true
because a mutator may write several tables, so there is no single .ownedBy(field)
to read it from. See
Owner-scoped writes.
The server impl is the linearization point; its writes append to the op-log
and poke back to subscribers. The DO is serialized, so there is no
server-side OCC-retry loop. The client-side optimistic twin and the watermark
ordering are covered in
Local-first sync;
the browser-side defineMutator / bindMutators live in
@lunora/db.
List endpoints
defineListArgs(config) is the shared convention for a paginated list query: it
returns the validator map for .input() plus the translation into
ctx.db.<table>.findMany(...) options, so every list endpoint agrees on how
filtering, sorting, and paging are spelled — and the generated OpenAPI describes
them without special-casing.
import type { Doc } from "./_generated/dataModel";
const listMessages = defineListArgs<Doc<"messages">>()({
filter: { authorId: v.id("users"), status: v.string() },
orderBy: ["_creationTime", "status"],
// defaultLimit: 25, maxLimit: 100, maxInValues: 100, maxOrderBy: 8
});
export const list = c.query
.input(listMessages.args)
.expose({ rest: true })
.query(({ args, ctx }) => ctx.db.messages.findMany(listMessages.toQueryArgs(args)));Callers get where, orderBy, cursor, and limit. where accepts either a
bare value (equality) or the operator object mirroring the ctx.db where DSL
({ gte, lt, in, contains, isNull, … }).
The extra () is what binds the table's document type. With Doc bound, filter
keys, orderBy entries, and each validator's type are checked against the table's
real columns — so a typo, or a column renamed out from under the endpoint, is a
compile error rather than a predicate that silently matches nothing.
TypeScript has no partial type-argument inference, so binding Doc while still
inferring the rest from the config needs the second call.
Three constraints are deliberate:
- Keyset paging, not offset. There is no
page/page_size. Offset paging re-scans from row 0 for every page and shifts rows between pages whenever the data changes — which, under a live query, it always is. - Filterable columns are enumerated.
filteris an allow-list.v.objectdrops undeclared keys, so a caller cannot predicate on a column you didn't publish. Keep the list to indexed columns;@lunora/advisor'sfilter-without-indexlint flags the static cases. This bounds which columns are reachable, not the cost of every operator:containscompiles to a leading-wildcardLIKE, andne/notIn/isNull: falseare non-sargable too, so all of them scan regardless of the index. - No
AND/OR/NOTtrees. A flat field⇒predicate map keeps every filter routable to an index. Compose richer logic inside the procedure.
limit is clamped into [1, maxLimit] rather than rejected, and orderBy
fields outside the allow-list are refused by the validator and re-checked in
toQueryArgs. in / notIn arrays are capped at maxInValues (default 100)
because each element becomes a bound parameter, and orderBy at maxOrderBy
(default 8).
lunora introspect emits list procedures built on this helper — see the
CLI docs.
Caching an exposed REST endpoint
.expose({ rest: true }) publishes a procedure at
/_lunora/rest/<namespace>/<fn>. Add cache to have the runtime answer with
Cache-Control / Cache-Tag / Vary:
export const listPublicPosts = c.query
.input(listPosts.args)
.expose({ rest: true, cache: { scope: "public", maxAge: 60, staleWhileRevalidate: 300, tag: "posts" } })
.query(({ args, ctx }) => ctx.db.posts.findMany(listPosts.toQueryArgs(args)));Caching a procedure-backed endpoint is how a REST cache turns into a data leak,
since the procedure runs under ctx.auth and RLS. So scope is enforced, not
trusted: a request carrying Authorization, Cookie, or
Cf-Access-Jwt-Assertion is always answered private, even under
scope: "public". A per-caller response therefore never reaches a shared or edge
cache, and the worst a mis-declared scope costs you is a missed cache hit.
That check is a header list, and it cannot be exhaustive — resolveIdentity
receives the whole request, so an app may authenticate on anything. If your
auth reads a header not in that list, declare it:
.expose({ rest: true, cache: { scope: "public", maxAge: 60, credentialHeaders: ["x-api-key"] } })Otherwise those callers read as anonymous and their responses are cached
public. The emitted Vary also covers x-lunora-shard-key and
x-d1-bookmark, which select which rows a request sees. Treat Vary as a
courtesy to well-behaved intermediaries rather than the safety mechanism —
Cloudflare's cache honours it only for Accept-Encoding, so the real protection
is the downgrade above.
Headers are only ever applied to a cacheable exchange — a GET (so query
procedures; a mutation / action is POST-only) that returned 2xx. An error
response is never cached. tag is purgeable through the same
ctx.cache.purge({ tags: [...] }) surface httpRoute(...).cacheTag() uses, and
the emitted OpenAPI documents the headers a caller will observe.
Subpaths
@lunora/server/rls/testing—expectPolicy(policies), an in-process RLS harness that evaluates the same logic therls()middleware runs (no Worker or Durable Object needed).@lunora/server/drizzle— drizzle's SQLite schema-definition surface, used by the generated_generated/drizzle.*files.@lunora/server/data-model/@lunora/server/types— type-only helpers.
See also
- @lunora/client
- Concepts: schema
- Local-first sync —
defineShape/defineMutator