Initial commit

This commit is contained in:
Hellings 2026-10-23 08:53:07 -04:00
commit 2864812fa3
15 changed files with 1370 additions and 0 deletions

2
index.ts Normal file
View file

@ -0,0 +1,2 @@
export * from "./src/index.ts";
export { DatabaseSessionStore } from "./src/sessions.ts";

12
package.json Normal file
View file

@ -0,0 +1,12 @@
{
"name": "@muddle/database",
"type": "module",
"exports": "./index.ts",
"version": "0.1.0",
"peerDependencies": {
"muddle": "*"
},
"dependencies": {
"pg": "^8.23.1"
}
}

213
src/adapters/d1.ts Normal file
View file

@ -0,0 +1,213 @@
import type { SqlDriver } from "muddle/contracts/typegen/orm/index.ts";
import type { FieldType } from "muddle/contracts/typegen/orm/index.ts";
import { buildCreateTable, type SqlDialect } from "./driver.ts";
export interface D1Options {
/** Account the database belongs to. */
accountId: string;
/** Identifier of the database. */
databaseId: string;
/** Api token with the `D1 Database:Edit` permission. */
apiToken: string;
/** Endpoint of the api, useful for tests. */
endpoint?: string;
}
const D1_TYPES: Record<FieldType, string> = {
text: "TEXT",
int: "INTEGER",
bigint: "INTEGER",
real: "REAL",
bool: "INTEGER",
blob: "BLOB",
json: "TEXT",
uuid: "TEXT",
timestamp: "TEXT",
date: "TEXT",
};
export const d1Dialect: SqlDialect = {
name: "sqlite",
columnType: (type) => D1_TYPES[type],
placeholder: () => "?",
supportsReturning: true,
defaultExpression: (column) => {
if (column.default === undefined) return undefined;
if (typeof column.default === "number") return String(column.default);
if (typeof column.default === "boolean") return column.default ? "1" : "0";
return `'${String(column.default).replace(/'/g, "''")}'`;
},
};
interface D1Response {
success: boolean;
result?: {
results?: Record<string, unknown>[];
success?: boolean;
meta?: { changes?: number };
}[];
errors?: { message: string }[];
}
export class D1Driver implements SqlDriver {
readonly dialect = "sqlite" as const;
readonly supportsReturning = true;
constructor(private readonly options: D1Options) {}
/** Placeholder for the n-th parameter (1 based). */
placeholder(_index: number): string {
return "?";
}
/** Runs a statement and returns every row. */
async query<T = Record<string, unknown>>(
sql: string,
params: readonly unknown[] = [],
): Promise<T[]> {
const response = await this.send(sql, params);
const statement = response.result?.[0];
return (statement?.results ?? []) as T[];
}
/** Runs a statement and returns the number of affected rows. */
async execute(
sql: string,
params: readonly unknown[] = [],
): Promise<{ changes: number }> {
const response = await this.send(sql, params);
const statement = response.result?.[0];
return { changes: statement?.meta?.changes ?? 0 };
}
/**
* Runs `fn` inside a transaction.
*
* D1 has no interactive transactions, the statements are collected and
* sent as a single batch, which is atomic.
*/
async transaction<T>(fn: (driver: SqlDriver) => Promise<T>): Promise<T> {
const buffered = new D1Buffer(this.options);
const result = await fn(buffered);
await buffered.flush();
return result;
}
/** Creates the table of a model, if it does not exist yet. */
async createTable(
table: string,
columns: Parameters<typeof buildCreateTable>[2],
options: Parameters<typeof buildCreateTable>[3],
): Promise<void> {
await this.execute(buildCreateTable(d1Dialect, table, columns, options));
}
/** Closes the driver, D1 has no local resources. */
async close(): Promise<void> {}
private async send(
sql: string,
params: readonly unknown[],
): Promise<D1Response> {
const endpoint =
this.options.endpoint ??
`https://api.cloudflare.com/client/v4/accounts/${this.options.accountId}/d1/database/${this.options.databaseId}/query`;
const response = await fetch(endpoint, {
method: "POST",
headers: {
authorization: `Bearer ${this.options.apiToken}`,
"content-type": "application/json",
},
body: JSON.stringify({
sql,
params: params.map((value) => normalize(value)),
}),
});
const payload = (await response.json()) as D1Response;
if (!response.ok || payload.success === false) {
const message = payload.errors?.[0]?.message ?? response.statusText;
throw new Error(`D1 request failed: ${message}`);
}
return payload;
}
}
/** Driver that collects the statements of a transaction. */
class D1Buffer implements SqlDriver {
readonly dialect = "sqlite" as const;
readonly supportsReturning = true;
private readonly statements: { sql: string; params: unknown[] }[] = [];
private result: unknown;
constructor(private readonly options: D1Options) {}
placeholder(_index: number): string {
return "?";
}
async query<T = Record<string, unknown>>(
sql: string,
params: readonly unknown[] = [],
): Promise<T[]> {
this.statements.push({ sql, params: [...params] });
this.result = [];
return [] as T[];
}
async execute(
sql: string,
params: readonly unknown[] = [],
): Promise<{ changes: number }> {
this.statements.push({ sql, params: [...params] });
return { changes: 0 };
}
async transaction<T>(fn: (driver: SqlDriver) => Promise<T>): Promise<T> {
return fn(this);
}
async close(): Promise<void> {}
async flush(): Promise<void> {
if (this.statements.length === 0) return;
const endpoint =
this.options.endpoint ??
`https://api.cloudflare.com/client/v4/accounts/${this.options.accountId}/d1/database/${this.options.databaseId}/batch`;
const response = await fetch(endpoint, {
method: "POST",
headers: {
authorization: `Bearer ${this.options.apiToken}`,
"content-type": "application/json",
},
body: JSON.stringify({
statements: this.statements.map((statement) => ({
sql: statement.sql,
params: statement.params.map((value) => normalize(value)),
})),
}),
});
if (!response.ok) {
throw new Error(`D1 batch failed: ${response.statusText}`);
}
}
}
function normalize(value: unknown): unknown {
if (value === undefined || value === null) return null;
if (typeof value === "boolean") return value ? 1 : 0;
if (value instanceof Date) return value.toISOString();
if (typeof value === "object" && !(value instanceof Uint8Array)) {
return JSON.stringify(value);
}
return value;
}
/** Creates a Cloudflare D1 driver. */
export function createD1Driver(options: D1Options): D1Driver {
return new D1Driver(options);
}

165
src/adapters/driver.ts Normal file
View file

@ -0,0 +1,165 @@
import type {
ColumnMapping,
FieldType,
TableOptions,
} from "muddle/contracts/typegen/orm/index.ts";
/** Anything able to produce a parameter placeholder. */
export interface PlaceholderSource {
placeholder(index: number): string;
}
export interface SqlDialect {
readonly name: "sqlite" | "postgres";
/** SQL type of a field. */
columnType(type: FieldType): string;
/** Placeholder for the n-th parameter (1 based). */
placeholder(index: number): string;
/** `true` when the dialect understands `RETURNING`. */
readonly supportsReturning: boolean;
/** Default value expression of a column, if any. */
defaultExpression(column: ColumnMapping): string | undefined;
}
/** Quotes an SQL identifier. */
export function quote(identifier: string): string {
return `"${identifier.replace(/"/g, '""')}"`;
}
/** Registers a parameter and returns its placeholder. */
export function bind(
dialect: PlaceholderSource,
params: unknown[],
value: unknown,
): string {
params.push(value);
return dialect.placeholder(params.length);
}
export interface Condition {
column: string;
operator: string;
value?: unknown;
}
/** Builds a `WHERE` clause from the condition groups of the query builder. */
export function buildWhere(
dialect: PlaceholderSource,
groups: { conditions: Condition[]; conjunction: "AND" | "OR" }[],
params: unknown[],
): string {
if (groups.length === 0) return "";
const parts = groups.map((group) => {
const conditions = group.conditions.map((condition) =>
buildCondition(dialect, condition, params),
);
if (conditions.length === 1) return conditions[0] as string;
return `(${conditions.join(` ${group.conjunction} `)})`;
});
return ` WHERE ${parts.join(" AND ")}`;
}
/** Builds a single condition, e.g. `"age" > $1`. */
export function buildCondition(
dialect: PlaceholderSource,
condition: Condition,
params: unknown[],
): string {
const column = quote(condition.column);
if (
condition.operator === "is null" ||
condition.operator === "is not null"
) {
return `${column} ${condition.operator.toUpperCase()}`;
}
if (condition.operator === "in" || condition.operator === "not in") {
const values = Array.isArray(condition.value)
? condition.value
: [condition.value];
if (values.length === 0) {
return condition.operator === "in" ? "1 = 0" : "1 = 1";
}
const placeholders = values
.map((value) => bind(dialect, params, value))
.join(", ");
return `${column} ${condition.operator.toUpperCase()} (${placeholders})`;
}
return `${column} ${condition.operator} ${bind(dialect, params, condition.value)}`;
}
/** Builds the `CREATE TABLE` statement of a table. */
export function buildCreateTable(
dialect: SqlDialect,
table: string,
columns: ColumnMapping[],
options: TableOptions,
): string {
const definitions: string[] = [];
for (const column of columns) {
const parts = [quote(column.column), dialect.columnType(column.type)];
if (column.primary) parts.push("PRIMARY KEY");
if (column.unique) parts.push("UNIQUE");
if (!column.nullable && !column.primary) parts.push("NOT NULL");
const defaultExpression = dialect.defaultExpression(column);
if (defaultExpression) parts.push(`DEFAULT ${defaultExpression}`);
if (column.references) {
const target = quote(column.references.table);
const targetColumn = column.references.column
? `${target}(${quote(column.references.column)})`
: undefined;
parts.push(
`REFERENCES ${targetColumn ?? target}${column.references.onDelete ? ` ON DELETE ${column.references.onDelete}` : ""}${
column.references.onUpdate
? ` ON UPDATE ${column.references.onUpdate}`
: ""
}`,
);
}
definitions.push(parts.join(" "));
}
if (options.timestamps) {
definitions.push(
`${quote("created_at")} ${dialect.columnType("timestamp")} NOT NULL DEFAULT CURRENT_TIMESTAMP`,
);
definitions.push(
`${quote("updated_at")} ${dialect.columnType("timestamp")} NOT NULL DEFAULT CURRENT_TIMESTAMP`,
);
}
const primaryKey = options.primaryKey
? Array.isArray(options.primaryKey)
? options.primaryKey
: [options.primaryKey]
: undefined;
if (primaryKey && primaryKey.length > 0) {
definitions.push(`PRIMARY KEY (${primaryKey.map(quote).join(", ")})`);
}
const ifNotExists = options.ifNotExists === false ? "" : "IF NOT EXISTS ";
const suffix = options.suffix ? ` ${options.suffix}` : "";
return `CREATE TABLE ${ifNotExists}${quote(table)} (\n ${definitions.join(",\n ")}\n)${suffix}`;
}
/** Normalises a value before it is handed to the driver. */
export function normalizeValue(value: unknown): unknown {
if (value === undefined) return null;
if (value instanceof Date) return value;
if (typeof value === "boolean") return value;
if (typeof value === "bigint") return value;
if (
value !== null &&
typeof value === "object" &&
!Buffer.isBuffer(value) &&
!(value instanceof Uint8Array)
) {
return JSON.stringify(value);
}
return value;
}

118
src/adapters/pg.ts Normal file
View file

@ -0,0 +1,118 @@
import { Pool, type PoolClient, type PoolConfig } from "pg";
import type { SqlDriver } from "muddle/contracts/typegen/orm/index.ts";
import type { FieldType } from "muddle/contracts/typegen/orm/index.ts";
import { buildCreateTable, type SqlDialect } from "./driver.ts";
export type PostgresOptions = PoolConfig | string;
const POSTGRES_TYPES: Record<FieldType, string> = {
text: "TEXT",
int: "INTEGER",
bigint: "BIGINT",
real: "DOUBLE PRECISION",
bool: "BOOLEAN",
blob: "BYTEA",
json: "JSONB",
uuid: "UUID",
timestamp: "TIMESTAMPTZ",
date: "DATE",
};
export const postgresDialect: SqlDialect = {
name: "postgres",
columnType: (type) => POSTGRES_TYPES[type],
placeholder: (index) => `$${index}`,
supportsReturning: true,
defaultExpression: (column) => {
if (column.default === undefined) return undefined;
if (typeof column.default === "number") return String(column.default);
if (typeof column.default === "boolean")
return column.default ? "TRUE" : "FALSE";
if (typeof column.default === "object")
return JSON.stringify(column.default);
return `'${String(column.default).replace(/'/g, "''")}'`;
},
};
export class PostgresDriver implements SqlDriver {
readonly dialect = "postgres" as const;
/** `true` when the dialect understands `RETURNING`. */
readonly supportsReturning = true;
private readonly pool: Pool | undefined;
private readonly client?: PoolClient;
constructor(options: PostgresOptions, client?: PoolClient) {
this.pool = client
? undefined
: new Pool(
typeof options === "string" ? { connectionString: options } : options,
);
this.client = client;
}
private get runner(): Pool | PoolClient {
return this.client ?? (this.pool as Pool);
}
/** Placeholder for the n-th parameter (1 based). */
placeholder(index: number): string {
return `$${index}`;
}
/** Runs a statement and returns every row. */
async query<T = Record<string, unknown>>(
sql: string,
params: readonly unknown[] = [],
): Promise<T[]> {
const result = await this.runner.query(sql, [...params] as unknown[]);
return result.rows as T[];
}
/** Runs a statement and returns the number of affected rows. */
async execute(
sql: string,
params: readonly unknown[] = [],
): Promise<{ changes: number }> {
const result = await this.runner.query(sql, [...params] as unknown[]);
return { changes: result.rowCount ?? 0 };
}
/** Runs `fn` inside a transaction on a dedicated connection. */
async transaction<T>(fn: (driver: SqlDriver) => Promise<T>): Promise<T> {
const client = this.client ?? (await (this.pool as Pool).connect());
const ownsClient = !this.client;
const driver = new PostgresDriver({}, client);
try {
await client.query("BEGIN");
const result = await fn(driver);
await client.query("COMMIT");
return result;
} catch (error) {
await client.query("ROLLBACK");
throw error;
} finally {
if (ownsClient) client.release();
}
}
/** Creates the table of a model, if it does not exist yet. */
async createTable(
table: string,
columns: Parameters<typeof buildCreateTable>[2],
options: Parameters<typeof buildCreateTable>[3],
): Promise<void> {
await this.execute(
buildCreateTable(postgresDialect, table, columns, options),
);
}
/** Closes the pool. */
async close(): Promise<void> {
if (this.pool) await this.pool.end();
}
}
/** Creates a PostgreSQL driver. */
export function createPostgresDriver(options: PostgresOptions): PostgresDriver {
return new PostgresDriver(options);
}

130
src/adapters/sqlite.ts Normal file
View file

@ -0,0 +1,130 @@
import { Database as SqliteDatabase, type SQLQueryBindings } from "bun:sqlite";
import type { SqlDriver } from "muddle/contracts/typegen/orm/index.ts";
import type { FieldType } from "muddle/contracts/typegen/orm/index.ts";
import { buildCreateTable, normalizeValue, type SqlDialect } from "./driver.ts";
export interface SqliteOptions {
/** Path of the database file, `:memory:` for an in-memory database. */
path?: string;
/** Opens the database in read only mode. */
readonly?: boolean;
/** `false` disables the automatic creation of the database file. */
create?: boolean;
}
const SQLITE_TYPES: Record<FieldType, string> = {
text: "TEXT",
int: "INTEGER",
bigint: "INTEGER",
real: "REAL",
bool: "INTEGER",
blob: "BLOB",
json: "TEXT",
uuid: "TEXT",
timestamp: "TEXT",
date: "TEXT",
};
export const sqliteDialect: SqlDialect = {
name: "sqlite",
columnType: (type) => SQLITE_TYPES[type],
placeholder: () => "?",
supportsReturning: true,
defaultExpression: (column) => {
if (column.default === undefined) return undefined;
if (typeof column.default === "number") return String(column.default);
if (typeof column.default === "boolean") return column.default ? "1" : "0";
return `'${String(column.default).replace(/'/g, "''")}'`;
},
};
/** Converts a value into a representation SQLite understands. */
function toSqliteValue(value: unknown): unknown {
const normalized = normalizeValue(value);
if (normalized === null || normalized === undefined) return null;
if (typeof normalized === "boolean") return normalized ? 1 : 0;
if (normalized instanceof Date) return normalized.toISOString();
return normalized;
}
export class SqliteDriver implements SqlDriver {
readonly dialect = "sqlite" as const;
/** `true` when the dialect understands `RETURNING`. */
readonly supportsReturning = true;
private readonly database: SqliteDatabase;
private depth = 0;
constructor(options: SqliteOptions = {}) {
this.database = new SqliteDatabase(options.path ?? ":memory:", {
readonly: options.readonly ?? false,
create: options.create ?? true,
});
}
/** Placeholder for the n-th parameter (1 based). */
placeholder(_index: number): string {
return "?";
}
/** Runs a statement and returns every row. */
async query<T = Record<string, unknown>>(
sql: string,
params: readonly unknown[] = [],
): Promise<T[]> {
const statement = this.database.query(sql);
return statement.all(
...(params.map(toSqliteValue) as SQLQueryBindings[]),
) as T[];
}
/** Runs a statement and returns the number of affected rows. */
async execute(
sql: string,
params: readonly unknown[] = [],
): Promise<{ changes: number }> {
const statement = this.database.query(sql);
const info = statement.run(
...(params.map(toSqliteValue) as SQLQueryBindings[]),
);
return { changes: info.changes };
}
/** Runs `fn` inside a transaction, nested calls use savepoints. */
async transaction<T>(fn: (driver: SqlDriver) => Promise<T>): Promise<T> {
const nested = this.depth > 0;
const savepoint = `muddle_sp_${this.depth}`;
await this.execute(nested ? `SAVEPOINT ${savepoint}` : "BEGIN");
this.depth += 1;
try {
const result = await fn(this);
this.depth -= 1;
await this.execute(nested ? `RELEASE ${savepoint}` : "COMMIT");
return result;
} catch (error) {
this.depth -= 1;
await this.execute(nested ? `ROLLBACK TO ${savepoint}` : "ROLLBACK");
throw error;
}
}
/** Creates the table of a model, if it does not exist yet. */
async createTable(
table: string,
columns: Parameters<typeof buildCreateTable>[2],
options: Parameters<typeof buildCreateTable>[3],
): Promise<void> {
await this.execute(
buildCreateTable(sqliteDialect, table, columns, options),
);
}
/** Closes the database. */
async close(): Promise<void> {
this.database.close();
}
}
/** Creates a SQLite driver. */
export function createSqliteDriver(options: SqliteOptions = {}): SqliteDriver {
return new SqliteDriver(options);
}

55
src/database.ts Normal file
View file

@ -0,0 +1,55 @@
import type {
ColumnMapping,
SqlDriver,
TableOptions,
} from "muddle/contracts/typegen/orm/index.ts";
import type { TableClass } from "muddle/contracts/typegen/orm/table.ts";
import { getTableMetadata } from "./decorators/Table.ts";
import { Repository } from "./repository.ts";
/**
* Database.
*
* Owns the driver, keeps track of the registered models and creates the
* tables of those models.
*/
export class Database {
private readonly models = new Map<string, TableClass<object>>();
constructor(readonly driver: SqlDriver) {}
/** Registers a model and returns its repository. */
model<T extends object>(model: TableClass<T>): Repository<T> {
const metadata = getTableMetadata(model as unknown as object);
this.models.set(metadata.name, model as unknown as TableClass<object>);
return new Repository<T>(this.driver, model);
}
/** Creates the tables of every registered model. */
async migrate(): Promise<void> {
for (const model of this.models.values()) {
const metadata = getTableMetadata(model as unknown as object);
const columns = [...metadata.fields.values()];
const driver = this.driver as SqlDriver & {
createTable?: (
table: string,
columns: ColumnMapping[],
options: TableOptions,
) => Promise<void>;
};
if (driver.createTable) {
await driver.createTable(metadata.name, columns, metadata.options);
}
}
}
/** Runs `fn` inside a transaction. */
async transaction<T>(fn: (database: Database) => Promise<T>): Promise<T> {
return this.driver.transaction(async (driver) => fn(new Database(driver)));
}
/** Closes the underlying driver. */
async close(): Promise<void> {
await this.driver.close();
}
}

40
src/decorators/Field.ts Normal file
View file

@ -0,0 +1,40 @@
import { defineMetadata, getMetadata } from "muddle/metadata";
import type { FieldOptions } from "muddle/contracts/typegen/orm/field.ts";
import type { FieldDescriptor } from "muddle/contracts/typegen/orm/field.ts";
export const FIELD_METADATA_KEY = "muddle:orm:fields";
/** Converts a property name into the default column name. */
export function toColumnName(property: string): string {
return property
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
.toLowerCase();
}
/** Declares the column backing a property. */
export function Field<Value = any>(
options: FieldOptions<Value>,
): PropertyDecorator {
return (target, propertyKey) => {
const constructor = target.constructor as object;
const fields = getFields(constructor);
const property = String(propertyKey);
fields.set(property, {
property,
column: options.column ?? toColumnName(property),
options,
});
};
}
/** Reads the fields declared on a model class. */
export function getFields(target: object): Map<string, FieldDescriptor> {
const existing = getMetadata(FIELD_METADATA_KEY, target) as
| Map<string, FieldDescriptor>
| undefined;
if (existing) return existing;
const fields = new Map<string, FieldDescriptor>();
defineMetadata(FIELD_METADATA_KEY, fields, target);
return fields;
}

42
src/decorators/Hydrate.ts Normal file
View file

@ -0,0 +1,42 @@
import { defineMetadata, getMetadata } from "muddle/metadata";
export const HYDRATE_METADATA_KEY = "muddle:orm:hydrated";
export interface HydrateOptions<Value = any> {
/** Computes the value from the raw database row. */
decode?: (row: Record<string, unknown>) => Value;
/** Column the value is derived from, handed to `decode` as second argument. */
column?: string;
}
export interface HydrateDescriptor {
property: string;
options: HydrateOptions;
}
/** Declares a hydrated (computed) property. */
export function Hydrate<Value = any>(
options: HydrateOptions<Value> = {},
): PropertyDecorator {
return (target, propertyKey) => {
const constructor = target.constructor as object;
const hydrated = getHydratedProperties(constructor);
hydrated.set(String(propertyKey), {
property: String(propertyKey),
options: options as HydrateOptions,
});
};
}
/** Reads the hydrated properties declared on a model class. */
export function getHydratedProperties(
target: object,
): Map<string, HydrateDescriptor> {
const existing = getMetadata(HYDRATE_METADATA_KEY, target) as
| Map<string, HydrateDescriptor>
| undefined;
if (existing) return existing;
const hydrated = new Map<string, HydrateDescriptor>();
defineMetadata(HYDRATE_METADATA_KEY, hydrated, target);
return hydrated;
}

85
src/decorators/Table.ts Normal file
View file

@ -0,0 +1,85 @@
import { defineMetadata, getMetadata } from "muddle/metadata";
import type { ColumnMapping } from "muddle/contracts/typegen/orm/field.ts";
import type { TableOptions } from "muddle/contracts/typegen/orm/table.ts";
import { getFields } from "./Field.ts";
import { getHydratedProperties } from "./Hydrate.ts";
export const TABLE_METADATA_KEY = "muddle:orm:table";
export interface TableMetadata {
/** Table name. */
name: string;
options: TableOptions;
/** Persisted fields, in declaration order. */
fields: Map<string, ColumnMapping>;
/** Raw field descriptors, including the encode/decode transformations. */
descriptors: Map<
string,
{ property: string; column: string; options: unknown }
>;
/** Computed properties, hydrated but never persisted. */
hydrated: Map<string, { property: string; options: unknown }>;
}
/** Declares the table backing a model class. */
export function Table(
name: string,
options: TableOptions = {},
): ClassDecorator {
return (target) => {
const constructor = target as unknown as object;
const descriptors = getFields(constructor);
const hydrated = getHydratedProperties(constructor);
const fields = new Map<string, ColumnMapping>();
for (const [property, descriptor] of descriptors) {
const fieldOptions = descriptor.options as {
type: ColumnMapping["type"];
primary?: boolean;
unique?: boolean;
nullable?: boolean;
default?: unknown;
references?: ColumnMapping["references"];
};
fields.set(property, {
property,
column: descriptor.column,
type: fieldOptions.type,
nullable: fieldOptions.nullable === true,
primary: fieldOptions.primary === true,
unique: fieldOptions.unique === true,
default: fieldOptions.default,
references: fieldOptions.references,
});
}
const metadata: TableMetadata = {
name,
options,
fields,
descriptors: descriptors as TableMetadata["descriptors"],
hydrated: hydrated as TableMetadata["hydrated"],
};
defineMetadata(TABLE_METADATA_KEY, metadata, constructor);
return target;
};
}
/** Reads the table metadata of a model class. */
export function getTableMetadata(target: object): TableMetadata {
const metadata = getMetadata(TABLE_METADATA_KEY, target) as
| TableMetadata
| undefined;
if (!metadata) {
throw new Error(
`${(target as { name?: string }).name ?? "model"} is not decorated with @Table()`,
);
}
return metadata;
}
/** `true` when the class is a table model. */
export function isTable(target: object): boolean {
return getMetadata(TABLE_METADATA_KEY, target) !== undefined;
}

81
src/hydration.ts Normal file
View file

@ -0,0 +1,81 @@
import { getTableMetadata } from "./decorators/Table.ts";
import type { TableClass } from "muddle/contracts/typegen/orm/table.ts";
function createInstance<T>(model: TableClass<T>): T {
try {
return new (model as unknown as new () => T)();
} catch {
return Object.create(model.prototype) as T;
}
}
/** Hydrates a raw database row into a model instance. */
export function hydrateRow<T>(
model: TableClass<T>,
row: Record<string, unknown>,
): T {
const metadata = getTableMetadata(model as unknown as object);
const instance = createInstance(model);
const target = instance as unknown as Record<string, unknown>;
for (const [property, descriptor] of metadata.descriptors) {
const raw = row[descriptor.column];
const options = descriptor.options as {
decode?: (value: unknown) => unknown;
};
target[property] = options.decode ? options.decode(raw) : raw;
}
for (const [property, descriptor] of metadata.hydrated) {
const options = descriptor.options as {
decode?: (row: Record<string, unknown>) => unknown;
column?: string;
};
let value: unknown;
if (options.decode) {
value = options.decode(row);
} else {
const getter = Object.getOwnPropertyDescriptor(
model.prototype,
property,
)?.get;
value = getter ? getter.call(instance) : undefined;
}
Object.defineProperty(target, property, {
value,
enumerable: true,
writable: true,
configurable: true,
});
}
return instance;
}
/** Encodes a model value into the values written to the database. */
export function encodeRow<T>(
model: TableClass<T>,
data: Partial<T> | Record<string, unknown>,
): Record<string, unknown> {
const metadata = getTableMetadata(model as unknown as object);
const encoded: Record<string, unknown> = {};
for (const [key, value] of Object.entries(data as Record<string, unknown>)) {
const descriptor = metadata.descriptors.get(key);
if (!descriptor) continue;
const options = descriptor.options as {
encode?: (value: unknown) => unknown;
};
encoded[descriptor.column] = options.encode ? options.encode(value) : value;
}
return encoded;
}
/** Hydrates a list of raw rows. */
export function hydrateRows<T>(
model: TableClass<T>,
rows: Record<string, unknown>[],
): T[] {
return rows.map((row) => hydrateRow(model, row));
}

68
src/index.ts Normal file
View file

@ -0,0 +1,68 @@
import type { PoolConfig } from "pg";
import { createD1Driver, D1Driver } from "./adapters/d1.ts";
import { createPostgresDriver, PostgresDriver } from "./adapters/pg.ts";
import { createSqliteDriver } from "./adapters/sqlite.ts";
import { Database } from "./database.ts";
export * from "./adapters/d1.ts";
export * from "./adapters/driver.ts";
export * from "./adapters/pg.ts";
export * from "./adapters/sqlite.ts";
export * from "./database.ts";
export * from "./decorators/Field.ts";
export * from "./decorators/Hydrate.ts";
export * from "./decorators/Table.ts";
export * from "./hydration.ts";
export * from "./repository.ts";
export * from "./migrations.ts";
export interface SqliteDatabaseOptions {
dialect: "sqlite";
/** Path of the database file, `:memory:` for an in-memory database. */
path?: string;
readonly?: boolean;
}
export interface PostgresDatabaseOptions {
dialect: "postgres";
/** Connection string, e.g. `postgres://user:pass@localhost:5432/db`. */
url?: string;
/** Connection options, used when no url is given. */
connection?: PoolConfig;
}
export interface D1DatabaseOptions {
dialect: "d1";
/** Account the database belongs to. */
accountId: string;
/** Identifier of the database. */
databaseId: string;
/** Api token of the account. */
apiToken: string;
/** Endpoint of the api, useful for tests. */
endpoint?: string;
}
/** Creates a database backed by SQLite or PostgreSQL. */
export function createDatabase(
options: SqliteDatabaseOptions | PostgresDatabaseOptions | D1DatabaseOptions,
): Database {
if (options.dialect === "sqlite") {
return new Database(
createSqliteDriver({ path: options.path, readonly: options.readonly }),
);
}
if (options.dialect === "d1") {
const driver: D1Driver = createD1Driver({
accountId: options.accountId,
databaseId: options.databaseId,
apiToken: options.apiToken,
endpoint: options.endpoint,
});
return new Database(driver);
}
const driver: PostgresDriver = options.url
? createPostgresDriver(options.url)
: createPostgresDriver(options.connection ?? {});
return new Database(driver);
}

43
src/migrations.ts Normal file
View file

@ -0,0 +1,43 @@
import type { TableClass } from "muddle/contracts/typegen/orm/index.ts";
import { getTableMetadata } from "./decorators/Table.ts";
export interface SchemaColumn { name: string; type: string; nullable: boolean }
export interface SchemaTable { name: string; columns: SchemaColumn[] }
export interface Migration { up: string[]; down: string[] }
export function schemaOf(tables: TableClass[]): SchemaTable[] {
return tables.map((table) => {
const metadata = getTableMetadata(table);
if (!metadata) throw new Error(`Missing @Table metadata for ${table.name}`);
return { name: metadata.name, columns: [...metadata.fields.values()].map((field) => ({ name: field.column, type: field.type, nullable: field.nullable })) };
});
}
export function diffSchema(previous: SchemaTable[], next: SchemaTable[]): Migration {
const oldTables = new Map(previous.map((table) => [table.name, table]));
const nextTables = new Map(next.map((table) => [table.name, table]));
const up: string[] = [];
const down: string[] = [];
for (const table of next) {
const old = oldTables.get(table.name);
if (!old) {
up.push(`CREATE TABLE ${table.name} (${table.columns.map(columnSql).join(", ")});`);
down.unshift(`DROP TABLE ${table.name};`);
continue;
}
const oldColumns = new Map(old.columns.map((column) => [column.name, column]));
for (const column of table.columns) if (!oldColumns.has(column.name)) {
up.push(`ALTER TABLE ${table.name} ADD COLUMN ${columnSql(column)};`);
down.unshift(`ALTER TABLE ${table.name} DROP COLUMN ${column.name};`);
}
}
for (const table of previous) if (!nextTables.has(table.name)) {
up.push(`DROP TABLE ${table.name};`);
down.unshift(`CREATE TABLE ${table.name} (${table.columns.map(columnSql).join(", ")});`);
}
return { up, down };
}
function columnSql(column: SchemaColumn): string {
return `${column.name} ${column.type.toUpperCase()}${column.nullable ? "" : " NOT NULL"}`;
}

281
src/repository.ts Normal file
View file

@ -0,0 +1,281 @@
import type {
ColumnName,
OrderDirection,
Repository as RepositoryContract,
WhereCondition,
WhereInput,
WhereOperator,
} from "muddle/contracts/typegen/orm/index.ts";
import { WHERE_OPERATORS } from "muddle/contracts/typegen/orm/index.ts";
import type { SqlDriver } from "muddle/contracts/typegen/orm/index.ts";
import type { TableClass } from "muddle/contracts/typegen/orm/table.ts";
import { getTableMetadata } from "./decorators/Table.ts";
import { encodeRow, hydrateRow, hydrateRows } from "./hydration.ts";
import { buildWhere, quote, type Condition } from "./adapters/driver.ts";
interface QueryState {
groups: { conditions: Condition[]; conjunction: "AND" | "OR" }[];
order: { column: string; direction: OrderDirection }[];
limit?: number;
offset?: number;
select?: string[];
}
const EMPTY_STATE: QueryState = { groups: [], order: [] };
function isOperator(value: unknown): value is WhereOperator {
return (
typeof value === "string" &&
(WHERE_OPERATORS as readonly string[]).includes(value)
);
}
export class Repository<T extends object> implements RepositoryContract<T> {
constructor(
private readonly driver: SqlDriver,
private readonly model: TableClass<T>,
private readonly state: QueryState = EMPTY_STATE,
) {}
private get table(): string {
return getTableMetadata(this.model as unknown as object).name;
}
private clone(state: Partial<QueryState>): this {
return new Repository(this.driver, this.model, {
...this.state,
...state,
}) as unknown as this;
}
/** Restricts the selected columns. */
select(...columns: ColumnName<T>[]): this {
return this.clone({
select: columns.length > 0 ? [...columns] : undefined,
});
}
/** Adds a condition. */
where(conditions: WhereCondition[]): this;
where(column: ColumnName<T>, value: unknown): this;
where(column: ColumnName<T>, operator: WhereOperator, value?: unknown): this;
where(
columnOrConditions: ColumnName<T> | WhereCondition[],
operatorOrValue?: WhereOperator | unknown,
value?: unknown,
): this {
if (Array.isArray(columnOrConditions)) {
return this.and(columnOrConditions);
}
const column = String(columnOrConditions);
if (isOperator(operatorOrValue)) {
return this.clone({
groups: [
...this.state.groups,
{
conditions: [{ column, operator: operatorOrValue, value }],
conjunction: "AND",
},
],
});
}
return this.clone({
groups: [
...this.state.groups,
{
conditions: [{ column, operator: "=", value: operatorOrValue }],
conjunction: "AND",
},
],
});
}
/** Adds a group of conditions combined with `AND`. */
and(conditions: WhereCondition[]): this {
return this.clone({
groups: [
...this.state.groups,
{ conditions: [...conditions], conjunction: "AND" },
],
});
}
/** Adds a group of conditions combined with `OR`. */
or(conditions: WhereCondition[]): this {
return this.clone({
groups: [
...this.state.groups,
{ conditions: [...conditions], conjunction: "OR" },
],
});
}
/** Orders the result set. */
orderBy(column: ColumnName<T>, direction: OrderDirection = "asc"): this {
return this.clone({
order: [...this.state.order, { column: String(column), direction }],
});
}
/** Limits the result set. */
limit(count: number): this {
return this.clone({ limit: count });
}
/** Skips rows. */
offset(count: number): this {
return this.clone({ offset: count });
}
/**
* Runs a raw statement and returns the rows.
*
* The rows are not hydrated, the method is meant for the statements the
* repository does not cover.
*/
async query<R = Record<string, unknown>>(
sql: string,
params: readonly unknown[] = [],
): Promise<R[]> {
return this.driver.query<R>(sql, params);
}
/** Executes the query and hydrates every row. */
async all(): Promise<T[]> {
const params: unknown[] = [];
const columns = this.state.select
? this.state.select
.map((column) => quote(this.columnOf(column)))
.join(", ")
: "*";
const where = buildWhere(this.driver, this.state.groups, params);
const order = this.buildOrder();
const limit = this.buildLimit(params);
const sql = `SELECT ${columns} FROM ${quote(this.table)}${where}${order}${limit}`;
const rows = await this.driver.query<Record<string, unknown>>(sql, params);
return hydrateRows(this.model, rows);
}
/** Alias of {@link all}. */
async find(where?: WhereInput<T>): Promise<T[]> {
const repository = where ? this.applyWhereInput(where) : this;
return repository.all();
}
/** Executes the query and hydrates the first row. */
async first(): Promise<T | null> {
const rows = await this.limit(1).all();
return rows[0] ?? null;
}
/** Alias of {@link first}. */
async findOne(where?: WhereInput<T>): Promise<T | null> {
const repository = where ? this.applyWhereInput(where) : this;
return repository.first();
}
/** Counts the rows matching the current conditions. */
async count(): Promise<number> {
const params: unknown[] = [];
const where = buildWhere(this.driver, this.state.groups, params);
const sql = `SELECT COUNT(*) AS ${quote("count")} FROM ${quote(this.table)}${where}`;
const rows = await this.driver.query<{ count: string | number }>(
sql,
params,
);
const value = rows[0]?.count ?? 0;
return Number(value);
}
/** Inserts a row, encoding every value before the commit. */
async create(data: Partial<T>): Promise<T> {
const encoded = encodeRow(this.model, data);
const columns = Object.keys(encoded);
if (columns.length === 0) {
throw new Error(`Cannot insert an empty row into ${this.table}`);
}
const params: unknown[] = [];
const placeholders = columns.map((column) => {
params.push(encoded[column]);
return this.driver.placeholder(params.length);
});
const returning = this.driver.supportsReturning ? " RETURNING *" : "";
const sql = `INSERT INTO ${quote(this.table)} (${columns.map(quote).join(", ")}) VALUES (${placeholders.join(", ")})${returning}`;
const rows = await this.driver.query<Record<string, unknown>>(sql, params);
const row = rows[0];
if (!row) {
throw new Error(`Insert into ${this.table} did not return a row`);
}
return hydrateRow(this.model, row);
}
/** Updates the matching rows, encoding every value before the commit. */
async update(data: Partial<T>): Promise<number> {
const encoded = encodeRow(this.model, data);
const columns = Object.keys(encoded);
if (columns.length === 0) return 0;
const params: unknown[] = [];
const assignments = columns.map((column) => {
params.push(encoded[column]);
return `${quote(column)} = ${this.driver.placeholder(params.length)}`;
});
const where = buildWhere(this.driver, this.state.groups, params);
const sql = `UPDATE ${quote(this.table)} SET ${assignments.join(", ")}${where}`;
const result = await this.driver.execute(sql, params);
return result.changes;
}
/** Deletes the matching rows. */
async delete(): Promise<number> {
const params: unknown[] = [];
const where = buildWhere(this.driver, this.state.groups, params);
const sql = `DELETE FROM ${quote(this.table)}${where}`;
const result = await this.driver.execute(sql, params);
return result.changes;
}
private get dialect(): SqlDriver["dialect"] {
return this.driver.dialect;
}
private applyWhereInput(where: WhereInput<T>): this {
if (Array.isArray(where)) {
return this.and(where);
}
const conditions: WhereCondition[] = Object.entries(where).map(
([column, value]) => ({
column,
operator: "=",
value,
}),
);
return this.and(conditions);
}
private columnOf(column: string): string {
const metadata = getTableMetadata(this.model as unknown as object);
return metadata.fields.get(column)?.column ?? column;
}
private buildOrder(): string {
if (this.state.order.length === 0) return "";
const parts = this.state.order.map(
(entry) =>
`${quote(this.columnOf(entry.column))} ${entry.direction.toUpperCase()}`,
);
return ` ORDER BY ${parts.join(", ")}`;
}
private buildLimit(params: unknown[]): string {
let sql = "";
if (this.state.limit !== undefined) {
params.push(this.state.limit);
sql += ` LIMIT ${this.driver.placeholder(params.length)}`;
}
if (this.state.offset !== undefined) {
params.push(this.state.offset);
sql += ` OFFSET ${this.driver.placeholder(params.length)}`;
}
return sql;
}
}

35
src/sessions.ts Normal file
View file

@ -0,0 +1,35 @@
import type { SessionRecord, SessionStore } from "muddle/sessions";
import type { SqlDriver } from "muddle/contracts/typegen/orm/index.ts";
/** Persistent sessions and atomic OAuth transaction consumption for SQLite, D1 and Postgres. */
export class DatabaseSessionStore implements SessionStore {
private constructor(private readonly driver: SqlDriver, private readonly table: string) {}
static async create(driver: SqlDriver, table = "muddle_sessions"): Promise<DatabaseSessionStore> {
if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(table)) throw new Error("Invalid session table name");
await driver.execute(`CREATE TABLE IF NOT EXISTS "${table}" (id TEXT PRIMARY KEY, value TEXT NOT NULL, expires BIGINT NOT NULL)`);
return new DatabaseSessionStore(driver, table);
}
async get(key: string): Promise<SessionRecord | undefined> {
const rows = await this.driver.query<{ value: string; expires: number }>(`SELECT value, expires FROM "${this.table}" WHERE id = ${this.driver.placeholder(1)}`, [key]);
const row = rows[0];
if (!row || Number(row.expires) <= Date.now()) { await this.delete(key); return undefined; }
return { value: JSON.parse(row.value), expires: Number(row.expires) };
}
async set(key: string, record: SessionRecord): Promise<void> {
const args = [key, JSON.stringify(record.value), record.expires];
await this.driver.execute(`INSERT INTO "${this.table}" (id, value, expires) VALUES (${args.map((_, index) => this.driver.placeholder(index + 1)).join(", ")}) ON CONFLICT (id) DO UPDATE SET value = excluded.value, expires = excluded.expires`, args);
}
async delete(key: string): Promise<void> { await this.driver.execute(`DELETE FROM "${this.table}" WHERE id = ${this.driver.placeholder(1)}`, [key]); }
async consume(key: string): Promise<SessionRecord | undefined> {
const rows = await this.driver.query<{ value: string; expires: number }>(`DELETE FROM "${this.table}" WHERE id = ${this.driver.placeholder(1)} RETURNING value, expires`, [key]);
const row = rows[0];
return row && Number(row.expires) > Date.now() ? { value: JSON.parse(row.value), expires: Number(row.expires) } : undefined;
}
async prune(): Promise<void> { await this.driver.execute(`DELETE FROM "${this.table}" WHERE expires <= ${this.driver.placeholder(1)}`, [Date.now()]); }
}