From 2864812fa3744d30cc81f9bad9d3dbd8a58215c4 Mon Sep 17 00:00:00 2001 From: Hellings Date: Fri, 23 Oct 2026 08:53:07 -0400 Subject: [PATCH] Initial commit --- index.ts | 2 + package.json | 12 ++ src/adapters/d1.ts | 213 +++++++++++++++++++++++++++++ src/adapters/driver.ts | 165 ++++++++++++++++++++++ src/adapters/pg.ts | 118 ++++++++++++++++ src/adapters/sqlite.ts | 130 ++++++++++++++++++ src/database.ts | 55 ++++++++ src/decorators/Field.ts | 40 ++++++ src/decorators/Hydrate.ts | 42 ++++++ src/decorators/Table.ts | 85 ++++++++++++ src/hydration.ts | 81 +++++++++++ src/index.ts | 68 +++++++++ src/migrations.ts | 43 ++++++ src/repository.ts | 281 ++++++++++++++++++++++++++++++++++++++ src/sessions.ts | 35 +++++ 15 files changed, 1370 insertions(+) create mode 100644 index.ts create mode 100644 package.json create mode 100644 src/adapters/d1.ts create mode 100644 src/adapters/driver.ts create mode 100644 src/adapters/pg.ts create mode 100644 src/adapters/sqlite.ts create mode 100644 src/database.ts create mode 100644 src/decorators/Field.ts create mode 100644 src/decorators/Hydrate.ts create mode 100644 src/decorators/Table.ts create mode 100644 src/hydration.ts create mode 100644 src/index.ts create mode 100644 src/migrations.ts create mode 100644 src/repository.ts create mode 100644 src/sessions.ts diff --git a/index.ts b/index.ts new file mode 100644 index 0000000..84f7fd1 --- /dev/null +++ b/index.ts @@ -0,0 +1,2 @@ +export * from "./src/index.ts"; +export { DatabaseSessionStore } from "./src/sessions.ts"; diff --git a/package.json b/package.json new file mode 100644 index 0000000..1ea59a8 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "@muddle/database", + "type": "module", + "exports": "./index.ts", + "version": "0.1.0", + "peerDependencies": { + "muddle": "*" + }, + "dependencies": { + "pg": "^8.23.1" + } +} diff --git a/src/adapters/d1.ts b/src/adapters/d1.ts new file mode 100644 index 0000000..5ac0006 --- /dev/null +++ b/src/adapters/d1.ts @@ -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 = { + 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[]; + 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>( + sql: string, + params: readonly unknown[] = [], + ): Promise { + 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(fn: (driver: SqlDriver) => Promise): Promise { + 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[2], + options: Parameters[3], + ): Promise { + await this.execute(buildCreateTable(d1Dialect, table, columns, options)); + } + + /** Closes the driver, D1 has no local resources. */ + async close(): Promise {} + + private async send( + sql: string, + params: readonly unknown[], + ): Promise { + 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>( + sql: string, + params: readonly unknown[] = [], + ): Promise { + 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(fn: (driver: SqlDriver) => Promise): Promise { + return fn(this); + } + + async close(): Promise {} + + async flush(): Promise { + 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); +} diff --git a/src/adapters/driver.ts b/src/adapters/driver.ts new file mode 100644 index 0000000..9ba2967 --- /dev/null +++ b/src/adapters/driver.ts @@ -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; +} diff --git a/src/adapters/pg.ts b/src/adapters/pg.ts new file mode 100644 index 0000000..3e97c4d --- /dev/null +++ b/src/adapters/pg.ts @@ -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 = { + 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>( + sql: string, + params: readonly unknown[] = [], + ): Promise { + 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(fn: (driver: SqlDriver) => Promise): Promise { + 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[2], + options: Parameters[3], + ): Promise { + await this.execute( + buildCreateTable(postgresDialect, table, columns, options), + ); + } + + /** Closes the pool. */ + async close(): Promise { + if (this.pool) await this.pool.end(); + } +} + +/** Creates a PostgreSQL driver. */ +export function createPostgresDriver(options: PostgresOptions): PostgresDriver { + return new PostgresDriver(options); +} diff --git a/src/adapters/sqlite.ts b/src/adapters/sqlite.ts new file mode 100644 index 0000000..b78681e --- /dev/null +++ b/src/adapters/sqlite.ts @@ -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 = { + 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>( + sql: string, + params: readonly unknown[] = [], + ): Promise { + 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(fn: (driver: SqlDriver) => Promise): Promise { + 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[2], + options: Parameters[3], + ): Promise { + await this.execute( + buildCreateTable(sqliteDialect, table, columns, options), + ); + } + + /** Closes the database. */ + async close(): Promise { + this.database.close(); + } +} + +/** Creates a SQLite driver. */ +export function createSqliteDriver(options: SqliteOptions = {}): SqliteDriver { + return new SqliteDriver(options); +} diff --git a/src/database.ts b/src/database.ts new file mode 100644 index 0000000..ff889e8 --- /dev/null +++ b/src/database.ts @@ -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>(); + + constructor(readonly driver: SqlDriver) {} + + /** Registers a model and returns its repository. */ + model(model: TableClass): Repository { + const metadata = getTableMetadata(model as unknown as object); + this.models.set(metadata.name, model as unknown as TableClass); + return new Repository(this.driver, model); + } + + /** Creates the tables of every registered model. */ + async migrate(): Promise { + 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; + }; + if (driver.createTable) { + await driver.createTable(metadata.name, columns, metadata.options); + } + } + } + + /** Runs `fn` inside a transaction. */ + async transaction(fn: (database: Database) => Promise): Promise { + return this.driver.transaction(async (driver) => fn(new Database(driver))); + } + + /** Closes the underlying driver. */ + async close(): Promise { + await this.driver.close(); + } +} diff --git a/src/decorators/Field.ts b/src/decorators/Field.ts new file mode 100644 index 0000000..bbf8272 --- /dev/null +++ b/src/decorators/Field.ts @@ -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( + options: FieldOptions, +): 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 { + const existing = getMetadata(FIELD_METADATA_KEY, target) as + | Map + | undefined; + if (existing) return existing; + const fields = new Map(); + defineMetadata(FIELD_METADATA_KEY, fields, target); + return fields; +} diff --git a/src/decorators/Hydrate.ts b/src/decorators/Hydrate.ts new file mode 100644 index 0000000..76e0b95 --- /dev/null +++ b/src/decorators/Hydrate.ts @@ -0,0 +1,42 @@ +import { defineMetadata, getMetadata } from "muddle/metadata"; + +export const HYDRATE_METADATA_KEY = "muddle:orm:hydrated"; + +export interface HydrateOptions { + /** Computes the value from the raw database row. */ + decode?: (row: Record) => 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( + options: HydrateOptions = {}, +): 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 { + const existing = getMetadata(HYDRATE_METADATA_KEY, target) as + | Map + | undefined; + if (existing) return existing; + const hydrated = new Map(); + defineMetadata(HYDRATE_METADATA_KEY, hydrated, target); + return hydrated; +} diff --git a/src/decorators/Table.ts b/src/decorators/Table.ts new file mode 100644 index 0000000..ff19ebb --- /dev/null +++ b/src/decorators/Table.ts @@ -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; + /** 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; +} + +/** 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(); + 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; +} diff --git a/src/hydration.ts b/src/hydration.ts new file mode 100644 index 0000000..8fac546 --- /dev/null +++ b/src/hydration.ts @@ -0,0 +1,81 @@ +import { getTableMetadata } from "./decorators/Table.ts"; +import type { TableClass } from "muddle/contracts/typegen/orm/table.ts"; + +function createInstance(model: TableClass): 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( + model: TableClass, + row: Record, +): T { + const metadata = getTableMetadata(model as unknown as object); + const instance = createInstance(model); + const target = instance as unknown as Record; + + 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) => 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( + model: TableClass, + data: Partial | Record, +): Record { + const metadata = getTableMetadata(model as unknown as object); + const encoded: Record = {}; + + for (const [key, value] of Object.entries(data as Record)) { + 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( + model: TableClass, + rows: Record[], +): T[] { + return rows.map((row) => hydrateRow(model, row)); +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..08fd40e --- /dev/null +++ b/src/index.ts @@ -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); +} diff --git a/src/migrations.ts b/src/migrations.ts new file mode 100644 index 0000000..0666974 --- /dev/null +++ b/src/migrations.ts @@ -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"}`; +} diff --git a/src/repository.ts b/src/repository.ts new file mode 100644 index 0000000..621c14d --- /dev/null +++ b/src/repository.ts @@ -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 implements RepositoryContract { + constructor( + private readonly driver: SqlDriver, + private readonly model: TableClass, + private readonly state: QueryState = EMPTY_STATE, + ) {} + + private get table(): string { + return getTableMetadata(this.model as unknown as object).name; + } + + private clone(state: Partial): this { + return new Repository(this.driver, this.model, { + ...this.state, + ...state, + }) as unknown as this; + } + + /** Restricts the selected columns. */ + select(...columns: ColumnName[]): this { + return this.clone({ + select: columns.length > 0 ? [...columns] : undefined, + }); + } + + /** Adds a condition. */ + where(conditions: WhereCondition[]): this; + where(column: ColumnName, value: unknown): this; + where(column: ColumnName, operator: WhereOperator, value?: unknown): this; + where( + columnOrConditions: ColumnName | 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, 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>( + sql: string, + params: readonly unknown[] = [], + ): Promise { + return this.driver.query(sql, params); + } + + /** Executes the query and hydrates every row. */ + async all(): Promise { + 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>(sql, params); + return hydrateRows(this.model, rows); + } + + /** Alias of {@link all}. */ + async find(where?: WhereInput): Promise { + const repository = where ? this.applyWhereInput(where) : this; + return repository.all(); + } + + /** Executes the query and hydrates the first row. */ + async first(): Promise { + const rows = await this.limit(1).all(); + return rows[0] ?? null; + } + + /** Alias of {@link first}. */ + async findOne(where?: WhereInput): Promise { + const repository = where ? this.applyWhereInput(where) : this; + return repository.first(); + } + + /** Counts the rows matching the current conditions. */ + async count(): Promise { + 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): Promise { + 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>(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): Promise { + 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 { + 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): 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; + } +} diff --git a/src/sessions.ts b/src/sessions.ts new file mode 100644 index 0000000..f4e8898 --- /dev/null +++ b/src/sessions.ts @@ -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 { + 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 { + 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 { + 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 { await this.driver.execute(`DELETE FROM "${this.table}" WHERE id = ${this.driver.placeholder(1)}`, [key]); } + + async consume(key: string): Promise { + 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 { await this.driver.execute(`DELETE FROM "${this.table}" WHERE expires <= ${this.driver.placeholder(1)}`, [Date.now()]); } +}