Files
WRNexusJS/packages/db/src/schema.ts
T
2026-07-12 15:55:18 +05:30

130 lines
3.8 KiB
TypeScript

/**
* 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;
},
};
}