first commit
This commit is contained in:
@@ -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");
|
||||
}
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { mysql } from "./bunsql.ts";
|
||||
@@ -0,0 +1 @@
|
||||
export { postgres } from "./bunsql.ts";
|
||||
@@ -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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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).`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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`;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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);`;
|
||||
}
|
||||
Reference in New Issue
Block a user