Crizzle Core
Crizzle Core is the dialect-neutral engine under Cruft's ORM, the crate that powers the query builder for SQLite and Postcrust. It escapes values into SQL literals for injection resistance, and adds a typed result boundary that checks every row coming back from the database against the type you declared.
An ORM lets you write where('email', userInput), read the word "parameterized" in the README, and stop thinking about SQL injection. In sqlx or Prisma the library binds each value: it rides to the database out of band as a bound parameter and never becomes part of the query text, so hostile input cannot become SQL structure.
Crizzle Core, the dialect-neutral heart of Cruft's ORM (the crizzle-core crate), is Cruft's answer to that promise, and it makes two decisions worth knowing before you trust it. Its injection defense is escaped-literal interpolation rather than driver parameter binding, and it adds a second mechanism most ORMs do not have: a typed result boundary that validates every row coming back from the database against the type you expect. The two are separate guarantees pointing in opposite directions, and this page is careful to keep them apart, because conflating them would be exactly the kind of mistake that gets an adopter hurt. The developer-facing surface it powers is documented on the ORM overview; this page is the engine underneath.
Alpha, unaudited. Crizzle Core 0.0.10 ships inside Cruft 0.0.10. It is the shared ORM and query-builder core that, with its dialect backends, stands in forsqlx/diesel. It has not been externally audited, has thin interop testing against real database engines, and is not production-ready. Do not point it at adversarial SQL as your only defense.
Opening a database
Two entry points, one per dialect. There is deliberately no bare open(), since neither dialect is the default.
import { openSqlite, openPostgres } from "cruft:orm";
const mem = openSqlite(); // fresh in-memory
const app = openSqlite("app.sqlite"); // file-backed, real sqlite3 format
const pg = openPostgres(); // fresh in-memory Postgres-parity catalog
No argument (or ":memory:") gives a fresh in-memory database; each in-memory connection is fully isolated from every other. A path is file-backed: SQLite writes genuine sqlite3 files, and openPostgres writes postcrust's own logical catalog (it is a Postgres-parity in-process engine, not a wire connection to a real PostgreSQL server). A file-backed Postgres catalog takes an exclusive lock, so a second open of the same path throws openPostgres: database is already open for writing.
The query builder
db.from(table) returns a chainable builder. The read surface:
A read returns plain JS objects keyed by column:
db.from("users")
.where("age", ">=", 30)
.orderBy("age", "desc")
.select(["email", "age"])
.all();
// [ { email: "bob@x", age: 41 }, { email: "ada@x", age: 36 } ]
Joins project each column namespaced as table_col (r.orders_total, r.users_name), and an outer join widens unmatched columns to accept null. .with("orders") gives each parent row an orders field: an array for a has-many relation, a single object or null for a belongs-to. Aggregate results for counts and sums come back as BigInt, so wrap them in String() when you concatenate.
Writes mirror the reads:
db.insertInto("users").values({ email, age }).returning(true).run();
// { affected: 1, rows: [ { id: 1, email: "ada@x", age: 36 } ] }
db.update("users").set({ age: 37 }).where("id", "=", 1).run(); // { affected: 1 }
db.deleteFrom("users").where("id", "=", 9).run(); // { affected: 1 }
A batched insert takes columns from the first row's keys and reads later rows by those columns, so key order in the rest of the batch does not matter. Upserts are insert-only: .onConflictDoNothing([target]) and .onConflictDoUpdate([target], [setCols]), where a DO UPDATE sets each column to its excluded (incoming) value.
How values stay safe
Pass a value through the builder and it never becomes SQL structure. A hostile string is escaped and rendered as a single SQL literal:
db.from("users").where("email", "=", "'; DROP TABLE users; --").all();
// matches nothing; the table is untouched
db.insertInto("logs").values({ note: "'; DROP TABLE users; --" }).run();
// stored verbatim as data; nothing executes
The mechanism is escaped-literal interpolation, and it is worth being precise about, because the common mental model is wrong here. Values are not sent to a driver as bound parameters. Each value is escaped (single quotes doubled, the literal wrapped in a type cast) and written directly into the SQL text that the in-process engine parses. Both dialects work this way; the Postgres path builds $n placeholders while lowering and then substitutes the escaped literals back in before execution, so nothing crosses to a binding API. Injection resistance is real, and the escaper is exercised by a fuzz harness that feeds arbitrary bytes as both values and identifiers, but the guarantee rests on that escaper being complete rather than on the value never touching the query string.
Table, column, and alias names are handled differently: they are not escaped, they are validated against an allowlist. A safe identifier starts with _ or an ASCII letter and continues with _, ASCII letters, or digits; anything else, a leading digit, a quote, a .-qualified name, users; DROP TABLE users; --, is rejected before it can reach the SQL. So identifiers can never be arbitrary user-controlled strings, and a qualified name like public.users is refused rather than quoted.
One boundary to hold onto: this safety belongs to the builder. db.query(sql) and db.exec(sql) take a raw SQL string you wrote, and interpolating user input into that string yourself has no protection. Use the builder for values that come from outside.
The result boundary
Crizzle Core's distinctive capability is a typed boundary on the way back. Every row a query returns is checked, cell by cell, against the type you say to expect, as it crosses from the database into the sound CruftScript compartment. This is a guard most ORMs do not have, and it catches a specific failure a code-first ORM built on trusting the schema cannot: live data that has drifted away from the declaration (a column that started returning NULL, an enum that gained a value mid-session).
Because the check needs a type to compare against, a query requires a contract, and omitting it is an error rather than a silent raw-row return:
db.query("SELECT id, name FROM users", "users"); // table name
db.query("SELECT id, role FROM users", { id: "number", role: ["admin", "user"] });
db.query("SELECT * FROM users");
// throws: query needs a contract (table name or descriptor)
A contract is either a table name (types derived from the live schema) or a descriptor object. In a descriptor, each column maps to a tag, number, bigint, boolean, string, Date, bytes, or unknown; a trailing ? marks it nullable; and an array of strings is a literal union. unknown is the bottom type and never fails, for columns (JSON, ranges) you intend to narrow yourself.
The crossing runs in one of three modes:
| Mode | Method | On a drifted cell |
|---|---|---|
| HALT | db.query (default) | throws CruftBoundaryError on the first violation |
| SANITIZE | db.querySanitize(sql, contract, defaults) | replaces the cell with its declared default; reports each repair on .sanitizations; still throws if no default is declared for that type |
| PROPAGATE | db.queryPropagate(sql, contract) | keeps the raw value, retypes the column unknown, reports each on .propagations; never throws on cell drift |
A HALT reads out exactly what went wrong:
CruftBoundaryError: soundness violation (null in non-null type) —
column "name" expected string, received NULL (row 0)
The violation classes are null-in-non-null, not-in-union, type-mismatch, missing-column, unexpected-column, and (under SANITIZE) no-sanitizer-default. The same boundary validates the builder's .all() / .get() results and every level of an eager-loaded relation.
The boundary inspects data coming back from the database. It does not parameterize or sanitize the SQL you send, and SANITIZE is not an input sanitizer. Injection safety comes entirely from the escaping path described above; the boundary is a database-to-JS soundness gate. They are different mechanisms guarding different directions, and each carries only its own guarantee.
Capability scoping
The db handle is a capability. Only a compartment you hand it to can use it:
new Compartment({ globals: { db } }); // this sandbox can query
An ungranted compartment sees typeof db === "undefined" and cannot construct one. Soundness still holds inside a granted compartment: a drift still HALTs.
Limitations
- Injection safety is escaped-literal interpolation, not parameter binding.
sqlxanddieselbind through the driver, so the value never enters the query text at all. That is a categorically stronger guarantee: it does not depend on an escaper being complete against every string mode of every engine. Cruft's escaper is fuzzed against the payload classes it knows, which is weaker than "the value never touches the SQL." - The raw-SQL surface has no protection.
db.queryanddb.execrun the string you give them. Only builder values are escaped for you. - The boundary guards one direction. It validates database-to-JS results; values you write into the database are not checked against a contract. It is a soundness gate, not an injection defense.
- Identifiers must be simple. Only unqualified
[_A-Za-z][_A-Za-z0-9]*names are accepted; schema-qualified, quoted, and non-ASCII identifiers are refused, which is a functionality limit as well as a safety one. - Type checks are runtime and heuristic. Contracts are validated against live data at query time, not proven at compile time;
numberaccepts numeric text,Dateaccepts date-shaped values, and arrays are checked one level deep. A declaration that is wrong but internally consistent passes. - Young and narrow. The API is synchronous and in-process, with no async, no transactions surfaced to JS, no migrations, and no connection pooling.
openPostgresis postcrust, not real PostgreSQL..iter()streaming is SQLite only and only for simple base-table selects. The JS builder surface exposes single-column equi-joins; the core IR itself carries the join condition as a list of column pairs and so supports composite equi-joins at the core level, but the builder does not yet expose them. Relations are derived from foreign keys only (an unknown relation name is skipped), and upserts can only set columns to their incoming value. - The maturity gap is real.
sqlxanddieselcarry years of production hardening, real driver protocols, and broad dialect coverage. This is a focused core with two in-process backends and example-plus-fuzz coverage.