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).
Modifiers wrap a builder and compose: notNull(), unique(), indexed(),
defaultTo(value), primaryKey(), and generated() ā e.g.
code: unique(t.text()), status: defaultTo(t.text(), "pending").
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 (datetime('now'))
}));
await ctx.db.insert("posts", { });
// -> { id: 1, status: "draft", createdAt: "2026-06-22 21:05:00" } (DB-filled)
expr.now() is the current UTC timestamp as TEXT (the CURRENT_TIMESTAMP shape);
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.
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.)
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.