@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), which is 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? }):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).
Determinism: what actually replays
A query handler can run more than once for the same logical read: a live
subscription re-runs its query whenever a table it reads changes, so
non-deterministic output there (Date.now(), Math.random()) can genuinely
flicker between re-evaluations. Compute it in an action and pass the value in
as an argument, or accept that the field is allowed to differ per re-run.
A mutation handler does not replay under ordinary dispatch. Two
mechanisms cooperate to guarantee this on the DO-backed runtime:
- Idempotency dedup. A client-issued
x-lunora-mutation-idis deduped by(identity, mutationId). A replay of an unacknowledged write returns the cached result without re-running the handler at all. - OCC surfaces as an error, not a retry. A write guarded by optimistic
concurrency that loses the race fails the request with a
409 Conflictresponse back to the caller. The runtime does not catch it and re-invoke the handler internally; the caller decides whether to retry, and a retry is a new dispatch (a fresh idempotency key if the client mints one per attempt), not a replay of the one that failed.
So Date.now() / crypto.randomUUID() inside an ordinary mutation handler
is stable for one logical write: the handler body runs exactly once. This is
the opposite of Convex's model (where OCC failure re-runs the handler), so a
Convex-shaped mental model treats mutation non-determinism as unsafe when it
is not; porting that assumption over errs safe. The one place a DO-shaped
mental model must stay careful: a mutation invoked from inside a workflow
step or a queue consumer can be called again if the surrounding step/
consumer itself replays (Cloudflare Workflows re-executes a step's code on
retry). That is a fresh ctx.runMutation dispatch from the step's
perspective, not a replay this guarantee covers, so non-deterministic values
computed inside such a step should still be treated the way an action value
would be.
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 substring position test, 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)
to bound request size and the scan the predicate costs, not to stay under the
statement's parameter ceiling, which the where compiler handles by binding a
long list as one JSON parameter. orderBy is capped 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 can leak data, because 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.