PackagesServer

@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, then defineShape carries it to clients. Implies .externallyManaged(); under .shardBy(), tenantBy is mandatory (the tenant-isolation boundary, enforced by the external_source_unscoped advisor lint)
  • .index(name, fields, { unique? }) — secondary index
  • .searchIndex(name, { field, filterFields?, language?, staged? }) — full-text index over field (a dot path reads a nested field); filterFields (≤16) are the columns .eq() may narrow by inside the search; language adds that language's stopwords to the always-on accent folding (de/en/es/fr/it/nl/pt); staged: true skips the migration-time backfill on a large table; strategy: "native" uses the engine's own full-text index where it has one (Postgres tsvector + 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 (default deletedAt) 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)args takes where, orderBy, with (relation loading), select (column projection, top-level and per-relation via with: { rel: { select: [...] } }), cursor / limit, and includeDeleted.
  • exists(where?) — boolean existence check (reuses findFirst, no count scan).
  • insert(doc, { skipDuplicates? })skipDuplicates: true resolves to null on 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 *Many batch 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.

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.

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. filter is an allow-list. v.object drops undeclared keys, so a caller cannot predicate on a column you didn't publish. Keep the list to indexed columns; @lunora/advisor's filter-without-index lint flags the static cases. This bounds which columns are reachable, not the cost of every operator: contains compiles to a leading-wildcard LIKE, and ne / notIn / isNull: false are non-sargable too, so all of them scan regardless of the index.
  • No AND / OR / NOT trees. 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/testingexpectPolicy(policies), an in-process RLS harness that evaluates the same logic the rls() 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