@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 carries a DatabaseWriter too — it does not drop db — but an
action's own writes are not transactional: each autocommits as it runs, and
a later throw rolls nothing back. A mutation dispatched through
ctx.runMutation does get the same transaction a top-level one gets, which is
what makes the "do the transactional part in a mutation, call it from the
action" split safe. See function context for
the full per-kind matrix.
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 —
for the tables that live in the shard. Every shard-local ctx.db write in a
single mutation commits atomically and rolls back together if the handler
throws, because they share one Durable Object storage transaction. This holds
however the mutation was reached — a top-level RPC, ctx.runMutation from an
action, or a reactor — and a ctx.runMutation from inside a mutation joins the
caller's transaction rather than opening a second one.
That transaction does not reach a .global() table:
those rows live in D1, which has no interactive transactions, so a mutation
that writes both a shard-local row and a global one is writing them
separately. If the handler throws after the global write, the global write
stays. Treat a global write as a step that can fail on its own, and order it
last — or move the pair into a workflow step, whose replay you can make
idempotent.
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). Scheduling from a mutation is part of the same all-or-nothing
unit: the job is enqueued only once the transaction commits, and a rollback
discards it.
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 read policies the shape itself declares (use).
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
use: [tenantScoped], // the rls(...) guards whose read policies gate this 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.
RLS on a shape is opt-in through use, exactly as it is on a procedure: a
shape runs no procedure, so policies declared elsewhere in the project never
reach it. A shape that names no guard replicates on its where alone — under
.rls("required") that is a hard denial for a non-.public() table, and
otherwise codegen refuses to boot the app when the shape's table is governed on
read, naming the shape and the table (use: [] acknowledges a deliberately
ungoverned shape). 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.
defineDocumentHistory(options) — row-version history
.triggers() already fires on every write with the merged row and the pre-write
row in hand, which is exactly the pair a version history needs. This preset gives
them somewhere to go:
// lunora/history.ts
import { defineDocumentHistory } from "@lunora/server";
export const history = defineDocumentHistory({ retentionMs: 90 * 24 * 60 * 60 * 1000 });
// lunora/schema.ts — attach to each table you want versioned
const threads = defineTable({ ... }).triggers(history.record);
export const schema = defineSchema({ threads }).extend(history.extension);
export const { listForDocument, vacuum } = history.functions;listForDocument({ documentId, before?, limit? }) returns that row's versions
newest first. With before, the first entry back is the last version at or
before that instant — a point-in-time reconstruction in one read. vacuum drops
entries past retentionMs and reports how many, so a cron can decide whether to
run again.
Three things worth knowing before you rely on it:
It records what changed, not who. A trigger's context carries db and
scheduler and no identity, so there is no actor to record and the field does
not exist. An unattributed trail that called itself an audit log would be worse
than none.
Snapshots are redacted, at every depth. Secret-shaped fields
(hashedPassword, apiKey, refreshToken, …) are dropped before the entry is
written — a stored credential outlives the rotation meant to retire it — and the
filter recurses, so a credential inside a v.object(...) column goes too.
Matching is by exact field name, which is the only signal a trigger has, so use
redact for anything spelled differently (api_key, sessionToken).
Reads are internal. An entry is a full row snapshot, including columns the
table's own RLS hides, so listForDocument is an internal query. Wrap it in a
procedure of your own with whatever authorization the surface needs.
A snapshot over maxSnapshotBytes (64 KB default) is dropped and the entry is
marked truncated — that a row changed, and when, is the part a trail cannot
afford to lose. doc and previous are capped independently, so a large
pre-write row does not discard a small post-write one.
One blind spot: insertManyUnsafe skips triggers by design, so seed, migration, and admin-import writes leave no entry. Fine for an undo stack; if you are
using this for compliance, that gap has to be closed elsewhere. Note too that on an .rls("required") schema the merged documentHistory_versions table
needs a policy — deny client access outright, since listForDocument is internal and meant to be wrapped by a procedure of your own.
defineActionCache(options) — memoising an action
The section above caches a response at the HTTP edge. This caches the result of an action inside the app — the model call, the third-party fetch, the embedding — keyed by its arguments, with a TTL:
// lunora/cache.ts
import { defineActionCache } from "@lunora/server";
export const cache = defineActionCache({ ttlMs: 60 * 60 * 1000 });
// lunora/schema.ts — merges in as `actionCache_entries`
export const schema = defineSchema({ ... }).extend(cache.extension);
// Re-export so codegen registers it; schedule it from a cron for bulk cleanup.
export const { purgeExpired } = cache.functions;export const embed = action
.input({ text: v.string() })
.action(async ({ args, ctx }) => cache.wrap(ctx, "embed", args, async () => callEmbeddingModel(args.text)));wrap(ctx, name, args, fn) returns the stored value on a hit and runs fn on a
miss. The key is a SHA-256 of name and the args encoded through the wire codec
with sorted keys — call sites pass whole model requests, which are far too large
to index directly. Because the encoding is structural, { a, b } and { b, a }
are one entry, and bigint / Date / bytes arguments key distinctly instead of
collapsing together.
invalidate(ctx, name, args) drops one entry. invalidateAll(ctx, name) drops
every entry under a name and returns { deleted, complete } — call it again while
complete is false.
Expiry is lazy: a read past the TTL reports a miss and the row is overwritten by
the same call. Each miss also reaps a few expired rows, so an app with steady
traffic stays bounded on its own; purgeExpired is for reclaiming entries whose
names went quiet.
One thing it deliberately does not do: there is no single-flight. Two callers
that miss at the same instant both run fn; the unique index on the key means one
result is stored rather than two rows appearing. Preventing the duplicate work
needs a lock held across an arbitrarily long external call, whose own failure mode
— a crashed holder wedging the key — is worse than a cold start being paid twice.
A result over maxValueBytes (512 KB by default) is returned to the caller but
not stored, so a large answer costs a cache miss rather than a failed action.
wrap reads and writes through the app's own ctx.db, so on an .rls("required") schema the merged actionCache_entries table needs a policy like any
other. Declare one that denies client access outright — entries are keyed by a digest and carry no ownership column, so there is nothing for a row policy to
scope to.
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.