From 47056b1d33a3e326088ff5cf586ac61cca6a9fec Mon Sep 17 00:00:00 2001 From: Jeff Repanich Date: Sat, 15 Aug 2026 08:55:58 -0400 Subject: [PATCH] docs: complete public API documentation --- package-lock.json | 4 +-- package.json | 2 +- src/adapter.ts | 8 ++++++ src/client.ts | 40 +++++++++++++++++++++++++++ src/definition.ts | 10 +++++++ src/errors.ts | 7 +++++ src/migrations.ts | 12 +++++++++ src/naming.ts | 12 +++++++++ src/postgres.ts | 7 +++++ src/query.ts | 37 +++++++++++++++++++++++++ src/registered-query.ts | 9 +++++++ src/schema.ts | 60 +++++++++++++++++++++++++++++++++++++++++ src/sql.ts | 40 +++++++++++++++++++++++++++ src/sqlite.ts | 7 +++++ src/tooling.ts | 19 +++++++++++++ 15 files changed, 271 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index d42cc86..db16b8d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@askrjs/orm", - "version": "0.0.0", + "version": "0.0.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@askrjs/orm", - "version": "0.0.0", + "version": "0.0.1", "license": "Apache-2.0", "dependencies": { "tsx": "^4.23.11" diff --git a/package.json b/package.json index 37e9453..846bac5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@askrjs/orm", - "version": "0.0.0", + "version": "0.0.1", "description": "Postgres-first, SQL-shaped micro-ORM for Askr", "keywords": [ "askr", diff --git a/src/adapter.ts b/src/adapter.ts index 33befd1..74eb758 100644 --- a/src/adapter.ts +++ b/src/adapter.ts @@ -1,16 +1,19 @@ import type { SqlQuery } from "./sql"; +/** Per-query execution options accepted by {@link DatabaseAdapter.execute} and `stream`. */ export interface QueryOptions { readonly signal?: AbortSignal; readonly timeoutMs?: number; readonly preparedName?: string; } +/** Result of executing a query: the returned rows plus the affected/returned row count. */ export interface ExecutionResult> { readonly rows: readonly Row[]; readonly rowCount: number; } +/** Low-level connection contract that dialect drivers implement and query/client code runs against. */ export interface DatabaseAdapter { readonly identity?: string; execute>( @@ -30,6 +33,7 @@ export interface DatabaseAdapter { close?(): Promise; } +/** SQL dialect targeted by a database connection or driver. */ export type DialectName = "postgres" | "sqlite"; /** Internal contract implemented by dialect entrypoints. */ @@ -41,12 +45,14 @@ export interface DatabaseDriver { shadow(): Promise; } +/** Options controlling isolation level, read-only mode, and cancellation of a transaction. */ export interface TransactionOptions { readonly isolation?: "read committed" | "repeatable read" | "serializable"; readonly readOnly?: boolean; readonly signal?: AbortSignal; } +/** Details reported for a single executed operation when telemetry is enabled. */ export interface TelemetryEvent { readonly operation: string; readonly durationMs: number; @@ -55,11 +61,13 @@ export interface TelemetryEvent { readonly sql?: string; } +/** Telemetry configuration passed to {@link DatabaseOpenOptions}. */ export interface TelemetryOptions { readonly includeSql?: boolean; readonly onEvent: (event: TelemetryEvent) => void; } +/** Options accepted when opening a database connection. */ export interface DatabaseOpenOptions { readonly telemetry?: TelemetryOptions; } diff --git a/src/client.ts b/src/client.ts index 48e4c29..a821e99 100644 --- a/src/client.ts +++ b/src/client.ts @@ -14,18 +14,22 @@ import type { SqlQuery } from "./sql"; import { createMigrationsApi, type MigrationManifest, type MigrationsApi } from "./migrations"; import type { RegisteredQuery } from "./registered-query"; +/** Outcome of a write operation that did not request rows back. */ export interface WriteResult { readonly rowsAffected: number; } +/** Option marker requesting that a write operation return the single affected row. */ export interface ReturningRow { readonly returning: "row"; } +/** Option marker requesting that a write operation return all affected rows. */ export interface ReturningRows { readonly returning: "rows"; } +/** Option marker requesting only a {@link WriteResult} status, without returned rows (the default). */ export interface ReturningStatus { readonly returning?: "status"; } @@ -129,6 +133,11 @@ async function execute( return result; } +/** + * Typed CRUD and query surface for a single table, backed by a {@link DatabaseAdapter}. + * Instances are created internally by {@link createDatabaseClient}; the select-query + * methods delegate to a fresh {@link SelectQuery} for that table. + */ export class TableClient { readonly definition: T; @@ -177,14 +186,17 @@ export class TableClient { stream: SelectQuery, References>["stream"] = (options) => this.query().stream(options); + /** Compiles the default `SELECT * FROM ` query to SQL without executing it. */ toSQL(): SqlQuery { return this.query().toSQL(); } + /** Fetches every row in the table. */ all(options?: QueryOptions): Promise[]> { return this.query().execute(options); } + /** Fetches a single row by primary key, or `null` if no row matches. */ async get(key: PrimaryKeyInput, options: QueryOptions = {}): Promise | null> { const where = whereKey(this.definition, key, 1); const query = { @@ -202,6 +214,7 @@ export class TableClient { return row ? decodeRow(this.definition, row) : null; } + /** Inserts a single row. Pass `{ returning: "row" }` to get the inserted row back. */ async insert( input: InferInsert, options?: ReturningStatus & QueryOptions, @@ -248,6 +261,10 @@ export class TableClient { return decodeRow(this.definition, row); } + /** + * Inserts many rows in chunks (default 1000 per statement, via `options.chunkSize`). + * Pass `{ returning: "rows" }` to get all inserted rows back. + */ async insertMany( inputs: readonly InferInsert[], options: (ReturningStatus | ReturningRows) & @@ -308,6 +325,7 @@ export class TableClient { return options.returning === "rows" ? rows : { rowsAffected }; } + /** Updates the row matching the primary key with the given patch. */ async update( key: PrimaryKeyInput, patch: InferPatch, @@ -360,6 +378,7 @@ export class TableClient { return row ? decodeRow(this.definition, row) : null; } + /** Deletes the row matching the primary key. */ async delete(key: PrimaryKeyInput, options: QueryOptions = {}): Promise { const where = whereKey(this.definition, key, 1); const result = await execute( @@ -375,6 +394,11 @@ export class TableClient { return { rowsAffected: result.rowCount }; } + /** + * Inserts many rows, updating non-primary-key columns on conflict (`ON CONFLICT ... DO UPDATE`), + * in chunks (default 1000 per statement, via `options.chunkSize`). Requires the table to have a + * primary key. + */ async upsertMany( inputs: readonly InferInsert[], options: QueryOptions & { @@ -450,6 +474,7 @@ export class TableClient { return options.returning === "rows" ? returned : { rowsAffected }; } + /** Inserts a single row, updating non-primary-key columns on conflict. See {@link upsertMany}. */ async upsert( input: InferInsert, options: QueryOptions & { readonly returning?: "status" | "row" } = {}, @@ -472,6 +497,7 @@ export class TableClient { } } +/** Maps each table in a schema record to its corresponding {@link TableClient}. */ export type DatabaseTables> = { readonly [K in keyof T]: TableClient; }; @@ -482,6 +508,10 @@ type QueryFunctions, Q extends Record>> = Record, @@ -495,6 +525,16 @@ export type DatabaseClient< close(): Promise; }; +/** + * Builds a {@link DatabaseClient} exposing one {@link TableClient} per table, the given + * registered queries as callable functions, a migrations API, and transaction support. + * + * @param tables Table definitions, keyed by the name used on the resulting client. + * @param adapter Low-level connection to run queries against. + * @param manifest Bundled migrations exposed through `client.migrations`. + * @param options Open options (e.g. telemetry). + * @param registeredQueries Precompiled queries (see {@link defineQuery}) exposed as `client.queries`. + */ export function createDatabaseClient< T extends Record, Q extends Record>> = Record, diff --git a/src/definition.ts b/src/definition.ts index e9b4bb6..a512d30 100644 --- a/src/definition.ts +++ b/src/definition.ts @@ -4,6 +4,7 @@ import type { MigrationManifest } from "./migrations"; import type { AnyTable, EnumDefinition, ViewDefinition } from "./schema"; import type { RegisteredQuery } from "./registered-query"; +/** Administrative connection used by tooling (migrations, schema diffing) against a database. */ export interface DatabaseToolingAdapter { readonly identity: string; reset(): Promise; @@ -23,6 +24,7 @@ export interface DatabaseToolingAdapter { close?(): Promise; } +/** Result of {@link defineDatabase}: the tables, dialect, and everything needed to open a client. */ export interface DatabaseDefinition< T extends Record, Q extends Record>> = Record, @@ -41,12 +43,14 @@ export interface DatabaseDefinition< open(target?: "target", options?: DatabaseOpenOptions): Promise>; } +/** Codegen output (schema identity, bundled migrations, precompiled queries) passed to {@link defineDatabase}. */ export interface GeneratedDatabaseArtifact { readonly schemaIdentity?: string; readonly manifest?: MigrationManifest; readonly queries?: Readonly>; } +/** Options accepted by {@link defineDatabase}. */ export interface CleanDatabaseOptions< T extends Record, Q extends Record>> = Record, @@ -59,6 +63,12 @@ export interface CleanDatabaseOptions< readonly views?: readonly ViewDefinition[]; } +/** + * Declares a database's tables, enums, views, and driver, validating that every column is + * compatible with the driver's dialect. + * + * @throws If a SQLite driver is given enums, or a table column requires a different dialect. + */ export function defineDatabase< const T extends Record, const Q extends Record>> = Record, diff --git a/src/errors.ts b/src/errors.ts index 527efe3..b4629b4 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -1,3 +1,4 @@ +/** Coarse classification assigned to a normalized {@link DatabaseError}. */ export type DatabaseErrorCategory = | "constraint" | "serialization" @@ -7,6 +8,7 @@ export type DatabaseErrorCategory = | "connection" | "unknown"; +/** Uniform error thrown for database failures, produced by {@link normalizeDatabaseError}. */ export class DatabaseError extends Error { readonly category: DatabaseErrorCategory; readonly code?: string; @@ -49,6 +51,11 @@ interface DriverErrorLike { const CONSTRAINT_CODES = new Set(["23000", "23502", "23503", "23505", "23514", "23P01"]); const CONNECTION_PREFIXES = ["08", "53", "57P0"]; +/** + * Converts an unknown driver-thrown error (Postgres or SQLite) into a {@link DatabaseError}, + * classifying it by inspecting driver-specific error codes/messages. Passes through values + * that are already a {@link DatabaseError} unchanged. + */ export function normalizeDatabaseError(error: unknown): DatabaseError { if (error instanceof DatabaseError) return error; const value = (error && typeof error === "object" ? error : {}) as DriverErrorLike; diff --git a/src/migrations.ts b/src/migrations.ts index a7eaa1b..8a1d12c 100644 --- a/src/migrations.ts +++ b/src/migrations.ts @@ -1,6 +1,7 @@ import type { DatabaseAdapter, QueryOptions } from "./adapter"; import { normalizeDatabaseError } from "./errors"; +/** A single migration bundled into a {@link MigrationManifest} by codegen. */ export interface BundledMigration { readonly id: string; readonly parent: string | null; @@ -10,10 +11,12 @@ export interface BundledMigration { readonly risk?: "safe" | "review" | "destructive"; } +/** Ordered chain of migrations bundled with a database definition. */ export interface MigrationManifest { readonly migrations: readonly BundledMigration[]; } +/** A migration's recorded state in the database's migration ledger. */ export interface AppliedMigration { readonly id: string; readonly parent: string | null; @@ -24,32 +27,41 @@ export interface AppliedMigration { readonly durationMs: number | null; } +/** A bundled migration not yet applied, as returned in a {@link MigrationPlan}. */ export interface MigrationPlanEntry extends BundledMigration { readonly status: "pending"; } +/** Comparison of the bundled manifest against the database's applied migration history. */ export interface MigrationPlan { readonly applied: readonly AppliedMigration[]; readonly pending: readonly MigrationPlanEntry[]; } +/** Progress notification emitted during {@link MigrationsApi.apply}. */ export interface MigrationEvent { readonly type: "lock-acquired" | "started" | "applied" | "failed" | "complete"; readonly migration?: string; readonly durationMs?: number; } +/** Options for {@link MigrationsApi.apply}. */ export interface MigrationApplyOptions extends QueryOptions { readonly onEvent?: (event: MigrationEvent) => void; } +/** Result of {@link MigrationsApi.apply}: the ids of migrations applied during that call. */ export interface MigrationApplyResult { readonly applied: readonly string[]; } +/** Migration operations exposed on `client.migrations`. */ export interface MigrationsApi { + /** Compares bundled migrations against applied history without changing the database. */ plan(options?: QueryOptions): Promise; + /** Acquires the migration lock and applies all pending migrations in order. */ apply(options?: MigrationApplyOptions): Promise; + /** Marks a failed or stuck-applying migration as applied or rolled back. */ resolve(id: string, resolution: "applied" | "rolled-back", options?: QueryOptions): Promise; } diff --git a/src/naming.ts b/src/naming.ts index c663240..e49e03c 100644 --- a/src/naming.ts +++ b/src/naming.ts @@ -100,6 +100,7 @@ const RESERVED = new Set([ "with", ]); +/** Converts camelCase/kebab-case/space-separated text to snake_case, e.g. for default column names. */ export function toSnakeCase(value: string): string { return value .replace(/([a-z0-9])([A-Z])/g, "$1_$2") @@ -108,11 +109,22 @@ export function toSnakeCase(value: string): string { .toLowerCase(); } +/** + * Wraps a SQL identifier in double quotes, escaping any embedded quotes. + * + * @throws If `value` is empty or contains a NUL byte. + */ export function quoteIdentifier(value: string): string { if (!value || value.includes("\0")) throw new Error("SQL identifiers must be non-empty."); return `"${value.replaceAll('"', '""')}"`; } +/** + * Validates that an identifier is safe to embed unquoted in SQL: alphanumeric/underscore, + * not starting with a digit, and not a reserved word. + * + * @throws If the identifier fails validation. + */ export function assertSafeIdentifier(value: string): void { if (!/^[a-z_][a-z0-9_]*$/i.test(value) || RESERVED.has(value.toLowerCase())) { throw new Error(`Unsafe or reserved unquoted SQL identifier: ${value}`); diff --git a/src/postgres.ts b/src/postgres.ts index a8e918e..17ba64e 100644 --- a/src/postgres.ts +++ b/src/postgres.ts @@ -12,6 +12,7 @@ export { jsonb, postgresEnum, postgresType, timestampTz, bytea } from "./schema" const MIGRATION_LOCK_KEY = "4707438161740729"; +/** Options accepted by {@link postgres}. */ export interface PostgresOptions { readonly url?: string | (() => string); readonly shadowUrl?: string | (() => string); @@ -210,6 +211,12 @@ async function pgTooling( }; } +/** + * Creates a PostgreSQL {@link DatabaseDriver} backed by `pg`, connecting to `options.url` + * (defaulting to `DATABASE_URL`). Requires the optional peers `pg` and `pg-query-stream`; the + * shadow database (for migration tooling) uses `options.shadowUrl`/`DATABASE_SHADOW_URL` and + * must differ from the target. + */ export function postgres(options: PostgresOptions = {}): DatabaseDriver { return { dialect: "postgres", diff --git a/src/query.ts b/src/query.ts index 3a9b976..d6cae44 100644 --- a/src/query.ts +++ b/src/query.ts @@ -12,11 +12,14 @@ import { tableRefs, } from "./sql"; +/** A `select()` projection: a map from output column name to a SQL expression. */ export type Selection = Readonly>; +/** Row shape produced by executing a query with the given {@link Selection}. */ export type SelectionResult = Readonly<{ [K in keyof S]: S[K] extends Expression ? T : never; }>; +/** Column references for a single table alias, keyed by alias then column property name. */ export type References = Readonly< Record< Alias, @@ -28,6 +31,7 @@ export type References = Readonly< type AnyReferences = Readonly>>>; +/** A table (or view) that can be passed to `join`/`leftJoin`/`rightJoin`/`fullJoin`. */ export interface JoinTarget { readonly definition: T; } @@ -166,12 +170,18 @@ function mapRows( ) as unknown as readonly Row[]; } +/** + * Immutable, chainable builder for a `SELECT` query against a table. Each method returns a new + * `SelectQuery`; call {@link execute}, {@link first}, {@link stream}, or {@link toSQL} to run or + * compile it. Created via {@link tableQuery} or {@link TableClient}. + */ export class SelectQuery { constructor( private readonly adapter: DatabaseAdapter, private readonly state: QueryState, ) {} + /** Adds a `WHERE` predicate, ANDed with any existing predicates. */ where( predicate: SqlFragment | ((refs: Refs) => SqlFragment), ): SelectQuery { @@ -183,6 +193,7 @@ export class SelectQuery { }); } + /** Adds `GROUP BY` expressions. */ groupBy( ...groups: readonly (Expression | ((refs: Refs) => Expression))[] ): SelectQuery { @@ -197,6 +208,7 @@ export class SelectQuery { }); } + /** Sets the `HAVING` predicate, replacing any previous one. */ having( predicate: SqlFragment | ((refs: Refs) => SqlFragment), ): SelectQuery { @@ -207,6 +219,7 @@ export class SelectQuery { }); } + /** Adds an `ORDER BY` expression, appended after any existing ordering. */ orderBy( expression: Expression | ((refs: Refs) => Expression), direction: "asc" | "desc" = "asc", @@ -226,18 +239,22 @@ export class SelectQuery { }); } + /** Sets `LIMIT`. */ limit(value: number): SelectQuery { return new SelectQuery(this.adapter, { ...this.state, limit: value }); } + /** Sets `OFFSET`. */ offset(value: number): SelectQuery { return new SelectQuery(this.adapter, { ...this.state, offset: value }); } + /** Adds `DISTINCT` to the selection. */ distinct(): SelectQuery { return new SelectQuery(this.adapter, { ...this.state, distinct: true }); } + /** Adds a `WITH AS (...)` common table expression, referencing another query's SQL. */ with(name: string, query: { toSQL(): SqlQuery }): SelectQuery { return new SelectQuery(this.adapter, { ...this.state, @@ -245,6 +262,7 @@ export class SelectQuery { }); } + /** Sets the projected columns/expressions, replacing the default `SELECT *`. */ select( projection: S | ((refs: Refs) => S), ): SelectQuery, Refs> { @@ -255,6 +273,7 @@ export class SelectQuery { }); } + /** Starts an `INNER JOIN`; call `.on(...)` on the result to complete it. */ join( target: JoinTarget, options: { readonly as?: A } = {}, @@ -262,6 +281,7 @@ export class SelectQuery { return new PendingJoin(this.adapter, this.state, target.definition, options.as, "INNER"); } + /** Starts a `LEFT JOIN`; call `.on(...)` on the result to complete it. */ leftJoin( target: JoinTarget, options: { readonly as?: A } = {}, @@ -269,6 +289,7 @@ export class SelectQuery { return new PendingJoin(this.adapter, this.state, target.definition, options.as, "LEFT"); } + /** Starts a `RIGHT JOIN`; call `.on(...)` on the result to complete it. */ rightJoin( target: JoinTarget, options: { readonly as?: A } = {}, @@ -276,6 +297,7 @@ export class SelectQuery { return new PendingJoin(this.adapter, this.state, target.definition, options.as, "RIGHT"); } + /** Starts a `FULL JOIN`; call `.on(...)` on the result to complete it. */ fullJoin( target: JoinTarget, options: { readonly as?: A } = {}, @@ -283,10 +305,12 @@ export class SelectQuery { return new PendingJoin(this.adapter, this.state, target.definition, options.as, "FULL"); } + /** Compiles the query to SQL text and parameter values without executing it. */ toSQL(): SqlQuery { return compileState(this.state).query; } + /** Compiles and runs the query, returning all matching rows. */ async execute(options: QueryOptions = {}): Promise { const compiled = compileState(this.state); try { @@ -297,10 +321,12 @@ export class SelectQuery { } } + /** Runs the query with `LIMIT 1` and returns the first row, or `null` if none match. */ async first(options: QueryOptions = {}): Promise { return (await this.limit(1).execute(options))[0] ?? null; } + /** Compiles the query once and returns a reusable {@link PreparedQuery} using a named prepared statement. */ prepare(name: string): PreparedQuery { const compiled = compileState(this.state); return { @@ -320,6 +346,7 @@ export class SelectQuery { }; } + /** Compiles and runs the query, yielding rows as they arrive. Requires adapter streaming support. */ stream(options: QueryOptions = {}): AsyncIterable { if (!this.adapter.stream) { throw new Error("This database adapter does not support streaming."); @@ -370,6 +397,7 @@ type ExistingJoinRefs = Nu ? NullableReferences : Refs; +/** A join with a target table chosen but no `ON` condition yet; returned by `SelectQuery`/`JoinedQuery` join methods. */ export class PendingJoin< _Row, Refs extends AnyReferences, @@ -393,6 +421,7 @@ export class PendingJoin< } } + /** Completes the join with an `ON` predicate, returning a {@link JoinedQuery}. */ on( predicate: ( refs: JoinedRefs, T, Alias, Nullable>, @@ -414,12 +443,14 @@ export class PendingJoin< } } +/** A query with one or more completed joins; supports further joins or a final `select()`. */ export class JoinedQuery { constructor( private readonly adapter: DatabaseAdapter, private readonly state: QueryState, ) {} + /** Sets the projected columns/expressions across the joined tables. */ select( projection: S | ((refs: Refs) => S), ): SelectQuery, Refs> { @@ -430,6 +461,7 @@ export class JoinedQuery { }); } + /** Starts an additional `INNER JOIN`; call `.on(...)` on the result to complete it. */ join( target: JoinTarget, options: { readonly as?: A } = {}, @@ -437,6 +469,7 @@ export class JoinedQuery { return new PendingJoin(this.adapter, this.state, target.definition, options.as, "INNER"); } + /** Starts an additional `LEFT JOIN`; call `.on(...)` on the result to complete it. */ leftJoin( target: JoinTarget, options: { readonly as?: A } = {}, @@ -444,6 +477,7 @@ export class JoinedQuery { return new PendingJoin(this.adapter, this.state, target.definition, options.as, "LEFT"); } + /** Starts an additional `RIGHT JOIN`; call `.on(...)` on the result to complete it. */ rightJoin( target: JoinTarget, options: { readonly as?: A } = {}, @@ -451,6 +485,7 @@ export class JoinedQuery { return new PendingJoin(this.adapter, this.state, target.definition, options.as, "RIGHT"); } + /** Starts an additional `FULL JOIN`; call `.on(...)` on the result to complete it. */ fullJoin( target: JoinTarget, options: { readonly as?: A } = {}, @@ -459,12 +494,14 @@ export class JoinedQuery { } } +/** A query compiled once for repeated execution, using a named prepared statement server-side. */ export interface PreparedQuery { readonly name: string; toSQL(): SqlQuery; execute(options?: QueryOptions): Promise; } +/** Builds the default `SelectQuery` (`SELECT * FROM
`) that {@link TableClient} query methods delegate to. */ export function tableQuery( adapter: DatabaseAdapter, definition: T, diff --git a/src/registered-query.ts b/src/registered-query.ts index a963263..89300ac 100644 --- a/src/registered-query.ts +++ b/src/registered-query.ts @@ -1,6 +1,7 @@ import type { QueryOptions } from "./adapter"; import type { SqlQuery } from "./sql"; +/** A precompiled, named SQL query template produced by {@link defineQuery}. */ export interface RegisteredQuery< Params extends Record, Row = Record, @@ -14,11 +15,19 @@ export interface RegisteredQuery< readonly _row?: Row; } +/** Shape of the callable exposed on `client.queries[name]` for a {@link RegisteredQuery}. */ export type RegisteredQueryFunction

, Row> = ( params: P, options?: QueryOptions, ) => Promise; +/** + * Creates a tagged-template builder for a named, parameterized SQL query. The returned function + * is used as a template tag, e.g. `defineQuery("byId")\`SELECT * FROM t WHERE id = ${"id"}\``, + * where interpolated values must be parameter names from `Params`. + * + * @throws If `key` is empty, or a template substitution is not a parameter name. + */ export function defineQuery>(key: string) { if (!key.trim()) throw new Error("Registered query keys cannot be empty."); return (strings: TemplateStringsArray, ...parameters: readonly (keyof Params & string)[]) => { diff --git a/src/schema.ts b/src/schema.ts index 0b76900..fe63d5c 100644 --- a/src/schema.ts +++ b/src/schema.ts @@ -1,5 +1,6 @@ import { toSnakeCase } from "./naming"; +/** Bidirectional converter between a column's stored (database) and application-facing value. */ export interface Codec { readonly name: string; encode(value: Application): Database; @@ -7,12 +8,14 @@ export interface Codec { readonly typeScriptType?: string; } +/** Target of a column's `references()` foreign key. */ export interface ColumnReference { readonly schema?: string; readonly table: string; readonly column: string; } +/** Serializable description of a column's shape, produced by {@link ColumnBuilder}. */ export interface ColumnAst { readonly property: string; readonly name: string; @@ -34,6 +37,11 @@ declare const columnNotNull: unique symbol; declare const columnHasDefault: unique symbol; declare const columnPrimary: unique symbol; +/** + * Immutable, chainable builder for a table column's definition. Each method returns a new + * builder reflecting the change; built via the type-specific factories (e.g. {@link text}, + * {@link integer}, {@link uuid}) exported from this module. + */ export class ColumnBuilder< T, NotNull extends boolean = false, @@ -58,30 +66,37 @@ export class ColumnBuilder< return new ColumnBuilder({ ...this.ast, ...patch }); } + /** Overrides the underlying SQL column name (defaults to the property's snake_case form). */ name(name: string): ColumnBuilder { return this.copy({ name }); } + /** Marks the column `NOT NULL`. */ notNull(): ColumnBuilder { return this.copy({ nullable: false }); } + /** Marks the column as (part of) the table's primary key; implies `notNull()`. */ primaryKey(): ColumnBuilder { return this.copy({ primaryKey: true, nullable: false }); } + /** Adds a single-column `UNIQUE` constraint. */ unique(): ColumnBuilder { return this.copy({ unique: true }); } + /** Sets a raw SQL default expression for the column. */ default(expression: string): ColumnBuilder { return this.copy({ default: expression }); } + /** Sets the default to `CURRENT_TIMESTAMP`. */ defaultNow(): ColumnBuilder { return this.default("CURRENT_TIMESTAMP"); } + /** Sets the default to `gen_random_uuid()`. PostgreSQL only. */ defaultRandom(): ColumnBuilder { return new ColumnBuilder({ ...this.ast, @@ -90,14 +105,17 @@ export class ColumnBuilder< }); } + /** Marks the column as a generated column with the given SQL expression. */ generatedAlwaysAs(expression: string): ColumnBuilder { return this.copy({ generated: expression }); } + /** Declares a foreign key to another table's column, given as a thunk to avoid circular references. */ references(target: () => AnyColumn): ColumnBuilder { return this.copy({ references: target }); } + /** Applies a {@link Codec} to convert between the stored value and an application-facing type. */ mapWith( codec: Codec, ): ColumnBuilder { @@ -107,16 +125,20 @@ export class ColumnBuilder< }); } + /** Records the column's previous SQL name, so migration codegen can generate a rename instead of a drop/add. */ renamedFrom(name: string): ColumnBuilder { return this.copy({ renamedFrom: name }); } + /** Sets a `USING` expression for converting existing data when the column's type changes. */ convertUsing(expression: string): ColumnBuilder { return this.copy({ convertUsing: expression }); } } +/** A {@link ColumnBuilder} of any value/nullability/default/primary-key combination. */ export type AnyColumn = ColumnBuilder; +/** The application-facing value type of a column, `| null` unless it is `notNull()`. */ export type ColumnValue = C extends ColumnBuilder ? N extends true @@ -131,18 +153,21 @@ type OptionalInsertKeys> = Exclude< RequiredInsertKeys >; +/** Table-level `CHECK (expression)` constraint, built via {@link check}. */ export interface CheckConstraint { readonly kind: "check"; readonly name?: string; readonly expression: string; } +/** Table-level `UNIQUE (columns...)` constraint, built via {@link unique}. */ export interface UniqueConstraint { readonly kind: "unique"; readonly name?: string; readonly columns: readonly string[]; } +/** Table-level index definition, built via {@link index}. */ export interface IndexDefinition { readonly kind: "index"; readonly name?: string; @@ -152,14 +177,17 @@ export interface IndexDefinition { readonly method?: string; } +/** A single table-level constraint passed via {@link TableOptions.constraints}. */ export type TableConstraint = CheckConstraint | UniqueConstraint | IndexDefinition; +/** Options accepted by {@link table}. */ export interface TableOptions { readonly schema?: string; readonly renamedFrom?: string; readonly constraints?: readonly TableConstraint[]; } +/** Result of {@link table}: the columns plus table metadata (`$name`, `$schema`, etc). */ export type TableDefinition, Name extends string = string> = { readonly [K in keyof C]: C[K]; } & { @@ -170,6 +198,7 @@ export type TableDefinition, Name extends st readonly $options: TableOptions; }; +/** A {@link TableDefinition} of any columns/name, used for generic table-accepting APIs. */ export interface AnyTable { readonly $kind: "table"; readonly $name: string; @@ -177,9 +206,11 @@ export interface AnyTable { readonly $columns: Record; readonly $options: TableOptions; } +/** Row shape returned by reads against a table. */ export type InferRow = Readonly<{ [K in keyof T["$columns"]]: ColumnValue; }>; +/** Input shape accepted by inserts: columns without a default are required, others optional. */ export type InferInsert = Readonly< { [K in RequiredInsertKeys]: ColumnValue; @@ -187,6 +218,7 @@ export type InferInsert = Readonly< [K in OptionalInsertKeys]?: Exclude, null> | null; } >; +/** Input shape accepted by updates: all non-primary-key columns, all optional. */ export type InferPatch = Readonly< Partial<{ [K in keyof T["$columns"] as T["$columns"][K] extends ColumnBuilder< @@ -199,6 +231,7 @@ export type InferPatch = Readonly< : K]: ColumnValue; }> >; +/** Primary key shape for a table: just its primary-key column(s). */ export type InferKey = Readonly<{ [K in keyof T["$columns"] as T["$columns"][K] extends ColumnBuilder< unknown, @@ -220,35 +253,52 @@ function column(dataType: string): ColumnBuilder { }); } +/** Defines a `uuid` column. */ export const uuid = (): ColumnBuilder => column("uuid"); +/** Defines a `text` column. */ export const text = (): ColumnBuilder => column("text"); +/** Defines a `boolean` column. */ export const boolean = (): ColumnBuilder => column("boolean"); +/** Defines an `integer` column. */ export const integer = (): ColumnBuilder => column("integer"); +/** Defines a `bigint` column. */ export const bigInt = (): ColumnBuilder => column("bigint"); +/** Defines a `real` (single-precision float) column. */ export const real = (): ColumnBuilder => column("real"); +/** Defines a `double precision` column. */ export const doublePrecision = (): ColumnBuilder => column("double precision"); +/** Defines a `numeric` column, optionally with precision and scale. */ export const numeric = (precision?: number, scale?: number): ColumnBuilder => column( precision === undefined ? "numeric" : `numeric(${precision}${scale === undefined ? "" : `,${scale}`})`, ); +/** Defines a `json` column. */ export const json = (): ColumnBuilder => column("json"); +/** Defines a `jsonb` column. PostgreSQL only. */ export const jsonb = (): ColumnBuilder => new ColumnBuilder({ ...column("jsonb").ast, dialect: "postgres" }); +/** Defines a `date` column. */ export const date = (): ColumnBuilder => column("date"); +/** Defines a `timestamp without time zone` column. */ export const timestamp = (): ColumnBuilder => column("timestamp without time zone"); +/** Defines a `timestamp with time zone` column. PostgreSQL only. */ export const timestampTz = (): ColumnBuilder => new ColumnBuilder({ ...column("timestamp with time zone").ast, dialect: "postgres", }); +/** Defines a raw binary column. */ export const bytes = (): ColumnBuilder => column("bytes"); +/** Defines a `bytea` column. PostgreSQL only. */ export const bytea = (): ColumnBuilder => new ColumnBuilder({ ...column("bytea").ast, dialect: "postgres" }); +/** Defines a column with an arbitrary PostgreSQL-only type name. */ export const postgresType = (name: string): ColumnBuilder => new ColumnBuilder({ ...column(name).ast, dialect: "postgres" }); +/** Result of {@link postgresEnum}: a PostgreSQL enum type usable as a column via `.column()`. */ export interface EnumDefinition { readonly kind: "enum"; readonly name: string; @@ -257,6 +307,7 @@ export interface EnumDefinition { column(): ColumnBuilder; } +/** Declares a PostgreSQL enum type with the given values, for use as a column type. */ export function postgresEnum( name: string, values: V, @@ -272,6 +323,10 @@ export function postgresEnum( }; } +/** + * Declares a table from its columns, defaulting each column's SQL name to the snake_case form + * of its property name. + */ export function table>( name: Name, columns: C, @@ -295,18 +350,21 @@ export function table ({ kind: "check", expression, ...(name === undefined ? {} : { name }), }); +/** Builds a table-level `UNIQUE` constraint over one or more columns. */ export const unique = (columns: readonly string[], name?: string): UniqueConstraint => ({ kind: "unique", columns, ...(name === undefined ? {} : { name }), }); +/** Builds an index definition over one or more expressions. */ export const index = ( expressions: readonly string[], options: Omit & { @@ -321,6 +379,7 @@ export const index = ( ...(options.method === undefined ? {} : { method: options.method }), }); +/** Result of {@link view}: a named SQL `SELECT` exposed as a view. */ export interface ViewDefinition { readonly kind: "view"; readonly name: string; @@ -328,6 +387,7 @@ export interface ViewDefinition { readonly query: string; } +/** Declares a database view backed by a raw SQL query. */ export function view( name: string, query: string, diff --git a/src/sql.ts b/src/sql.ts index 3daf20c..d479b60 100644 --- a/src/sql.ts +++ b/src/sql.ts @@ -5,11 +5,13 @@ import type { AnyTable } from "./schema"; const SQL_FRAGMENT = Symbol("askr.sql.fragment"); +/** Compiled, ready-to-execute SQL: parameterized text plus the ordered bind values. */ export interface SqlQuery { readonly text: string; readonly values: readonly unknown[]; } +/** An uncompiled piece of SQL built with {@link sql}, compiled via {@link compileSql}. */ export interface SqlFragment { readonly [SQL_FRAGMENT]: true; readonly chunks: readonly SqlChunk[]; @@ -22,6 +24,7 @@ type SqlChunk = | { readonly kind: "identifier"; readonly value: string } | { readonly kind: "fragment"; readonly value: SqlFragment }; +/** A named SQL template with `:named` parameters, built with `sql.key(...)`. See {@link compileKeyedSql}. */ export interface KeyedSql, TResult> { readonly kind: "keyed-sql"; readonly key: string; @@ -30,6 +33,7 @@ export interface KeyedSql, TResult> readonly result?: TResult; } +/** Raw SQL text inserted verbatim (not as a bound parameter) by {@link unsafeSql}. */ export interface UnsafeSql { readonly kind: "unsafe-sql"; readonly text: string; @@ -48,10 +52,12 @@ function isFragment(value: unknown): value is SqlFragment { ); } +/** Embeds `name` as a quoted SQL identifier (not a bound parameter). Also available as `sql.identifier`. */ export function identifier(name: string): SqlFragment { return fragment([{ kind: "identifier", value: name }]); } +/** Embeds a value as a SQL literal (not a bound parameter). Also available as `sql.literal`. */ export function literal(value: string | number | boolean | null): SqlFragment { if (typeof value === "string") { return fragment([{ kind: "text", value: `'${value.replaceAll("'", "''")}'` }]); @@ -60,6 +66,7 @@ export function literal(value: string | number | boolean | null): SqlFragment { return fragment([{ kind: "text", value: String(value) }]); } +/** Wraps raw SQL text to be inserted verbatim into a query. Also available as `sql.unsafe`. */ export function unsafeSql(text: string): UnsafeSql { return { kind: "unsafe-sql", text }; } @@ -116,6 +123,11 @@ function keyedSql, TResult = unknown }; } +/** + * Tagged template for building a {@link SqlFragment}: interpolated fragments splice in, other + * values become bound parameters. Also exposes `sql.identifier`, `sql.literal`, `sql.unsafe`, + * and `sql.key` for keyed/named-parameter queries. + */ export const sql: SqlTag = Object.assign(sqlTag, { identifier, literal, @@ -123,6 +135,7 @@ export const sql: SqlTag = Object.assign(sqlTag, { key: keyedSql, }); +/** Compiles a {@link SqlFragment} tree into parameterized SQL text and an ordered values array. */ export function compileSql(value: SqlFragment): SqlQuery { const values: unknown[] = []; let text = ""; @@ -140,6 +153,7 @@ export function compileSql(value: SqlFragment): SqlQuery { return { text, values }; } +/** A reference to `tableAlias.columnName`, as produced by {@link tableRefs} for query builders. */ export interface ColumnRef { readonly kind: "column-ref"; readonly tableAlias: string; @@ -147,8 +161,10 @@ export interface ColumnRef { readonly value?: T; } +/** Anything usable as a query expression: a {@link SqlFragment} or a {@link ColumnRef}. */ export type Expression = SqlFragment | ColumnRef; +/** Builds a {@link ColumnRef} to `tableAlias.columnName`. */ export function columnRef(tableAlias: string, columnName: string): ColumnRef { return { kind: "column-ref", tableAlias, columnName }; } @@ -174,39 +190,53 @@ function binary( return sql`${expressionSql(left)} ${sql.unsafe(operator)} ${expressionSql(right)}`; } +/** Builds an `=` comparison predicate. */ export const eq = (left: Expression, right: Expression | T): SqlFragment => binary(left, "=", right); +/** Builds a `<>` comparison predicate. */ export const ne = (left: Expression, right: Expression | T): SqlFragment => binary(left, "<>", right); +/** Builds a `>` comparison predicate. */ export const gt = (left: Expression, right: Expression | T): SqlFragment => binary(left, ">", right); +/** Builds a `>=` comparison predicate. */ export const gte = (left: Expression, right: Expression | T): SqlFragment => binary(left, ">=", right); +/** Builds a `<` comparison predicate. */ export const lt = (left: Expression, right: Expression | T): SqlFragment => binary(left, "<", right); +/** Builds a `<=` comparison predicate. */ export const lte = (left: Expression, right: Expression | T): SqlFragment => binary(left, "<=", right); +/** Builds a `LIKE` predicate. */ export const like = (left: Expression, pattern: string): SqlFragment => binary(left, "LIKE", pattern); +/** Builds an `ILIKE` predicate. PostgreSQL only. */ export const ilike = (left: Expression, pattern: string): SqlFragment => binary(left, "ILIKE", pattern); +/** Builds an `IS NULL` predicate. */ export const isNull = (value: Expression): SqlFragment => sql`${expressionSql(value)} IS NULL`; +/** Builds an `IS NOT NULL` predicate. */ export const isNotNull = (value: Expression): SqlFragment => sql`${expressionSql(value)} IS NOT NULL`; +/** Combines predicates with `AND`, parenthesized as a single expression. */ export function and(...predicates: readonly SqlFragment[]): SqlFragment { return joinFragments(predicates, " AND ", true) as SqlFragment; } +/** Combines predicates with `OR`, parenthesized as a single expression. */ export function or(...predicates: readonly SqlFragment[]): SqlFragment { return joinFragments(predicates, " OR ", true) as SqlFragment; } +/** Negates a predicate with `NOT (...)`. */ export function not(predicate: SqlFragment): SqlFragment { return sql`NOT (${predicate})`; } +/** Builds an `IN (...)` predicate; returns a `FALSE` predicate for an empty array. */ export function inArray(value: Expression, values: readonly T[]): SqlFragment { if (values.length === 0) return sql`FALSE`; return sql`${expressionSql(value)} IN (${joinFragments( @@ -215,6 +245,7 @@ export function inArray(value: Expression, values: readonly T[]): SqlFragm )})`; } +/** Joins fragments with `separator`, optionally wrapping the result in parentheses. */ export function joinFragments( fragments: readonly SqlFragment[], separator: string, @@ -229,12 +260,14 @@ export function joinFragments( return parentheses ? sql`(${joined})` : joined; } +/** A {@link ColumnRef} for every column of a table, keyed by property name. */ export type TableRefs = { readonly [K in keyof T["$columns"]]: ColumnRef< T["$columns"][K] extends { readonly value?: infer V } ? V : unknown >; }; +/** Builds {@link ColumnRef}s for every column of `table`, aliased to `alias` (default: the table name). */ export function tableRefs(table: T, alias = table.$name): TableRefs { return Object.fromEntries( Object.entries(table.$columns).map(([property, value]) => [ @@ -244,6 +277,12 @@ export function tableRefs(table: T, alias = table.$name): Ta ) as TableRefs; } +/** + * Compiles a {@link KeyedSql} template by substituting its `:named` parameters with `values`, + * producing positional `$n` placeholders. + * + * @throws If a `:name` in the SQL text is not declared, or a declared parameter has no value. + */ export function compileKeyedSql( query: KeyedSql, unknown>, values: Record, @@ -268,6 +307,7 @@ export function compileKeyedSql( return { text, values: ordered }; } +/** Compiles and executes a {@link KeyedSql} query, using its key as the prepared statement name. */ export async function executeKeyedSql, TResult>( adapter: DatabaseAdapter, query: KeyedSql, diff --git a/src/sqlite.ts b/src/sqlite.ts index 37b7e5b..63ffc41 100644 --- a/src/sqlite.ts +++ b/src/sqlite.ts @@ -12,6 +12,7 @@ import type { DatabaseToolingAdapter } from "./definition"; import type { SqlQuery } from "./sql"; import { rewritePlaceholders, sqliteSql } from "./placeholders"; +/** Options accepted by {@link sqlite}. */ export interface SqliteOptions { readonly filename?: string | (() => string); } @@ -249,6 +250,12 @@ function tooling(filename: string): DatabaseToolingAdapter { }; } +/** + * Creates a SQLite {@link DatabaseDriver} backed by `node:sqlite`. The filename defaults to + * `DATABASE_PATH`; its in-memory shadow database is used for migration tooling. + * + * @throws If no filename is configured and `DATABASE_PATH` is unset. + */ export function sqlite(options: SqliteOptions = {}): DatabaseDriver { const configured = options.filename ?? (() => process.env.DATABASE_PATH ?? ""); const filename = typeof configured === "function" ? configured() : configured; diff --git a/src/tooling.ts b/src/tooling.ts index f2d3790..6049293 100644 --- a/src/tooling.ts +++ b/src/tooling.ts @@ -17,17 +17,20 @@ import type { ViewDefinition, } from "./schema"; +/** Output sink used by {@link runDatabaseCli} for logging and errors. */ export interface DatabaseCliIo { log(message?: unknown): void; error(message?: unknown): void; } +/** Options accepted by {@link runDatabaseCli}. */ export interface RunDatabaseCliOptions { readonly cwd?: string; readonly io?: DatabaseCliIo; readonly confirm?: (message: string) => Promise; } +/** Serialized column shape stored in a {@link SchemaSnapshot}. */ export interface SnapshotColumn extends Omit { /** Accepted only when reading a legacy snapshot; live definitions cannot declare drops. */ readonly drop?: boolean; @@ -40,6 +43,7 @@ export interface SnapshotColumn extends Omit, @@ -294,6 +300,10 @@ function byName( return new Map(values.map((value) => [`${value.schema}.${value.name}`, value])); } +/** + * Generates a migration's SQL by diffing a `current` schema snapshot against a `desired` one + * (an empty `current` produces the full initial-schema SQL instead of a diff). + */ export function diffSnapshots( current: SchemaSnapshot, desiredInput: SchemaSnapshot, @@ -918,6 +928,13 @@ async function targetAdapter(database: LoadedDatabase): Promise return adapter; } +/** + * Runs the `askr-orm` database CLI (validate, generate, migration create/status/plan/apply/resolve) + * against the databases discovered under `options.cwd`, writing results via `options.io`. + * + * @param args CLI arguments, e.g. `["migration", "apply", "--yes"]`. + * @returns The process exit code: `0` on success, `1` on error. + */ export async function runDatabaseCli( args: readonly string[], options: RunDatabaseCliOptions = {}, @@ -1045,6 +1062,7 @@ export async function runDatabaseCli( } } +/** Checks whether a discoverable database entry file exists under `cwd`. */ export async function hasDatabaseEntry(cwd: string): Promise { return findDatabaseEntry(cwd).then( () => true, @@ -1052,6 +1070,7 @@ export async function hasDatabaseEntry(cwd: string): Promise { ); } +/** Runs `askr-orm validate` against the database(s) discovered under `cwd`, capturing its output. */ export async function validateDiscoveredDatabase(cwd: string): Promise<{ readonly status: "passed" | "failed"; readonly stdout: string;