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
  • .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 (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
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/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