Schema & Handlers

Schema

Declare entities with Entity(...) and assemble them with defineSchema(...). Field builders (t.id(), t.text(), …) return as const literals, so the exact shape flows into the type system.

import { Entity, defineSchema } from "pramen/sdk/schema";

const schema = defineSchema({
  users: Entity(
    (t) => ({ id: t.textId(), name: t.text(), email: t.text() }),
    (r) => ({ notes: r.hasMany("notes", "ownerId") }),
  ),
  notes: Entity(
    (t) => ({ id: t.id(), title: t.text(), body: t.text(), ownerId: t.text(), createdAt: t.int() }),
    (r) => ({ owner: r.belongsTo("users", "ownerId") }),
  ),
});

Field builders: id() (auto-increment integer PK), textId() (text PK), text(), int(), real(), bool(), json() (arbitrary JSON, typed as JsonValue), fileRef() (an R2 file), uuid() (a TEXT column typed as string). Relations: belongsTo(target, column) and hasMany(target, column).

Any column or table name is safe, including SQL reserved words (order, group, select, …) because pramen double-quotes every identifier it emits.

Modifiers wrap a builder and compose: notNull(), unique(), indexed(), defaultTo(value), primaryKey(), generated(), and hidden(), e.g. code: unique(t.text()), status: defaultTo(t.text(), "pending").

Hidden columns

hidden() marks a column never readable through the ORM: it's stripped from every read projection (find/get, mutation echoes, relation loads, and even the SYSTEM-scope admin data API) regardless of ACL, including a full allow() grant. It stays writable on insert/update and visible to the raw ctx.db.exec escape hatch, so credential code can still read it. Use it for secrets like a password hash:

accounts: Entity((t) => ({
  username: t.textId(),
  passwordHash: hidden(t.text()), // writable + readable via exec; never via find/get
  email: t.text(),
}));

await ctx.db.find({ from: "accounts" });
// -> [{ username: "alice", email: "a@x.com" }]   // no passwordHash, ever

Defaults

defaultTo(field, value) gives a column a DEFAULT and makes it optional on insert. Pass a literal (rendered as a quoted SQL literal) or a SQL expression via the expr helper, rendered raw:

posts: Entity((t) => ({
  id: t.id(),
  status: defaultTo(t.text(), "draft"),       // literal  -> DEFAULT 'draft'
  createdAt: defaultTo(t.text(), expr.now()),  // expr     -> DEFAULT (strftime(...))
}));

await ctx.db.insert("posts", { });
// -> { id: 1, status: "draft", createdAt: "2026-09-03T21:05:00.412Z" }  (DB-filled)

expr.now() is the current UTC instant as ISO-8601 TEXT, byte-identical to new Date().toISOString(), so it compares correctly against $now() under SQLite's lexicographic TEXT comparison. expr.raw(sql) is an escape hatch for any other SQLite default expression. Expr-default columns are filled by the database, so they're optional on insert.

Upgrading a store written before v0.0.60. expr.now() used to emit datetime('now') ('2026-09-03 21:05:00'). Changing a column's DEFAULT rebuilds the table, and a rebuild copies existing values through, so old rows keep the space form while new ones get ISO. Mixed in one column, values on the same date sort by their separator rather than their instant (a space sorts below T), and a { lte: $now() } policy lets a row scheduled for later today through. Declare isoTimestampBackfill() in app.migrations to rewrite them:

import { isoTimestampBackfill } from "@pramen/server";
import { CMS_LEGACY_TIMESTAMP_COLUMNS } from "@pramen/cms";  // if you use the CMS

migrations: [isoTimestampBackfill({ extraColumns: CMS_LEGACY_TIMESTAMP_COLUMNS })]

It finds every expr.now() column from your schema; extraColumns names the ones written by handler code, which have no default to find. Declaring it on a fresh store costs nothing: every UPDATE matches no rows.

UUIDs

t.uuid() is a string column. Wrap it with generated() to auto-mint a v4 on insert (via crypto.randomUUID()) when you omit it, and primaryKey() to use it as the primary key. A generated column is optional on insert; a value you supply is validated and rejected (400) if it isn't a well-formed UUID.

events: Entity((t) => ({
  id: primaryKey(generated(t.uuid())), // auto-minted on insert, optional in the type
  kind: t.text(),
  traceId: generated(t.uuid()),        // a generated non-PK uuid
}));

await ctx.db.insert("events", { kind: "signup" });
// -> { id: "9f1c2e3a-…", kind: "signup", traceId: "1b7d…" }   (uuids minted server-side)

SQLite (DO) has no boolean type: booleans are stored as INTEGER 0/1; the runtime handles the coercion for you.

Handlers

createApp(schema) returns query and mutation whose ctx.db is fully inferred from the schema: table names, where columns and value types, row results, and insert/patch shapes are all checked at compile time.

import { createApp } from "pramen/sdk/app";

const { query, mutation } = createApp(schema);

export const handlers = {
  listNotes: query((ctx) =>
    ctx.db.find({ from: "notes", orderBy: { column: "createdAt", dir: "desc" } }),
  ),

  createNote: mutation((ctx, input: { title: string; body: string }) =>
    ctx.db.insert("notes", { title: input.title, body: input.body, createdAt: Date.now() }),
  ),
};
  • Queries read; mutations write. A mutation is automatically wrapped in storage.transaction(): it commits on return and rolls back on throw. Do not write transaction control in handler code.
  • No raw SQL in handlers; go through ctx.db. (ctx.db.exec is an escape hatch and is not ACL-checked.)
  • Side effects after a write (send an email, fire a webhook) go through ctx.tasks.enqueue, a transactional outbox, not an inline call. See Deferred Tasks.

The context is { db, kv, files, env, identity, tasks, mail, queue }: ctx.kv (global config cache), ctx.files (R2, see File Storage), ctx.env (bindings + secrets), ctx.identity (the verified caller), ctx.tasks (deferred work), ctx.mail (transactional email), and ctx.queue (Cloudflare Queues).

Input validation

A handler may declare an input validator that parses the raw body and throws to reject (surfaced as a 400):

createNote: mutation(run, {
  input: (raw) => {
    const o = raw as Record<string, unknown>;
    if (typeof o?.title !== "string") throw new Error("title must be a string");
    return { title: o.title, body: String(o.body ?? "") };
  },
});

Errors

Responses are { ok, result } or { ok: false, error, code } with a real status: ACL denial → 403 forbidden; bad input / unknown handler / failed validate → 400 bad_request. Anything unexpected is logged server-side and returned as a generic 500; stack traces and internal messages never reach the client.