@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.index(name, fields, { unique? })— secondary index.searchIndex(name, { field, filterFields? })— full-text index.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.
import { defineMutator, v } from "@lunora/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 }),
});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.
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