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 emitdatetime('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 belowT), and a{ lte: $now() }policy lets a row scheduled for later today through. DeclareisoTimestampBackfill()inapp.migrationsto 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;extraColumnsnames the ones written by handler code, which have no default to find. Declaring it on a fresh store costs nothing: everyUPDATEmatches 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.execis 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.