Raises the typescript devDependency across the workspace, bumps package versions, re-adds ignoreDeprecations, and repoints the @wrnexus registry. These were pre-existing working-tree changes, committed as-is rather than authored here. The .npmrc change redirects @wrnexus publishes from registry.npmjs.org to registry.workroot.in — confirm that is intended before publishing from this branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@wrnexus/db
Rollout-safe migrations
Run wrnexus db check in CI before deployment. The analyzer reports stable
diagnostics for drops, renames, type changes, new/enforced required columns,
and potentially blocking index creation, with an expand/backfill/switch/contract
recommendation. wrnexus db migrate blocks critical issues in pending
migrations. --allow-breaking is an explicit operator override; already-applied
migrations do not block later releases.
The database layer for WrNexus: TS models as the single source of truth for DDL, validation, and result typing, plus a driver-based
Dbclient, migrations, and a sqlc-style query generator.
Part of the WrNexus framework — an SSR-first, Bun-native full-stack web framework.
Overview
@wrnexus/db is the server-side data layer. You describe tables as TypeScript
models (the v column builder + table()); those models drive migrations,
coerce raw DB rows into typed objects, and feed the query generator. A thin
Driver interface is implemented by adapters for SQLite (bun:sqlite),
Postgres/MySQL (Bun.SQL), and MongoDB. The Db client adds ergonomics —
model-mapped all/one, transactions, createTable, pagination, and batched
relation loading. A process-wide registry (getDb/setDb) exposes configured
connections to pages and API routes. Reach for it whenever a WrNexus app needs
persistence.
Installation
bun add @wrnexus/db
Private package — the machine must be authenticated to the
wrnexusnpm org (a read token in~/.npmrc). Requires Bun (Node is not supported).
API
The core entry (@wrnexus/db) is dependency-free; adapters and connectors live
in subpaths so importing the core doesn't pull in every driver.
| Subpath | Exports |
|---|---|
@wrnexus/db |
v, table, Column, createDb, createTableSql, the client registry (setDb/getDb/…), migrations, the query generator, and query helpers |
@wrnexus/db/connect |
connectFromConfig, resolveDbUrl, DbConfig — resolve a config to a live SQL Db |
@wrnexus/db/session |
sqliteSessionStore — a bun:sqlite session backend for @wrnexus/core |
@wrnexus/db/sqlite |
sqlite(url?) driver |
@wrnexus/db/postgres |
postgres(url) driver |
@wrnexus/db/mysql |
mysql(url) driver |
@wrnexus/db/mongo |
mongo(url, dbName?) document API |
Schema — v, table, Column
table(name, columns) returns a Model<T>. Columns are built with v:
import { v, table } from "@wrnexus/db";
const users = table("users", {
id: v.id(), // auto-increment primary key
email: v.text().unique(),
name: v.text().optional(), // NULLable
age: v.int().default(0),
active: v.bool().default(true),
createdAt: v.timestamp().default("now"), // CURRENT_TIMESTAMP
});
Column builders: v.id, v.text (alias v.string), v.int, v.real (alias
v.number), v.bool (alias v.boolean), v.timestamp, v.json. BaseType
values are "id" | "text" | "int" | "real" | "bool" | "timestamp" | "json".
Column modifiers (chainable): .optional(), .unique(), .default(value)
(use the sentinel "now" for a current-timestamp default), .primaryKey(),
.references(table, column = "id"). .coerce(raw) converts a raw DB value to
its JS type.
A Model<T> exposes: name, columns, parse(row) (coerces a raw row into a
typed T; unknown columns pass through), and describe() (returns each
column's ColumnDef, for migrations and the generator).
Driver & client — createDb, Db, Driver
createDb(driver: Driver): Db
A Driver (implemented by adapters) exposes dialect, query(sql, params?),
exec(sql, params?), transaction(fn), and close(). createDb wraps it in a
Db:
all<T>(sql, params?, model?)— all rows, mapped throughmodel.parsewhen a model is given.one<T>(sql, params?, model?)— first row ornull.exec(sql, params?)—Promise<ExecResult>({ changes, lastInsertId? }).tx(fn)— runfn(db)in a transaction; rolls back on throw. Nestedtxreuses the current transaction.createTable(model)— runs the model'sCREATE TABLE IF NOT EXISTSDDL.close()— idempotently rejects new top-level work, drains active queries and transactions, then closes the underlying pool.
Every query is parameterized (positional params). createTableSql(model, dialect, ifNotExists?)
renders CREATE TABLE directly; Dialect is "sqlite" | "postgres" | "mysql".
Client registry — getDb / setDb
A process-wide registry the runtime configures at startup from wrnexus.config.ts
(the db setting is the default; databases.<name> entries are named).
setDb(db)/setDb(name, db)— set the default or a named connection.registerDb(name, db)— alias ofsetDb(name, db).getDb(name = "default")— the default or a namedDb(throws if unconfigured).hasDb(name?),databaseNames(),closeDatabases(). Registry shutdown clears registrations first, attempts every open database, and reports close failures together withAggregateErrorinstead of leaking later pools.
const users = await getDb().all("SELECT * FROM users");
const events = await getDb("analytics").all("SELECT * FROM hits");
Adapters
@wrnexus/db/sqlite—sqlite(url = ":memory:").urlmay befile:./dev.db, a raw path, or:memory:. Built onbun:sqlite; no external service.@wrnexus/db/postgres—postgres(url)(e.g.postgres://user:pass@host:5432/db, placeholders$N).@wrnexus/db/mysql—mysql(url)(e.g.mysql://user:pass@host:3306/db, placeholders?). Postgres/MySQL both use Bun's nativeBun.SQLclient and its pooledbegin()for transactions.@wrnexus/db/mongo—mongo(url, dbName?). A document API, not SQL:db.collection(model)returns aMongoRepo<T>withfind,findOne,insert,insertMany,update,delete,count. Reads are coerced throughmodel.parse(_idis mapped toid). Themongodbdriver is imported lazily — install it to use Mongo.
Migrations
Migrations are .sql files (in e.g. app/db/migrations), each split into
-- +up and -- +down sections. A file with no markers is treated entirely as
up. Applied names are recorded in a _wrn_migrations table so each runs once.
parseMigration(name, content)→Migration({ name, up, down }).loadMigrations(dir)— parse all.sqlfiles, sorted by filename.appliedMigrations(db)— applied names, oldest first.migrate(db, dir, options?)— apply all pending (each in a transaction); returns applied names.rollback(db, dir, options?)— roll back the most recent; returns its name ornull.status(db, dir)—{ name, applied }[]for every migration file.scaffoldMigration(dir, name, dialect, models?)— write a new numbered migration; withmodelsit generatesCREATE/DROPfor every table (referenced tables first via topological sort). Returns the file path.
Query generator (sqlc-style)
Turns annotated SQL into typed TS functions; params and result types are
inferred from the models, and rows map back through model.parse when the
selected columns are model columns.
parseQueries(content)→QueryDef[]from-- name: X :one|:many|:execblocks.generateQueriesFile(queries, models, dialect)→ thequeries.gen.tssource.modelsis aModelRef[]({ varName, model }). Rewrites:nameplaceholders to positional ($N/?) form.
QueryKind is "one" | "many" | "exec".
Query helpers
paginate(db, { sql, params?, countSql?, model? }, opts?)— offset pagination. Pass the base SELECT without a LIMIT; it appends the page window and derivestotalvia a COUNT subquery.PageOptions:{ page?, perPage?, maxPerPage? }(defaults page 1, perPage 20, maxPerPage 100). ReturnsPaginated<T>(items, page, perPage, total, totalPages, hasNext, hasPrev).loadRelated(db, parents, opts)— load a relation for many parents in ONE query and attach it (no N+1).RelationOptions:{ table, foreignKey, as, localKey?, single?, model? }—single: trueattaches one child (belongsTo), otherwise an array (hasMany). Table/foreign-key names are validated as identifiers.
Session store
@wrnexus/db/session exports sqliteSessionStore(path = "sessions.db"), a
persistent, process-shared SessionBackend (from @wrnexus/core) backed by
bun:sqlite (WAL mode). Sessions survive restarts and are shared by every
worker on the same file.
Usage
Define models, connect, create tables, and query with typed results:
import { v, table, createDb } from "@wrnexus/db";
import { sqlite } from "@wrnexus/db/sqlite";
const users = table<{ id: number; email: string; name: string | null }>("users", {
id: v.id(),
email: v.text().unique(),
name: v.text().optional(),
createdAt: v.timestamp().default("now"),
});
const db = createDb(sqlite("file:./dev.db"));
await db.createTable(users);
await db.exec("INSERT INTO users (email) VALUES (?)", ["a@b.com"]);
const list = await db.all("SELECT * FROM users", [], users); // rows typed + coerced
const one = await db.one("SELECT * FROM users WHERE id = ?", [1], users);
await db.tx(async (tx) => {
await tx.exec("UPDATE users SET name = ? WHERE id = ?", ["Ada", 1]);
});
Resolve a config to a live SQL Db, and register it:
import { connectFromConfig } from "@wrnexus/db/connect";
import { setDb, getDb } from "@wrnexus/db";
setDb(connectFromConfig({ driver: "sqlite", url: "file:./dev.db" }, process.cwd()));
const rows = await getDb().all("SELECT * FROM users");
Run migrations and paginate:
import { migrate, paginate } from "@wrnexus/db";
await migrate(db, "app/db/migrations");
const pageTwo = await paginate(
db,
{ sql: "SELECT * FROM users ORDER BY id", model: users },
{ page: 2 },
);
For deployments, { dryRun: true } reports pending names without applying
their SQL, signal cancels safely between migrations, and the default
database-backed lock prevents concurrent deploy runners. A live lock produces
WRN-DB-MIGRATION-LOCKED; crash-stale locks expire after lockTimeoutMs (five
minutes by default). Disable it with lock: false only when an external deploy
coordinator already guarantees exclusivity.
const pending = await migrate(db, "app/db/migrations", { dryRun: true });
await migrate(db, "app/db/migrations", {
signal: shutdownController.signal,
lockTimeoutMs: 10 * 60_000,
});
MongoDB (document API):
import { mongo } from "@wrnexus/db/mongo";
const mdb = await mongo(process.env.MONGO_URL!, "app");
const repo = mdb.collection(users);
await repo.insert({ email: "a@b.com" });
const active = await repo.find({ active: true });
Configuration
connectFromConfig (and the runtime) read a DbConfig ({ driver, url })
where driver is sqlite | postgres | mysql. resolveDbUrl(url, appRoot?)
resolves a relative file:/sqlite: URL against the app root. MongoDB is not a
SQL driver — use @wrnexus/db/mongo directly.
Requirements / Notes
- Bun-only. Uses
bun:sqlite(SQLite adapter + session store) andBun.SQL(Postgres/MySQL). Migrations/scaffolding usenode:fs/node:path. - Works with
@wrnexus/core—sqliteSessionStoreimplements itsSessionBackend;getDb/setDbare connected by the WrNexus runtime fromwrnexus.config.ts. - The
mongodbnpm package is an optional, lazily-imported peer — install it only if you use@wrnexus/db/mongo. The core package stays dependency-free.
Repository and transaction helpers
Repositories accept an immutable equality scope, normally { column: "tenant_id", value: ctx.tenant.id }. The scope is injected into every read, count, update and delete, while create
overwrites any caller-supplied tenant value. This makes accidental cross-tenant CRUD through the
repository API fail closed.
import { createRepository, retryTransaction, databaseHealth, batch } from "@wrnexus/db";
const users = createRepository<User>(db, {
table: "users",
allowedColumns: ["email", "name", "active"],
});
const user = await users.require(42);
await users.update(42, { active: true });
Repository identifiers are validated, writes may be restricted to an allowlist, and values always use query parameters. Infrastructure packages remain helper-only and do not add UI dependencies to server code.
0.8 repository and transaction helpers
import { createRepository, databaseHealth, firstOrThrow, retryTransaction } from "@wrnexus/db";
const usersRepo = createRepository<User>(db, {
table: "users",
allowedColumns: ["email", "name", "active"],
maxListLimit: 250,
});
const users = await usersRepo.all({
orderBy: "name",
direction: "asc",
limit: 50,
offset: 0,
});
Repository SQL identifiers are validated and values remain parameterized. Placeholder generation is dialect-aware: PostgreSQL uses $1, $2, and SQLite/MySQL use ?. List limits are bounded.
retryTransaction() retries recognized serialization, deadlock, and database-lock errors by default. Supply shouldRetry for application-specific retryable errors; ordinary validation or business errors are not retried automatically.