Crizzle
Crizzle, reached through cruft:orm, is Cruft's query builder and ORM over the in-process postcrust and CQuilite engines. Because the database is a function call away, every row is validated against its declared type as it crosses into your program, halting, sanitizing, or flagging a value that drifted from the schema.
Tools like Prisma and Drizzle share a common shape: declare a schema, a generator produces types, and the editor shows a User whose email is a string. The promise is a codegen promise. At run time the row comes back over a socket as untyped bytes, gets shaped into an object, and is handed back as a User on the generator's word. If a column is nullable in a way the schema didn't capture, or a migration drifted, the null arrives in a field the type declares a string, and nothing catches it. The types are a build-time story about data that only shows up at run time.
Cruft's ORM, cruft:orm (internally Crizzle, a nod to Drizzle as its DX yardstick), makes a different choice, one only an in-process ORM over an owned engine can make: the ORM is a Boundary. A row crossing from the database into the program is runtime-validated against its declared type at the crossing, so type-safety is an enforced guarantee rather than codegen trust. This page covers the thesis, the shipped surface, and the runtime behavior.
The thesis: trust vs. validation
Every ORM answers "how do database rows become typed objects?" The industry answer is trust: Prisma generates types from a schema file, Drizzle infers them from a builder definition, and both then trust that the running database still matches. Schema drift, a skewed migration, a hand-edited column: all silently return wrong-typed data under a green type-check. For a library talking to a database over a socket, that gap is structural.
Cruft closes it with two architectural facts:
- The database runs in-process.
openPostgres()opens postcrust, Cruft's PostgreSQL-parity engine living inside the runtime (andopenSqlite()its CQuilite peer). The crossing from rows to objects is a function call, not a wire protocol, cheap enough to validate every row, with the live catalog directly readable. - CruftScript's Boundary System treats the meeting point of sound types and unsound data as a first-class runtime contract. A database is the canonical unsound data source, so query results cross through a validated boundary with three continue-modes on mismatch.
The three continue-modes
Given a contract (a table name, the live schema's row type, or an inline descriptor), a drifting result behaves per mode. In the outputs below, drift is simulated by projecting a NULL into a NOT NULL column:
HALT (db.query(sql, "users")), the secure default; the violation is an error carrying the full resolution chain:
CruftBoundaryError: soundness violation (null in non-null type):
column "name" expected string, received NULL (row 0)
PROPAGATE (db.queryPropagate(sql, "users")), rows flow through with the violating values intact, and the result carries a provenance array:
rows // [{ id: 1, name: null, age: 36 }, …]
rows.propagations
// [{ column: "name", row: 0, expected: "string", received: "NULL",
// kind: "null-in-non-null" }, …]
SANITIZE (db.querySanitize(sql, "users", { string: "(missing)" })), violating cells are replaced from type-keyed defaults, with provenance:
rows // [{ id: 1, name: "(missing)", age: 36 }, …]
rows.sanitizations
// [{ column: "name", row: 0, …, replacedWith: "(missing)" }, …]
Two semantics worth knowing. A table-name contract expects the full row shape: a projected SELECT name, age against the "users" contract HALTs with column "id" … <absent from result>; project with SELECT * or supply a matching descriptor. And a query without a contract is refused: db.query(sql) with no table name or descriptor throws query needs a contract (table name or descriptor). There is no raw, unvalidated crossing through query; every result that reaches your code has passed a contract.
The query builder
db.from(table) starts a fluent, schema-aware builder in the familiar Drizzle/Kysely shape, but the builder's state lives as a Rust query IR behind the handle (each method mutates it in place). It lowers to SQL with every user value escaped and type-cast, so a '; DROP TABLE users; -- value is stored verbatim and executes nothing, and its result crosses the same boundary. See the note below for the precise mechanism, which is escaped-literal lowering, not driver parameter binding, and what that distinction means.
import { openPostgres } from "cruft:orm";
const db = openPostgres();
db.exec("CREATE TABLE users (id INT PRIMARY KEY, name TEXT NOT NULL, age INT)");
db.from("users").where("age", ">", 40).all();
// [{ id: 2, name: "grace", age: 45 }]
db.from("users").select(["name", "age"]).orderBy("id").limit(1).all();
db.from("users").where("id", "=", 1).get(); // one row
db.from("users").count("n").get(); // { n: 2n } (BigInt)
db.from("users").avg("age", "a").get(); // { a: 40.5 }
The read surface: where(col, op, value), equi-joins (join/joinInner/joinLeft/joinRight/joinFull, projected columns named table_col), with(relation) for FK-derived eager loading (batched IN, no N+1, each nesting level boundary-validated), groupBy, the aggregates (count, countCol, sum, avg, min, max, each taking an alias), select([cols]), orderBy, limit, offset, and the terminals all() / get(). Note count returns a BigInt.
Writes
db.insertInto("users").values({ id: 3, name: "kay", age: 70 }).run();
// { affected: 1 }
db.update("users").set({ age: 37 }).where("id", "=", 1)
.returning(["name", "age"]).run();
// { affected: 1, rows: [{ name: "ada", age: 37 }] }
db.deleteFrom("users").where("id", "=", 2).run();
// { affected: 1 }
db.insertInto("users").values({ id: 1, name: "dup", age: 1 })
.onConflictDoNothing(["id"]).run(); // upsert: skip
db.insertInto("users").values({ id: 1, name: "new", age: 1 })
.onConflictDoUpdate(["id"], ["name"]).run(); // upsert: update cols
A returning result crosses the boundary like a read. Both forms of onConflictDoNothing work: the targeted onConflictDoNothing(["id"]) and the no-target onConflictDoNothing() (which skips on any conflict) both run without error.
Capability posture
The db handle is an ordinary value, which in Cruft's model makes it an endowable capability: grant it into a compartment's globals and that compartment can query; withhold it and no ambient path to the data exists. Which code can touch which data is governed by the same machinery as the rest of the isolation story, a posture socket ORMs structurally lack. (Row/column-scoped capabilities are a natural extension, not yet shipped.)
What is shipped vs. designed
Shipped today: both engine factories, contracted queries with all three continue-modes and provenance, the full read builder, the write builders with targeted and no-target upserts and validated returning, on both postcrust and sqlite (the builders are engine-agnostic; only the terminals dispatch).
Staged work: schema derivation into CruftScript types (DB-first codegen and code-first checking against the live catalog), installing result types for .fts consumers so a typed core can read rows directly, and capability scoping below the handle level. These are designed, not yet built.
The injection mechanism, and what the boundary is not
Two things a reader should know precisely.
Injection safety comes from escaping. The builder does not lower to driver-bound parameters. The SQLite path interpolates each value directly as an escaped literal, and the Postgres path builds placeholder SQL but then substitutes the values back into the SQL text before execution. Both escape by doubling ' and adding type casts. The end-to-end safety property holds (the classic drop-table payload is stored as data and executes nothing), but it rests on that escaper being complete rather than on driver-level parameter binding. Its soundness therefore depends on the engines using standard-conforming string literals, and the escaper has not been fuzzed. This is a weaker guarantee than true binding: the mechanism is "escaped-literal lowering", not "parameterized".
The Boundary is a soundness check. The HALT / PROPAGATE / SANITIZE continue-modes are real, enforcing, well-tested code, but they validate data flowing from the database into JavaScript against its declared type (schema drift). They have nothing to do with SQL injection, and SANITIZE is not an input sanitizer. Injection safety and the boundary are separate code paths, and neither should be described as providing the other.
Identifier handling is also thinner than value handling: the Postgres path relies on catalog membership rather than quoting a user-supplied column, and the SQLite path quotes but does not catalog-validate. Neither is a known hole, but both are defense-in-depth gaps rather than guarantees.