Helper Methods
The shortcuts that save a query-builder chain.
Lookups, upserts, counters, column extraction and raw SQL — all on the repository.
Single-Row Lookups
The …OrFail variants throw instead of returning null, which removes a null-check and produces a proper 404 at the boundary.
await repo.findOne(10); // T | nullawait repo.findOne(10, { relations: ["roles"] });await repo.findOneBy({ email: "ada@example.com" }); // T | nullawait repo.findBy({ active: true }, { limit: 10, orderBy: "createdAt DESC" });await repo.first({ status: "draft" }); // T | nullawait repo.last("createdAt"); // T | null, defaults to "id"await repo.random(); // T | nullawait repo.exists({ email: "ada@example.com" }); // boolean
await repo.findOrFail(10); // T - throws if absentawait repo.firstOrFail({ title: "Second" }); // T - throws if absentimport { StabilizeError } from "stabilize-orm";
try { const user = await repo.findOrFail(id);} catch (error) { if (error instanceof StabilizeError && error.code === "NOT_FOUND_ERROR") { return res.status(404).json({ error: "Not found" }); } throw error;}Create or Update
Two idempotent writes for the common "get it, or make it" case. Both return the row, so the caller does not need to know which branch ran.
// Find by conditions, or create using conditions + defaultsawait repo.firstOrCreate({ email: "ada@example.com" }, { name: "Ada" });
// Update if present, create if notawait repo.updateOrCreate({ email: "ada@example.com" }, { name: "Ada L" });Counters and Toggles
Each applies the change in SQL and returns the updated row, so you see the committed value rather than computing it yourself — no read-modify-write race.
await repo.increment(1, "views"); // views = views + 1await repo.increment(1, "views", 5); // views = views + 5await repo.decrement(1, "stock", 2); // stock = stock - 2await repo.toggle(1, "active"); // flips a booleanReading Columns
When you want a list of values rather than entities, these skip the hydration step:
const emails = await repo.pluck("email"); // string[]const rows = await repo.selectColumns("id", "email"); // Partial<T>[]Bulk Writes
// One transaction, applies _upsert per rowawait repo.bulkUpsert(rows, ["slug"]);
// Chunked in batches of 100 by default - better for very large inputsawait repo.upsertMany(rows, ["slug"], 500);
// Conditional mass update / delete - returns affected row countawait repo.updateBy({ status: "draft" }, { status: "archived" });await repo.deleteBy({ userId: 7 });updateBy() and deleteBy() refuse empty conditions with UNSAFE_QUERY — a guard against a filter that silently became {} and rewrote the whole table. deleteBy() soft-deletes when the model has a soft-delete column, and hard deletes otherwise.
Soft Delete Recovery
The single-row restore is recover(), not restore()
There is no repo.restore(id). The three ways back are recover for one row, restoreBy for a filtered set, and recoverAll for everything.
await repo.recover(5); // one row, returns itawait repo.restoreBy({ userId: 1 }); // affected row countawait repo.recoverAll(); // every soft-deleted row
// Reading deleted rowsawait repo.findDeleted().execute(db.client); // only trashedawait repo.withTrashed().where("id = ?", 5).execute(db.client); // all rowsrestoreBy() needs a soft-delete column and throws RECOVER_ERROR without one, as does recoverAll(). findDeleted() throws QUERY_ERROR in the same case, since "only the deleted rows" has no meaning on a model that never soft deletes.
withTrashed() is different: it does not throw and does not check for a soft-delete column at all. The soft-delete filter is added to every other builder entry point — find(), findBy(), findOne() and the rest — and withTrashed() is simply the one that returns a bare builder with no filter attached. On a model without a soft-delete column that is what an ordinary query already returns, so the call is a no-op rather than an error.
Raw SQL and Truncate
// Repository - parameterized readawait repo.rawQuery("SELECT * FROM users WHERE age > ?", [18]);
// ORM client - read and execawait db.rawQuery("PRAGMA table_info(users)");await db.rawExec("CREATE TABLE post_tags (post_id INTEGER, tag_id INTEGER)");// => { affectedRows: number }
await repo.truncate(); // hard DELETE of every row, ignores soft deleterawExec exists only on the Stabilize client, not on a repository. Both take positional ? placeholders — never interpolate values into the string yourself.
Streaming Over Results
For a job that touches every row, these page internally so the whole table is never in memory at once.
// Transform each row, collect resultsconst titles = await repo.map(repo.find(), (post) => post.title);
// Side effect per row, paged at 100await repo.each(repo.find(), async (post, index) => { await indexer.add(post, index);});
// Callback receives a whole batch, not one rowawait repo.eachBatch(repo.find(), async (batch) => { await search.bulkIndex(batch);}, 500);Both accept a QueryBuilder and clone it, so the builder you pass in is not mutated. The third argument is the page size: each takes pageSize, eachBatch takes batchSize — positionally the same, both defaulting to 100. Pagination is offset-based, so rows inserted during a long run can shift the window.
Utilities
import { generateUUID, sqlDefault } from "stabilize-orm";
generateUUID(); // crypto.randomUUID()
// Use as defaultExpression, not defaultValuecolumns: { id: { type: DataTypes.UUID, defaultExpression: sqlDefault("gen_random_uuid()") },}sqlDefault() marks a value as raw SQL to be emitted verbatim. Note that AutoMigrate emits only defaultValue when it creates a table — a defaultExpression is not included, so supply the default in the migration if the database itself must apply it.