first commit

This commit is contained in:
2026-07-12 15:55:18 +05:30
commit ee98026cc5
404 changed files with 44522 additions and 0 deletions
+228
View File
@@ -0,0 +1,228 @@
# @wrnexus/db
> The database layer for WrNexus: TS models as the single source of truth for DDL, validation, and result typing, plus a driver-based `Db` client, 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
```bash
bun add @wrnexus/db
```
> Private package — the machine must be authenticated to the `wrnexus` npm 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`:
```ts
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`
```ts
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 through `model.parse` when a model is given.
- `one<T>(sql, params?, model?)` — first row or `null`.
- `exec(sql, params?)``Promise<ExecResult>` (`{ changes, lastInsertId? }`).
- `tx(fn)` — run `fn(db)` in a transaction; rolls back on throw. Nested `tx` reuses the current transaction.
- `createTable(model)` — runs the model's `CREATE TABLE IF NOT EXISTS` DDL.
- `close()`.
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 of `setDb(name, db)`.
- `getDb(name = "default")` — the default or a named `Db` (throws if unconfigured).
- `hasDb(name?)`, `databaseNames()`, `closeDatabases()`.
```ts
const users = await getDb().all("SELECT * FROM users");
const events = await getDb("analytics").all("SELECT * FROM hits");
```
### Adapters
- `@wrnexus/db/sqlite``sqlite(url = ":memory:")`. `url` may be `file:./dev.db`, a raw path, or `:memory:`. Built on `bun: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 native `Bun.SQL` client and its pooled `begin()` for transactions.
- `@wrnexus/db/mongo``mongo(url, dbName?)`. A document API, not SQL: `db.collection(model)` returns a `MongoRepo<T>` with `find`, `findOne`, `insert`, `insertMany`, `update`, `delete`, `count`. Reads are coerced through `model.parse` (`_id` is mapped to `id`). The `mongodb` driver 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 `_wire_migrations` table so each runs once.
- `parseMigration(name, content)``Migration` (`{ name, up, down }`).
- `loadMigrations(dir)` — parse all `.sql` files, sorted by filename.
- `appliedMigrations(db)` — applied names, oldest first.
- `migrate(db, dir)` — apply all pending (each in a transaction); returns applied names.
- `rollback(db, dir)` — roll back the most recent; returns its name or `null`.
- `status(db, dir)``{ name, applied }[]` for every migration file.
- `scaffoldMigration(dir, name, dialect, models?)` — write a new numbered migration; with `models` it generates `CREATE`/`DROP` for 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|:exec` blocks.
- `generateQueriesFile(queries, models, dialect)` → the `queries.gen.ts` source. `models` is a `ModelRef[]` (`{ varName, model }`). Rewrites `:name` placeholders 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 derives `total` via a COUNT subquery. `PageOptions`: `{ page?, perPage?, maxPerPage? }` (defaults page 1, perPage 20, maxPerPage 100). Returns `Paginated<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: true` attaches 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:
```ts
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:
```ts
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:
```ts
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 },
);
```
MongoDB (document API):
```ts
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) and `Bun.SQL`
(Postgres/MySQL). Migrations/scaffolding use `node:fs`/`node:path`.
- Works with `@wrnexus/core``sqliteSessionStore` implements its
`SessionBackend`; `getDb`/`setDb` are wired by the WrNexus runtime from
`wrnexus.config.ts`.
- The `mongodb` npm package is an optional, lazily-imported peer — install it
only if you use `@wrnexus/db/mongo`. The core package stays dependency-free.
+16
View File
@@ -0,0 +1,16 @@
{
"name": "@wrnexus/db",
"version": "0.2.12",
"private": true,
"type": "module",
"main": "src/index.ts",
"exports": {
".": "./src/index.ts",
"./connect": "./src/connect.ts",
"./session": "./src/session-store.ts",
"./sqlite": "./src/adapters/sqlite.ts",
"./postgres": "./src/adapters/postgres.ts",
"./mysql": "./src/adapters/mysql.ts",
"./mongo": "./src/adapters/mongo.ts"
}
}
+69
View File
@@ -0,0 +1,69 @@
/**
* Postgres + MySQL adapters built on Bun's native SQL client (`Bun.SQL`) — no
* external driver dependency. Both speak the same `Driver` interface; only the
* dialect (and thus the DDL types + placeholder style) differ.
*
* `Bun.SQL` pools connections, so transactions use its managed `begin(fn)` to
* keep BEGIN/…/COMMIT on one reserved connection.
*/
import type { Dialect } from "../sql.ts";
import type { Driver, Row, TxHandle } from "../driver.ts";
interface BunSqlClient {
unsafe(query: string, params?: unknown[]): Promise<unknown>;
begin<T>(fn: (tx: BunSqlClient) => Promise<T>): Promise<T>;
close(): Promise<void>;
}
interface ExecMeta {
count?: number;
affectedRows?: number;
lastInsertRowid?: number | bigint;
insertId?: number | bigint;
}
function runnerFor(client: BunSqlClient): TxHandle {
return {
async query(sql, params = []): Promise<Row[]> {
const rows = (await client.unsafe(sql, params)) as Iterable<Row>;
return Array.from(rows);
},
async exec(sql, params = []) {
const meta = (await client.unsafe(sql, params)) as ExecMeta;
const id = meta.lastInsertRowid ?? meta.insertId;
return {
changes: Number(meta.affectedRows ?? meta.count ?? 0),
lastInsertId: id != null ? Number(id) : undefined,
};
},
};
}
/** Create a Bun.sql-backed driver for the given connection URL + dialect. */
export function bunSql(url: string, dialect: Dialect): Driver {
const Ctor = (Bun as unknown as { SQL: new (u: string) => BunSqlClient }).SQL;
const client = new Ctor(url);
const runner = runnerFor(client);
return {
dialect,
query: runner.query,
exec: runner.exec,
transaction(fn) {
return client.begin((tx) => fn(runnerFor(tx)));
},
close() {
return client.close();
},
};
}
/** PostgreSQL adapter (`postgres://user:pass@host:5432/db`). Placeholders: `$N`. */
export function postgres(url: string): Driver {
return bunSql(url, "postgres");
}
/** MySQL adapter (`mysql://user:pass@host:3306/db`). Placeholders: `?`. */
export function mysql(url: string): Driver {
return bunSql(url, "mysql");
}
+120
View File
@@ -0,0 +1,120 @@
/**
* MongoDB adapter. Mongo is a document store, not SQL, so it does NOT use the
* SQL `Driver`/migrations/query-generator. Instead it exposes a small, typed
* collection API keyed by your models — reads are still coerced through
* `model.parse`, so results match your schema.
*
* The `mongodb` driver is imported lazily (install it to use Mongo); the core
* `@wrnexus/db` stays dependency-free.
*
* const db = await mongo(process.env.MONGO_URL!, "app");
* const repo = db.collection(users);
* await repo.insert({ email, name });
* const active = await repo.find({ active: true });
*/
import type { Model } from "../schema.ts";
// Minimal shape of the `mongodb` driver we rely on (typed locally so this file
// compiles without `mongodb` installed).
interface MongoCollection {
find(filter: object, options?: object): { toArray(): Promise<Record<string, unknown>[]> };
findOne(filter: object): Promise<Record<string, unknown> | null>;
insertOne(doc: object): Promise<{ insertedId: unknown }>;
insertMany(docs: object[]): Promise<{ insertedCount: number }>;
updateMany(
filter: object,
update: object,
): Promise<{ matchedCount: number; modifiedCount: number }>;
deleteMany(filter: object): Promise<{ deletedCount: number }>;
countDocuments(filter: object): Promise<number>;
}
interface MongoDatabase {
collection(name: string): MongoCollection;
}
interface MongoClientLike {
connect(): Promise<unknown>;
db(name?: string): MongoDatabase;
close(): Promise<void>;
}
export interface FindOptions {
sort?: Record<string, 1 | -1>;
limit?: number;
skip?: number;
}
export interface MongoRepo<T> {
find(filter?: Record<string, unknown>, options?: FindOptions): Promise<T[]>;
findOne(filter: Record<string, unknown>): Promise<T | null>;
insert(doc: Partial<T>): Promise<{ id: unknown }>;
insertMany(docs: Partial<T>[]): Promise<{ count: number }>;
update(
filter: Record<string, unknown>,
patch: Partial<T>,
): Promise<{ matched: number; modified: number }>;
delete(filter: Record<string, unknown>): Promise<{ deleted: number }>;
count(filter?: Record<string, unknown>): Promise<number>;
}
export interface MongoDb {
collection<T>(model: Model<T>): MongoRepo<T>;
close(): Promise<void>;
}
/** Map Mongo's `_id` to `id` so documents line up with model columns. */
function normalize(doc: Record<string, unknown>): Record<string, unknown> {
if (doc && doc._id != null && doc.id == null) {
const { _id, ...rest } = doc;
return { id: String(_id), ...rest };
}
return doc;
}
/** Connect to MongoDB and return a model-aware collection API. */
export async function mongo(url: string, dbName?: string): Promise<MongoDb> {
// Non-literal specifier so this compiles without `mongodb` installed.
const specifier: string = "mongodb";
const mod = (await import(specifier)) as { MongoClient: new (u: string) => MongoClientLike };
const client = new mod.MongoClient(url);
await client.connect();
const database = client.db(dbName);
return {
collection<T>(model: Model<T>): MongoRepo<T> {
const col = database.collection(model.name);
return {
async find(filter = {}, options = {}) {
const docs = await col.find(filter, options).toArray();
return docs.map((d) => model.parse(normalize(d)));
},
async findOne(filter) {
const doc = await col.findOne(filter);
return doc ? model.parse(normalize(doc)) : null;
},
async insert(doc) {
const r = await col.insertOne(doc as object);
return { id: r.insertedId };
},
async insertMany(docs) {
const r = await col.insertMany(docs as object[]);
return { count: r.insertedCount };
},
async update(filter, patch) {
const r = await col.updateMany(filter, { $set: patch });
return { matched: r.matchedCount, modified: r.modifiedCount };
},
async delete(filter) {
const r = await col.deleteMany(filter);
return { deleted: r.deletedCount };
},
count(filter = {}) {
return col.countDocuments(filter);
},
};
},
close() {
return client.close();
},
};
}
+1
View File
@@ -0,0 +1 @@
export { mysql } from "./bunsql.ts";
+1
View File
@@ -0,0 +1 @@
export { postgres } from "./bunsql.ts";
+60
View File
@@ -0,0 +1,60 @@
/**
* SQLite adapter, built on Bun's zero-dependency `bun:sqlite`. Use a file URL
* (`file:./dev.db`) or the default in-memory database (great for tests). This is
* the reference adapter — it needs no external service to run.
*/
import { Database } from "bun:sqlite";
import type { Driver, ExecResult, Row, TxHandle } from "../driver.ts";
/** SQLite can only bind numbers/strings/bigints/null/blobs — coerce JS values. */
function bind(params: unknown[]): unknown[] {
return params.map((p) => {
if (p === true) return 1;
if (p === false) return 0;
if (p === undefined) return null;
if (p instanceof Date) return p.toISOString();
return p;
});
}
/** Create a SQLite driver. `url` may be `file:./x.db`, a path, or `:memory:`. */
export function sqlite(url = ":memory:"): Driver {
const path = url.replace(/^(file:|sqlite:)/, "") || ":memory:";
const database = new Database(path);
database.exec("PRAGMA foreign_keys = ON;");
const runner: TxHandle = {
async query(sql, params = []): Promise<Row[]> {
return database.query(sql).all(...(bind(params) as never[])) as Row[];
},
async exec(sql, params = []): Promise<ExecResult> {
if (params.length === 0) {
database.exec(sql); // DDL / multi-statement
return { changes: 0 };
}
const result = database.query(sql).run(...(bind(params) as never[]));
return { changes: result.changes, lastInsertId: Number(result.lastInsertRowid) };
},
};
return {
dialect: "sqlite",
query: runner.query,
exec: runner.exec,
async transaction(fn) {
database.exec("BEGIN");
try {
const result = await fn(runner);
database.exec("COMMIT");
return result;
} catch (err) {
database.exec("ROLLBACK");
throw err;
}
},
close() {
database.close();
},
};
}
+61
View File
@@ -0,0 +1,61 @@
/**
* A process-wide database registry. The framework configures it at server
* startup from `wrnexus.config.ts`: the `db` setting becomes the **default**
* connection, and each entry under `databases` becomes a **named** connection.
* Pages and API routes then call `getDb()` for the default, or `getDb("<name>")`
* for a named one, to run queries (including the generated typed functions).
*
* const users = await getDb().all("SELECT * FROM users"); // default db
* const events = await getDb("analytics").all("SELECT * FROM hits"); // named db
*/
import type { Db } from "./driver.ts";
const DEFAULT = "default";
const registry = new Map<string, Db>();
/** Set the default database (called by the runtime at startup). */
export function setDb(db: Db): Db;
/** Set a named database (from `databases.<name>` in config). */
export function setDb(name: string, db: Db): Db;
export function setDb(a: string | Db, b?: Db): Db {
const name = typeof a === "string" ? a : DEFAULT;
const db = typeof a === "string" ? b! : a;
registry.set(name, db);
return db;
}
/** Register a named database. Alias of `setDb(name, db)` for readability. */
export function registerDb(name: string, db: Db): Db {
return setDb(name, db);
}
/** The default database, or a named one. Throws if it isn't configured. */
export function getDb(name = DEFAULT): Db {
const db = registry.get(name);
if (!db) {
throw new Error(
name === DEFAULT
? "No database configured. Add `db: { driver, url }` to wrnexus.config.ts."
: `No database named '${name}'. Add it under \`databases\` in wrnexus.config.ts ` +
`(e.g. databases: { ${name}: { driver, url } }).`,
);
}
return db;
}
/** Whether the default (or a named) database has been configured. */
export function hasDb(name = DEFAULT): boolean {
return registry.has(name);
}
/** Names of all configured databases (the default appears as "default"). */
export function databaseNames(): string[] {
return [...registry.keys()];
}
/** Close every configured database and clear the registry. */
export async function closeDatabases(): Promise<void> {
for (const db of registry.values()) await db.close();
registry.clear();
}
+42
View File
@@ -0,0 +1,42 @@
/**
* Resolve a `db` config (from wrnexus.config.ts) to a live SQL `Db`. Kept in a
* subpath (`@wrnexus/db/connect`) so importing the core `@wrnexus/db` doesn't pull
* in every adapter. MongoDB is not here — it uses a document API (`@wrnexus/db/mongo`).
*/
import { isAbsolute, join } from "node:path";
import { createDb, type Db } from "./index.ts";
import { sqlite } from "./adapters/sqlite.ts";
import { postgres } from "./adapters/postgres.ts";
import { mysql } from "./adapters/mysql.ts";
export interface DbConfig {
driver: string;
url: string;
}
/** Resolve a `file:`/`sqlite:` URL's relative path against the app root. */
export function resolveDbUrl(url: string, appRoot?: string): string {
const m = /^(?:file:|sqlite:\/\/|sqlite:)(.*)$/.exec(url);
if (!m || !appRoot) return url;
const path = m[1]!.replace(/^\.\//, "");
return `file:${isAbsolute(path) ? path : join(appRoot, path)}`;
}
/** Build the configured SQL database (resolving a file URL against `appRoot`). */
export function connectFromConfig(config: DbConfig, appRoot?: string): Db {
const url = resolveDbUrl(config.url, appRoot);
switch (config.driver) {
case "sqlite":
return createDb(sqlite(url));
case "postgres":
return createDb(postgres(url));
case "mysql":
return createDb(mysql(url));
default:
throw new Error(
`Database driver '${config.driver}' is not a SQL driver ` +
`(use sqlite | postgres | mysql; MongoDB has a document API in @wrnexus/db/mongo).`,
);
}
}
+86
View File
@@ -0,0 +1,86 @@
/**
* The database driver interface and the `Db` client built on top of it.
*
* Adapters (SQLite now; Postgres/MySQL/Mongo later) implement `Driver`. The
* client adds ergonomics: `all`/`one` optionally map rows through a model's
* `.parse` (so results match your schema), `tx` wraps work in a transaction, and
* `createTable` runs a model's DDL. Every query is parameterized.
*/
import { createTableSql, type Dialect } from "./sql.ts";
import type { Model } from "./schema.ts";
export type Row = Record<string, unknown>;
export interface ExecResult {
changes: number;
lastInsertId?: number;
}
/** The minimal query surface — the driver itself and each transaction expose it. */
export interface TxHandle {
/** Run a query returning rows (SELECT). Params are positional. */
query(sql: string, params?: unknown[]): Promise<Row[]>;
/** Run a statement (INSERT/UPDATE/DELETE/DDL). */
exec(sql: string, params?: unknown[]): Promise<ExecResult>;
}
export interface Driver extends TxHandle {
dialect: Dialect;
/**
* Run `fn` inside a transaction on a single reserved connection, committing
* on success and rolling back on throw. (Pooled drivers must reserve one
* connection so BEGIN/…/COMMIT don't span connections.)
*/
transaction<T>(fn: (tx: TxHandle) => Promise<T>): Promise<T>;
close(): void | Promise<void>;
}
export interface Db {
driver: Driver;
/** All matching rows, mapped through `model.parse` when a model is given. */
all<T = Row>(sql: string, params?: unknown[], model?: Model<T>): Promise<T[]>;
/** The first matching row (or null), mapped through `model.parse`. */
one<T = Row>(sql: string, params?: unknown[], model?: Model<T>): Promise<T | null>;
exec(sql: string, params?: unknown[]): Promise<ExecResult>;
/** Run `fn` inside a transaction; rolls back if it throws. */
tx<T>(fn: (db: Db) => Promise<T>): Promise<T>;
/** Create a table from its model (`CREATE TABLE IF NOT EXISTS`). */
createTable(model: Model): Promise<void>;
close(): void | Promise<void>;
}
/** Build a `Db` over a query runner (the driver at top level, or a transaction). */
function dbOver(runner: TxHandle, driver: Driver): Db {
const db: Db = {
driver,
async all(sql, params = [], model) {
const rows = await runner.query(sql, params);
return (model ? rows.map((r) => model.parse(r)) : rows) as never;
},
async one(sql, params = [], model) {
const rows = await db.all(sql, params, model as never);
return (rows[0] ?? null) as never;
},
exec(sql, params = []) {
return runner.exec(sql, params);
},
async tx(fn) {
// Top level opens a real transaction; inside one, reuse the current tx.
if (runner === driver) return driver.transaction((tx) => fn(dbOver(tx, driver)));
return fn(db);
},
async createTable(model) {
await runner.exec(createTableSql(model, driver.dialect));
},
close() {
return driver.close();
},
};
return db;
}
/** Build a `Db` client from a driver. */
export function createDb(driver: Driver): Db {
return dbOver(driver, driver);
}
+232
View File
@@ -0,0 +1,232 @@
/**
* sqlc-style query generator. Annotated SQL in `app/db/queries/*.sql` becomes
* typed TS functions whose params + results are inferred from the TS models and
* whose rows are mapped back through `model.parse`.
*
* -- name: GetUserByEmail :one
* SELECT * FROM users WHERE email = :email;
*
* → GetUserByEmail(db, { email: string }): Promise<{…} | null>
*
* Type inference is best-effort (comparisons + INSERT column lists + SELECT list
* vs the model); anything it can't resolve becomes `unknown`.
*/
import type { Column, Model } from "./schema.ts";
import type { Dialect } from "./sql.ts";
export type QueryKind = "one" | "many" | "exec";
export interface QueryDef {
name: string;
kind: QueryKind;
sql: string;
}
/** A model plus the variable name it is exported under (for imports). */
export interface ModelRef {
varName: string;
model: Model;
}
/** Parse annotated queries from one `.sql` file's contents. */
export function parseQueries(content: string): QueryDef[] {
const out: QueryDef[] = [];
const re = /--\s*name:\s*(\w+)\s*:(one|many|exec)\b[^\n]*\n([\s\S]*?)(?=--\s*name:|$)/gi;
let m: RegExpExecArray | null;
while ((m = re.exec(content))) {
out.push({
name: m[1]!,
kind: m[2]!.toLowerCase() as QueryKind,
sql: m[3]!.trim().replace(/;\s*$/, ""),
});
}
return out;
}
/** Rewrite `:name` placeholders to positional params, keeping their order. */
function toPositional(sql: string, dialect: Dialect): { sql: string; order: string[] } {
const order: string[] = [];
const rewritten = sql.replace(/:([A-Za-z_]\w*)/g, (_m, name: string) => {
order.push(name);
return dialect === "postgres" ? `$${order.length}` : "?";
});
return { sql: rewritten, order };
}
function uniqueInOrder(names: string[]): string[] {
const seen = new Set<string>();
const out: string[] = [];
for (const n of names) {
if (!seen.has(n)) {
seen.add(n);
out.push(n);
}
}
return out;
}
function tableOf(sql: string): string | undefined {
const from = /\bFROM\s+["`]?(\w+)["`]?/i.exec(sql);
if (from) return from[1];
const into = /\bINTO\s+["`]?(\w+)["`]?/i.exec(sql);
if (into) return into[1];
const upd = /\bUPDATE\s+["`]?(\w+)["`]?/i.exec(sql);
return upd?.[1];
}
function tsOutput(col: Column): string {
switch (col.def.type) {
case "id":
case "int":
case "real":
return "number";
case "bool":
return "boolean";
case "timestamp":
return "Date";
case "json":
return "unknown";
default:
return "string";
}
}
function tsInput(col: Column): string {
return col.def.type === "timestamp" ? "string | Date" : tsOutput(col);
}
interface SelectCol {
name: string;
/** A type forced by an aggregate (e.g. COUNT → number), overriding the model. */
forced?: string;
}
/** Split a SELECT list on top-level commas (respecting `fn(a, b)`). */
function splitTopLevel(list: string): string[] {
const out: string[] = [];
let depth = 0;
let cur = "";
for (const ch of list) {
if (ch === "(") depth++;
else if (ch === ")") depth--;
if (ch === "," && depth === 0) {
out.push(cur);
cur = "";
} else cur += ch;
}
if (cur.trim()) out.push(cur);
return out;
}
/** Parse the SELECT list into columns, or null for `SELECT *`. */
function selectColumns(sql: string): SelectCol[] | null {
const m = /SELECT\s+([\s\S]*?)\s+FROM\b/i.exec(sql);
if (!m) return null;
const list = m[1]!.trim();
if (list === "*") return null;
return splitTopLevel(list).map((seg): SelectCol => {
const s = seg.trim();
const alias = /\s+AS\s+["`]?(\w+)["`]?$/i.exec(s);
const name = alias ? alias[1]! : s.split(".").pop()!.replace(/["`]/g, "");
const forced = /\b(count|sum|avg|min|max|total)\s*\(/i.test(s) ? "number" : undefined;
return { name, forced };
});
}
/** True when every selected column is a plain model column (so `model.parse` fits). */
function columnsMatchModel(cols: SelectCol[] | null, model: Model | undefined): boolean {
if (!model) return false;
if (cols === null) return true; // SELECT * → full model row
return cols.every((c) => !c.forced && !!model.columns[c.name]);
}
function resultType(cols: SelectCol[] | null, model: Model | undefined): string {
if (cols === null) {
if (!model) return "Record<string, unknown>";
return `{ ${Object.entries(model.columns)
.map(([k, col]) => `${k}: ${tsOutput(col)}`)
.join("; ")} }`;
}
return `{ ${cols
.map(
(c) =>
`${c.name}: ${c.forced ?? (model && model.columns[c.name] ? tsOutput(model.columns[c.name]!) : "unknown")}`,
)
.join("; ")} }`;
}
/** Find the column a `:param` is compared to / inserted into, if any. */
function paramColumn(param: string, sql: string): string | undefined {
const op = "(?:=|!=|<>|<=|>=|<|>|LIKE)";
const cmp1 = new RegExp(`(\\w+)\\s*${op}\\s*:${param}\\b`, "i").exec(sql);
if (cmp1) return cmp1[1];
const cmp2 = new RegExp(`:${param}\\b\\s*${op}\\s*(\\w+)`, "i").exec(sql);
if (cmp2) return cmp2[1];
const ins = /INSERT\s+INTO\s+\w+\s*\(([^)]*)\)\s*VALUES\s*\(([^)]*)\)/i.exec(sql);
if (ins) {
const cols = ins[1]!.split(",").map((s) => s.trim().replace(/["`]/g, ""));
const vals = ins[2]!.split(",").map((s) => s.trim());
const idx = vals.indexOf(`:${param}`);
if (idx >= 0 && cols[idx]) return cols[idx];
}
return undefined;
}
function inferParamType(param: string, sql: string, model: Model | undefined): string {
if (!model) return "unknown";
const col = paramColumn(param, sql);
const column = col ? model.columns[col] : undefined;
return column ? tsInput(column) : "unknown";
}
/** Generate the full `queries.gen.ts` source. */
export function generateQueriesFile(
queries: QueryDef[],
models: ModelRef[],
dialect: Dialect,
): string {
const byTable = new Map(models.map((m) => [m.model.name, m]));
const usedModels = new Set<string>();
const blocks: string[] = [];
for (const q of queries) {
const { sql, order } = toPositional(q.sql, dialect);
const sqlLit = JSON.stringify(sql);
const positional = `[${order.map((n) => `args.${n}`).join(", ")}]`;
const params = uniqueInOrder(order);
const table = tableOf(q.sql);
const ref = table ? byTable.get(table) : undefined;
const argFields = params.map((p) => `${p}: ${inferParamType(p, q.sql, ref?.model)}`);
const sig = argFields.length ? `db: Db, args: { ${argFields.join("; ")} }` : "db: Db";
if (q.kind === "exec") {
blocks.push(
`export async function ${q.name}(${sig}): Promise<ExecResult> {\n return db.exec(${sqlLit}, ${positional});\n}`,
);
continue;
}
const cols = selectColumns(q.sql);
const row = resultType(cols, ref?.model);
const ret = q.kind === "one" ? `${row} | null` : `${row}[]`;
const method = q.kind === "one" ? "one" : "all";
// Only map through the model when the selected columns are model columns.
const passModel = !!ref && columnsMatchModel(cols, ref.model);
let modelArg = "";
if (passModel && ref) {
usedModels.add(ref.varName);
modelArg = `, ${ref.varName}`;
}
blocks.push(
`export async function ${q.name}(${sig}): Promise<${ret}> {\n return (await db.${method}(${sqlLit}, ${positional}${modelArg})) as ${ret};\n}`,
);
}
const imports = [`import type { Db, ExecResult } from "@wrnexus/db";`];
if (usedModels.size > 0) {
imports.push(`import { ${[...usedModels].sort().join(", ")} } from "./schema.ts";`);
}
return `// AUTO-GENERATED by \`wrnexus db generate\` — do not edit.\n${imports.join("\n")}\n\n${blocks.join("\n\n")}\n`;
}
+29
View File
@@ -0,0 +1,29 @@
/**
* @wrnexus/db — the data layer core: TS models (source of truth for DDL,
* validation, and result typing), the `Driver` interface, and the `Db` client.
*
* Adapters are imported from subpaths, e.g. `@wrnexus/db/sqlite`. Migrations and
* the sqlc-style query generator build on this core in later phases.
*/
export { v, table, Column } from "./schema.ts";
export type { Model, Columns, ColumnDef, BaseType } from "./schema.ts";
export { createDb } from "./driver.ts";
export type { Db, Driver, Row, ExecResult, TxHandle } from "./driver.ts";
export { setDb, getDb, hasDb, registerDb, databaseNames, closeDatabases } from "./client.ts";
export { createTableSql } from "./sql.ts";
export type { Dialect } from "./sql.ts";
export {
parseMigration,
loadMigrations,
appliedMigrations,
migrate,
rollback,
status,
scaffoldMigration,
} from "./migrate.ts";
export type { Migration } from "./migrate.ts";
export { parseQueries, generateQueriesFile } from "./generate.ts";
export type { QueryDef, QueryKind, ModelRef } from "./generate.ts";
export { paginate, loadRelated } from "./query.ts";
export type { Paginated, PageOptions, RelationOptions } from "./query.ts";
+164
View File
@@ -0,0 +1,164 @@
/**
* Migration runner. Migrations are `.sql` files in `app/db/migrations`, each
* split into `-- +up` and `-- +down` sections. Applied migrations are recorded
* in a `_wire_migrations` table so they run exactly once, newest-last.
*
* `scaffoldMigration(..., models)` writes an initial migration straight from the
* TS models — the source of truth — so you don't hand-write the first schema.
*/
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import type { Db } from "./driver.ts";
import { createTableSql, type Dialect } from "./sql.ts";
import type { Model } from "./schema.ts";
export interface Migration {
name: string;
up: string;
down: string;
}
const MIGRATIONS_TABLE = "_wire_migrations";
/** Split a migration file into its `up` and `down` SQL sections. */
export function parseMigration(name: string, content: string): Migration {
return { name, up: section(content, "up"), down: section(content, "down") };
}
function section(content: string, which: "up" | "down"): string {
const marker = new RegExp(`^--\\s*\\+${which}\\b.*$`, "mi");
const match = marker.exec(content);
if (!match) {
// A file with no markers at all is treated entirely as `up`.
return which === "up" && !/^--\s*\+(up|down)\b/im.test(content) ? content.trim() : "";
}
const from = content.indexOf("\n", match.index);
const rest = content.slice(from === -1 ? content.length : from + 1);
const next = /^--\s*\+(up|down)\b/im.exec(rest);
return (next ? rest.slice(0, next.index) : rest).trim();
}
/** Load and parse all migration files in a directory, sorted by filename. */
export function loadMigrations(dir: string): Migration[] {
if (!existsSync(dir)) return [];
return readdirSync(dir)
.filter((f) => f.endsWith(".sql"))
.sort()
.map((f) => parseMigration(f.replace(/\.sql$/, ""), readFileSync(join(dir, f), "utf8")));
}
async function ensureTable(db: Db): Promise<void> {
await db.exec(
`CREATE TABLE IF NOT EXISTS ${MIGRATIONS_TABLE} (name TEXT PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP)`,
);
}
/** Names of already-applied migrations, oldest first. */
export async function appliedMigrations(db: Db): Promise<string[]> {
await ensureTable(db);
const rows = await db.all<{ name: string }>(
`SELECT name FROM ${MIGRATIONS_TABLE} ORDER BY applied_at, name`,
);
return rows.map((r) => r.name);
}
/** Apply all pending migrations (each in a transaction). Returns applied names. */
export async function migrate(db: Db, dir: string): Promise<string[]> {
const applied = new Set(await appliedMigrations(db));
const pending = loadMigrations(dir).filter((m) => !applied.has(m.name));
const done: string[] = [];
for (const m of pending) {
await db.tx(async (tx) => {
if (m.up) await tx.exec(m.up);
await tx.exec(`INSERT INTO ${MIGRATIONS_TABLE} (name) VALUES (?)`, [m.name]);
});
done.push(m.name);
}
return done;
}
/** Roll back the most recently applied migration. Returns its name, or null. */
export async function rollback(db: Db, dir: string): Promise<string | null> {
const applied = await appliedMigrations(db);
const last = applied[applied.length - 1];
if (!last) return null;
const migration = loadMigrations(dir).find((m) => m.name === last);
await db.tx(async (tx) => {
if (migration?.down) await tx.exec(migration.down);
await tx.exec(`DELETE FROM ${MIGRATIONS_TABLE} WHERE name = ?`, [last]);
});
return last;
}
/** Full status: every migration file with whether it has been applied. */
export async function status(db: Db, dir: string): Promise<{ name: string; applied: boolean }[]> {
const applied = new Set(await appliedMigrations(db));
return loadMigrations(dir).map((m) => ({ name: m.name, applied: applied.has(m.name) }));
}
/** Order models so a referenced table is created before the table referencing it. */
function topoSort(models: Model[]): Model[] {
const byName = new Map(models.map((m) => [m.name, m]));
const sorted: Model[] = [];
const visited = new Set<string>();
const visit = (m: Model): void => {
if (visited.has(m.name)) return;
visited.add(m.name);
for (const column of Object.values(m.columns)) {
const ref = column.def.references;
if (ref && ref.table !== m.name && byName.has(ref.table)) visit(byName.get(ref.table)!);
}
sorted.push(m);
};
for (const m of models) visit(m);
return sorted;
}
function nextNumber(dir: string): number {
if (!existsSync(dir)) return 1;
let max = 0;
for (const f of readdirSync(dir)) {
const m = /^(\d+)/.exec(f);
if (m) max = Math.max(max, Number(m[1]));
}
return max + 1;
}
/**
* Write a new migration file. With `models`, the `up`/`down` are generated from
* the TS models (create/drop every table); otherwise empty stubs are written.
* Returns the created file path.
*/
export function scaffoldMigration(
dir: string,
name: string,
dialect: Dialect,
models: Model[] = [],
): string {
mkdirSync(dir, { recursive: true });
const num = String(nextNumber(dir)).padStart(4, "0");
const slug =
name
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, "_")
.replace(/^_+|_+$/g, "") || "migration";
const file = join(dir, `${num}_${slug}.sql`);
let up = "";
let down = "";
if (models.length > 0) {
const ordered = topoSort(models); // referenced tables first
up = ordered.map((m) => createTableSql(m, dialect)).join("\n\n");
const quote = dialect === "mysql" ? (s: string) => `\`${s}\`` : (s: string) => `"${s}"`;
down = ordered
.slice()
.reverse()
.map((m) => `DROP TABLE IF EXISTS ${quote(m.name)};`)
.join("\n");
}
writeFileSync(file, `-- +up\n${up}\n\n-- +down\n${down}\n`, "utf8");
return file;
}
+145
View File
@@ -0,0 +1,145 @@
/**
* Query ergonomics built on the `Db` client: offset pagination and a batched
* relation loader (avoids N+1). Both are dialect-aware — placeholders follow the
* driver's style (`$N` for Postgres, `?` for SQLite/MySQL).
*/
import type { Db, Row } from "./driver.ts";
import type { Model } from "./schema.ts";
import type { Dialect } from "./sql.ts";
const IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/;
function placeholder(dialect: Dialect, index: number): string {
return dialect === "postgres" ? `$${index}` : "?";
}
function assertIdent(name: string, what: string): void {
if (!IDENT_RE.test(name)) throw new Error(`Unsafe ${what}: ${JSON.stringify(name)}`);
}
// --- Pagination ------------------------------------------------------------
export interface PageOptions {
page?: number;
perPage?: number;
/** Upper bound on perPage. Default 100. */
maxPerPage?: number;
}
export interface Paginated<T> {
items: T[];
page: number;
perPage: number;
total: number;
totalPages: number;
hasNext: boolean;
hasPrev: boolean;
}
/**
* Paginate a SELECT. Pass the base query WITHOUT a LIMIT; the helper appends the
* page window and derives the total with a COUNT over the same query.
*
* await paginate(db, { sql: "SELECT * FROM users ORDER BY name", model: users }, { page: 2 })
*/
export async function paginate<T = Row>(
db: Db,
query: { sql: string; params?: unknown[]; countSql?: string; model?: Model<T> },
opts: PageOptions = {},
): Promise<Paginated<T>> {
const dialect = db.driver.dialect;
const params = query.params ?? [];
const maxPerPage = opts.maxPerPage ?? 100;
const page = Math.max(1, Math.floor(opts.page ?? 1));
const perPage = Math.min(maxPerPage, Math.max(1, Math.floor(opts.perPage ?? 20)));
const offset = (page - 1) * perPage;
const countSql = query.countSql ?? `SELECT COUNT(*) AS n FROM (${query.sql}) AS __wire_sub`;
const countRow = await db.one<{ n: number | string }>(countSql, params);
const total = Number(countRow?.n ?? 0);
const limitPh = placeholder(dialect, params.length + 1);
const offsetPh = placeholder(dialect, params.length + 2);
const items = await db.all<T>(
`${query.sql} LIMIT ${limitPh} OFFSET ${offsetPh}`,
[...params, perPage, offset],
query.model,
);
const totalPages = perPage > 0 ? Math.ceil(total / perPage) : 0;
return {
items,
page,
perPage,
total,
totalPages,
hasNext: page < totalPages,
hasPrev: page > 1,
};
}
// --- Relations (batched, no N+1) -------------------------------------------
export interface RelationOptions<C> {
/** Parent field whose value matches the child's foreign key. Default "id". */
localKey?: string;
/** Child table to load from. */
table: string;
/** Child column that references the parent. */
foreignKey: string;
/** Property name to attach on each parent. */
as: string;
/** true → attach a single child (belongsTo); false → an array (hasMany). */
single?: boolean;
/** Map child rows through a model. */
model?: Model<C>;
}
/**
* Load a relation for a set of parent rows in ONE query and attach it to each
* parent (no N+1). Returns the same parents, each with `opts.as` populated.
*
* await loadRelated(db, users, { table: "posts", foreignKey: "userId", as: "posts" })
*/
export async function loadRelated<P extends Row, C extends Row = Row>(
db: Db,
parents: P[],
opts: RelationOptions<C>,
): Promise<(P & Record<string, C | C[] | null>)[]> {
const localKey = opts.localKey ?? "id";
assertIdent(opts.table, "table name");
assertIdent(opts.foreignKey, "foreign key");
const results = parents as (P & Record<string, C | C[] | null>)[];
if (parents.length === 0) return results;
const keys = [...new Set(parents.map((p) => p[localKey]).filter((k) => k != null))];
if (keys.length === 0) {
for (const parent of results)
(parent as Record<string, unknown>)[opts.as] = opts.single ? null : [];
return results;
}
const dialect = db.driver.dialect;
const placeholders = keys.map((_, i) => placeholder(dialect, i + 1)).join(", ");
const children = await db.all<C>(
`SELECT * FROM ${opts.table} WHERE ${opts.foreignKey} IN (${placeholders})`,
keys,
opts.model,
);
const grouped = new Map<unknown, C[]>();
for (const child of children) {
const fk = (child as Row)[opts.foreignKey];
const bucket = grouped.get(fk);
if (bucket) bucket.push(child);
else grouped.set(fk, [child]);
}
for (const parent of results) {
const matches = grouped.get(parent[localKey]) ?? [];
(parent as Record<string, unknown>)[opts.as] = opts.single ? (matches[0] ?? null) : matches;
}
return results;
}
+129
View File
@@ -0,0 +1,129 @@
/**
* Database models — the single source of truth for a table's shape.
*
* A model defined with `table()` + the `v` column builder drives (1) DDL for
* migrations, (2) coercion/validation of DB rows into typed objects
* (`model.parse`), and later (3) the types the sqlc-style query generator emits.
* Column types are dialect-neutral; each adapter maps them to its own SQL types.
*/
export type BaseType = "id" | "text" | "int" | "real" | "bool" | "timestamp" | "json";
export interface ColumnDef {
type: BaseType;
/** NOT NULL unless `.optional()` was called. Ids are implicitly not-null. */
notNull: boolean;
primaryKey: boolean;
autoIncrement: boolean;
unique: boolean;
/** Literal default, or the sentinel "now" for a current-timestamp default. */
default?: unknown;
references?: { table: string; column: string };
}
/** A fluent column definition. Chain modifiers, then hand it to `table()`. */
export class Column {
readonly def: ColumnDef;
constructor(type: BaseType, overrides: Partial<ColumnDef> = {}) {
this.def = {
type,
notNull: true,
primaryKey: false,
autoIncrement: false,
unique: false,
...overrides,
};
}
optional(): this {
this.def.notNull = false;
return this;
}
unique(): this {
this.def.unique = true;
return this;
}
default(value: unknown): this {
this.def.default = value;
return this;
}
primaryKey(): this {
this.def.primaryKey = true;
return this;
}
references(table: string, column = "id"): this {
this.def.references = { table, column };
return this;
}
/** Coerce a raw DB value into its JS type (used by `model.parse`). */
coerce(raw: unknown): unknown {
if (raw === null || raw === undefined) return this.def.notNull ? raw : null;
switch (this.def.type) {
case "id":
case "int":
return typeof raw === "bigint" ? Number(raw) : Number(raw);
case "real":
return Number(raw);
case "bool":
return raw === true || raw === 1 || raw === "1" || raw === "true";
case "timestamp":
return raw instanceof Date ? raw : new Date(raw as string | number);
case "json":
return typeof raw === "string" ? safeJson(raw) : raw;
default:
return String(raw);
}
}
}
function safeJson(value: string): unknown {
try {
return JSON.parse(value);
} catch {
return value;
}
}
/** Column builders. `v.id()` is an auto-increment primary key. */
export const v = {
id: () => new Column("id", { primaryKey: true, autoIncrement: true }),
text: () => new Column("text"),
string: () => new Column("text"),
int: () => new Column("int"),
number: () => new Column("real"),
real: () => new Column("real"),
bool: () => new Column("bool"),
boolean: () => new Column("bool"),
timestamp: () => new Column("timestamp"),
json: () => new Column("json"),
};
export type Columns = Record<string, Column>;
export interface Model<T = Record<string, unknown>> {
name: string;
columns: Columns;
/** Coerce a raw DB row into a typed object (unknown columns pass through). */
parse(row: Record<string, unknown>): T;
/** Column definitions, for migrations and the query generator. */
describe(): Record<string, ColumnDef>;
}
/** Define a table model from a name and a map of columns. */
export function table<T = Record<string, unknown>>(name: string, columns: Columns): Model<T> {
return {
name,
columns,
parse(row) {
const out: Record<string, unknown> = { ...row };
for (const [key, column] of Object.entries(columns)) {
if (key in row) out[key] = column.coerce(row[key]);
}
return out as T;
},
describe() {
const out: Record<string, ColumnDef> = {};
for (const [key, column] of Object.entries(columns)) out[key] = column.def;
return out;
},
};
}
+48
View File
@@ -0,0 +1,48 @@
/**
* A persistent, process-shared session backend built on `bun:sqlite` (sync, so
* it satisfies `SessionBackend` without a load/save wrapper). Sessions survive
* restarts and are shared by every worker pointed at the same file.
*
* import { setSessionBackend } from "@wrnexus/core";
* import { sqliteSessionStore } from "@wrnexus/db/session";
* setSessionBackend(sqliteSessionStore("./sessions.db"));
*/
import { Database } from "bun:sqlite";
import type { SessionBackend, SessionEntry } from "@wrnexus/core";
export function sqliteSessionStore(path = "sessions.db"): SessionBackend {
const db = new Database(path);
db.run("PRAGMA journal_mode = WAL");
db.run(
"CREATE TABLE IF NOT EXISTS sessions (id TEXT PRIMARY KEY, data TEXT NOT NULL, expiresAt INTEGER NOT NULL)",
);
const getStmt = db.query("SELECT data, expiresAt FROM sessions WHERE id = ?");
const setStmt = db.query(
"INSERT INTO sessions (id, data, expiresAt) VALUES (?, ?, ?) " +
"ON CONFLICT(id) DO UPDATE SET data = excluded.data, expiresAt = excluded.expiresAt",
);
const delStmt = db.query("DELETE FROM sessions WHERE id = ?");
const gcStmt = db.query("DELETE FROM sessions WHERE expiresAt <= ?");
return {
get(id): SessionEntry | undefined {
const row = getStmt.get(id) as { data: string; expiresAt: number } | null;
if (!row) return undefined;
try {
return { data: JSON.parse(row.data) as Record<string, unknown>, expiresAt: row.expiresAt };
} catch {
return undefined;
}
},
set(id, entry) {
setStmt.run(id, JSON.stringify(entry.data), entry.expiresAt);
},
delete(id) {
delStmt.run(id);
},
gc(now) {
gcStmt.run(now);
},
};
}
+68
View File
@@ -0,0 +1,68 @@
/**
* SQL rendering shared by adapters and the migration runner. Column types are
* dialect-neutral in the model; this maps them to each dialect's SQL types and
* renders `CREATE TABLE`. (Postgres/MySQL land in later phases; the mappings are
* here so the model layer is already portable.)
*/
import type { ColumnDef, Model } from "./schema.ts";
export type Dialect = "sqlite" | "postgres" | "mysql";
function sqlType(def: ColumnDef, dialect: Dialect): string {
if (def.type === "id") {
if (dialect === "postgres") return "SERIAL";
if (dialect === "mysql") return "INT AUTO_INCREMENT";
return "INTEGER";
}
switch (def.type) {
case "int":
return "INTEGER";
case "real":
return dialect === "mysql" ? "DOUBLE" : "REAL";
case "bool":
return dialect === "postgres" ? "BOOLEAN" : "INTEGER";
case "timestamp":
return dialect === "sqlite" ? "TEXT" : "TIMESTAMP";
case "json":
return dialect === "postgres" ? "JSONB" : "TEXT";
default:
return dialect === "mysql" ? "VARCHAR(255)" : "TEXT";
}
}
function quoteId(id: string, dialect: Dialect): string {
return dialect === "mysql" ? `\`${id}\`` : `"${id}"`;
}
function renderDefault(value: unknown, dialect: Dialect): string {
if (value === "now") return "CURRENT_TIMESTAMP";
if (typeof value === "number") return String(value);
if (typeof value === "boolean") return dialect === "postgres" ? String(value) : value ? "1" : "0";
return `'${String(value).replace(/'/g, "''")}'`;
}
/** Render `CREATE TABLE` for a model in the given dialect. */
export function createTableSql(model: Model, dialect: Dialect, ifNotExists = true): string {
const cols: string[] = [];
for (const [name, column] of Object.entries(model.columns)) {
const def = column.def;
const parts = [quoteId(name, dialect), sqlType(def, dialect)];
if (def.primaryKey) {
parts.push(
dialect === "sqlite" && def.type === "id" ? "PRIMARY KEY AUTOINCREMENT" : "PRIMARY KEY",
);
}
if (def.notNull && !def.primaryKey) parts.push("NOT NULL");
if (def.unique && !def.primaryKey) parts.push("UNIQUE");
if (def.default !== undefined) parts.push(`DEFAULT ${renderDefault(def.default, dialect)}`);
if (def.references) {
parts.push(
`REFERENCES ${quoteId(def.references.table, dialect)}(${quoteId(def.references.column, dialect)})`,
);
}
cols.push(" " + parts.join(" "));
}
const head = `CREATE TABLE ${ifNotExists ? "IF NOT EXISTS " : ""}${quoteId(model.name, dialect)}`;
return `${head} (\n${cols.join(",\n")}\n);`;
}
+195
View File
@@ -0,0 +1,195 @@
import { test, expect } from "bun:test";
import { mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
v,
table,
createDb,
createTableSql,
parseMigration,
migrate,
status,
rollback,
parseQueries,
generateQueriesFile,
} from "../src/index.ts";
import { sqlite } from "../src/adapters/sqlite.ts";
import { bunSql } from "../src/adapters/bunsql.ts";
const users = table<{ id: number; email: string; name: string; active: boolean }>("users", {
id: v.id(),
email: v.string().unique(),
name: v.string(),
active: v.boolean().default(true),
});
test("createTableSql renders dialect-specific DDL", () => {
expect(createTableSql(users, "sqlite")).toContain("INTEGER PRIMARY KEY AUTOINCREMENT");
const pg = createTableSql(users, "postgres");
expect(pg).toContain("SERIAL PRIMARY KEY");
expect(pg).toContain("BOOLEAN");
});
test("model.parse coerces DB rows to typed values", () => {
const row = users.parse({ id: "1", email: "a@b.com", name: "Ann", active: 1 });
expect(row.id).toBe(1);
expect(row.active).toBe(true);
});
for (const [label, driver] of [
["bun:sqlite", () => sqlite()],
["Bun.sql/sqlite", () => bunSql("sqlite://:memory:", "sqlite")],
] as const) {
test(`CRUD + transactions [${label}]`, async () => {
const db = createDb(driver());
await db.createTable(users);
await db.exec("INSERT INTO users (email, name, active) VALUES (?, ?, ?)", [
"a@b.com",
"Ann",
true,
]);
// rollback
try {
await db.tx(async (t) => {
await t.exec("INSERT INTO users (email, name) VALUES (?, ?)", ["x@y.com", "X"]);
throw new Error("boom");
});
} catch {
/* expected */
}
// commit
await db.tx(async (t) => {
await t.exec("INSERT INTO users (email, name) VALUES (?, ?)", ["c@d.com", "Cy"]);
});
const rows = await db.all("SELECT * FROM users ORDER BY id", [], users);
expect(rows.map((r) => r.name)).toEqual(["Ann", "Cy"]);
expect(typeof rows[0]!.active).toBe("boolean");
db.close();
});
}
test("migration runner: parse, migrate, status, rollback", async () => {
const dir = mkdtempSync(join(tmpdir(), "wire-mig-"));
writeFileSync(
join(dir, "0001_init.sql"),
"-- +up\nCREATE TABLE t (id INTEGER PRIMARY KEY, n TEXT);\n-- +down\nDROP TABLE t;",
);
const parsed = parseMigration(
"0001_init",
"-- +up\nCREATE TABLE t (id INTEGER);\n-- +down\nDROP TABLE t;",
);
expect(parsed.up).toContain("CREATE TABLE t");
expect(parsed.down).toContain("DROP TABLE t");
const db = createDb(sqlite());
expect(await migrate(db, dir)).toEqual(["0001_init"]);
expect(await migrate(db, dir)).toEqual([]); // idempotent
expect((await status(db, dir))[0]).toEqual({ name: "0001_init", applied: true });
const tablesAfter = await db.all<{ name: string }>(
"SELECT name FROM sqlite_master WHERE type='table' AND name='t'",
);
expect(tablesAfter.length).toBe(1);
expect(await rollback(db, dir)).toBe("0001_init");
expect((await status(db, dir))[0]!.applied).toBe(false);
db.close();
});
test("query generator infers params and result types", () => {
const q = parseQueries(
"-- name: GetByEmail :one\nSELECT * FROM users WHERE email = :email;\n" +
"-- name: CountActive :one\nSELECT COUNT(*) AS n FROM users WHERE active = :active;\n" +
"-- name: Create :exec\nINSERT INTO users (email, name) VALUES (:email, :name);",
);
expect(q.map((x) => x.name)).toEqual(["GetByEmail", "CountActive", "Create"]);
const code = generateQueriesFile(q, [{ varName: "users", model: users }], "sqlite");
expect(code).toContain("GetByEmail(db: Db, args: { email: string })");
expect(code).toContain(
"CountActive(db: Db, args: { active: boolean }): Promise<{ n: number } | null>",
);
expect(code).toContain(
"Create(db: Db, args: { email: string; name: string }): Promise<ExecResult>",
);
});
test("paginate returns a page window with correct metadata", async () => {
const { paginate } = await import("../src/index.ts");
const db = createDb(sqlite());
await db.createTable(users);
for (let i = 1; i <= 25; i++) {
await db.exec("INSERT INTO users (email, name, active) VALUES (?, ?, ?)", [
`u${i}@x.com`,
`U${i}`,
true,
]);
}
const p2 = await paginate(
db,
{ sql: "SELECT * FROM users ORDER BY id", model: users },
{ page: 2, perPage: 10 },
);
expect(p2.total).toBe(25);
expect(p2.totalPages).toBe(3);
expect(p2.items.length).toBe(10);
expect(p2.items[0]!.name).toBe("U11");
expect(p2.hasNext).toBe(true);
expect(p2.hasPrev).toBe(true);
const p3 = await paginate(
db,
{ sql: "SELECT * FROM users ORDER BY id" },
{ page: 3, perPage: 10 },
);
expect(p3.items.length).toBe(5);
expect(p3.hasNext).toBe(false);
await db.close();
});
test("loadRelated batches children onto parents (no N+1)", async () => {
const { loadRelated } = await import("../src/index.ts");
const db = createDb(sqlite());
await db.exec("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)");
await db.exec("CREATE TABLE posts (id INTEGER PRIMARY KEY, userId INTEGER, title TEXT)");
await db.exec("INSERT INTO users (id, name) VALUES (1, 'Ann'), (2, 'Bob')");
await db.exec(
"INSERT INTO posts (id, userId, title) VALUES (1, 1, 'a'), (2, 1, 'b'), (3, 2, 'c')",
);
const parents = await db.all<{ id: number; name: string }>("SELECT * FROM users ORDER BY id");
const withPosts = await loadRelated(db, parents, {
table: "posts",
foreignKey: "userId",
as: "posts",
});
expect((withPosts[0]!.posts as unknown[]).length).toBe(2);
expect((withPosts[1]!.posts as unknown[]).length).toBe(1);
await db.close();
});
test("loadRelated rejects unsafe identifiers", async () => {
const { loadRelated } = await import("../src/index.ts");
const db = createDb(sqlite());
await expect(
loadRelated(db, [{ id: 1 }], {
table: "posts; DROP TABLE users",
foreignKey: "userId",
as: "x",
}),
).rejects.toThrow("Unsafe table name");
await db.close();
});
test("sqliteSessionStore persists sessions (get/set/delete/gc)", async () => {
const { sqliteSessionStore } = await import("../src/session-store.ts");
const store = sqliteSessionStore(":memory:");
expect(store.get("s1")).toBeUndefined();
store.set("s1", { data: { user: 7 }, expiresAt: Date.now() + 10_000 });
expect(store.get("s1")!.data).toEqual({ user: 7 });
store.set("s1", { data: { user: 8 }, expiresAt: Date.now() + 10_000 }); // upsert
expect(store.get("s1")!.data).toEqual({ user: 8 });
store.set("old", { data: {}, expiresAt: Date.now() - 1 });
store.gc!(Date.now());
expect(store.get("old")).toBeUndefined();
store.delete("s1");
expect(store.get("s1")).toBeUndefined();
});
+77
View File
@@ -0,0 +1,77 @@
/**
* Live Postgres/MySQL integration tests. These are GATED on env vars so the
* normal `bun test` run stays green without a database:
*
* WRNEXUS_PG_URL=postgres://… WRNEXUS_MYSQL_URL=mysql://… bun test packages/db
*
* The `bun run test:db:live` script spins up both via docker-compose, sets the
* env vars, runs this file, and tears the containers down.
*/
import { test, expect } from "bun:test";
import { v, table, createDb, createTableSql, paginate, type Dialect } from "../src/index.ts";
import { bunSql } from "../src/adapters/bunsql.ts";
const users = table<{ id: number; email: string; name: string; active: boolean }>("users", {
id: v.id(),
email: v.string().unique(),
name: v.string(),
active: v.boolean().default(true),
});
const targets: { dialect: Dialect; url: string }[] = [];
if (process.env.WRNEXUS_PG_URL)
targets.push({ dialect: "postgres", url: process.env.WRNEXUS_PG_URL });
if (process.env.WRNEXUS_MYSQL_URL)
targets.push({ dialect: "mysql", url: process.env.WRNEXUS_MYSQL_URL });
const ph = (dialect: Dialect, i: number) => (dialect === "postgres" ? `$${i}` : "?");
if (targets.length === 0) {
test.skip("live PG/MySQL (set WRNEXUS_PG_URL / WRNEXUS_MYSQL_URL to run)", () => {});
} else {
for (const { dialect, url } of targets) {
test(`${dialect}: DDL + CRUD + transaction + pagination`, async () => {
const db = createDb(bunSql(url, dialect));
try {
await db.exec("DROP TABLE IF EXISTS users");
await db.exec(createTableSql(users, dialect));
for (let i = 1; i <= 5; i++) {
await db.exec(
`INSERT INTO users (email, name, active) VALUES (${ph(dialect, 1)}, ${ph(dialect, 2)}, ${ph(dialect, 3)})`,
[`u${i}@x.com`, `U${i}`, true],
);
}
const count = await db.one<{ n: number | string }>("SELECT COUNT(*) AS n FROM users");
expect(Number(count?.n)).toBe(5);
// Transaction rollback leaves the table unchanged.
await db
.tx(async (t) => {
await t.exec(
`INSERT INTO users (email, name, active) VALUES (${ph(dialect, 1)}, ${ph(dialect, 2)}, ${ph(dialect, 3)})`,
["rollback@x.com", "R", true],
);
throw new Error("rollback");
})
.catch(() => {});
const after = await db.one<{ n: number | string }>("SELECT COUNT(*) AS n FROM users");
expect(Number(after?.n)).toBe(5);
const page = await paginate(
db,
{ sql: "SELECT * FROM users ORDER BY id", model: users },
{ page: 1, perPage: 2 },
);
expect(page.total).toBe(5);
expect(page.totalPages).toBe(3);
expect(page.items.length).toBe(2);
await db.exec("DROP TABLE IF EXISTS users");
} finally {
await db.close();
}
});
}
}
+47
View File
@@ -0,0 +1,47 @@
import { test, expect } from "bun:test";
import {
createDb,
setDb,
getDb,
hasDb,
registerDb,
databaseNames,
closeDatabases,
} from "../src/index.ts";
import { sqlite } from "../src/adapters/sqlite.ts";
test("multi-database registry: default + named connections", async () => {
await closeDatabases(); // isolate from any prior state
const main = createDb(sqlite(":memory:"));
const analytics = createDb(sqlite(":memory:"));
setDb(main); // default
registerDb("analytics", analytics); // named
expect(getDb()).toBe(main);
expect(getDb("analytics")).toBe(analytics);
expect(hasDb()).toBe(true);
expect(hasDb("analytics")).toBe(true);
expect(hasDb("missing")).toBe(false);
expect(databaseNames().sort()).toEqual(["analytics", "default"]);
// Each connection is independent — a table in one is not in the other.
await main.exec("CREATE TABLE a (id INTEGER)");
await analytics.exec("CREATE TABLE b (id INTEGER)");
await getDb().exec("INSERT INTO a (id) VALUES (1)");
await getDb("analytics").exec("INSERT INTO b (id) VALUES (2)");
expect((await getDb().all("SELECT id FROM a")).length).toBe(1);
expect((await getDb("analytics").all("SELECT id FROM b")).length).toBe(1);
await closeDatabases();
expect(hasDb()).toBe(false);
expect(databaseNames()).toEqual([]);
});
test("getDb throws a helpful error for an unknown named database", async () => {
await closeDatabases();
setDb(createDb(sqlite(":memory:")));
expect(() => getDb("nope")).toThrow(/No database named 'nope'/);
await closeDatabases();
});