Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
8 changes: 8 additions & 0 deletions src/adapter.ts
Original file line number Diff line number Diff line change
@@ -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<Row = Record<string, unknown>> {
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<Row = Record<string, unknown>>(
Expand All @@ -30,6 +33,7 @@ export interface DatabaseAdapter {
close?(): Promise<void>;
}

/** SQL dialect targeted by a database connection or driver. */
export type DialectName = "postgres" | "sqlite";

/** Internal contract implemented by dialect entrypoints. */
Expand All @@ -41,12 +45,14 @@ export interface DatabaseDriver {
shadow(): Promise<import("./definition").DatabaseToolingAdapter>;
}

/** 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;
Expand All @@ -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;
}
40 changes: 40 additions & 0 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
Expand Down Expand Up @@ -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<T extends AnyTable> {
readonly definition: T;

Expand Down Expand Up @@ -177,14 +186,17 @@ export class TableClient<T extends AnyTable> {
stream: SelectQuery<InferRow<T>, References<T, T["$name"]>>["stream"] = (options) =>
this.query().stream(options);

/** Compiles the default `SELECT * FROM <table>` query to SQL without executing it. */
toSQL(): SqlQuery {
return this.query().toSQL();
}

/** Fetches every row in the table. */
all(options?: QueryOptions): Promise<readonly InferRow<T>[]> {
return this.query().execute(options);
}

/** Fetches a single row by primary key, or `null` if no row matches. */
async get(key: PrimaryKeyInput<T>, options: QueryOptions = {}): Promise<InferRow<T> | null> {
const where = whereKey(this.definition, key, 1);
const query = {
Expand All @@ -202,6 +214,7 @@ export class TableClient<T extends AnyTable> {
return row ? decodeRow(this.definition, row) : null;
}

/** Inserts a single row. Pass `{ returning: "row" }` to get the inserted row back. */
async insert(
input: InferInsert<T>,
options?: ReturningStatus & QueryOptions,
Expand Down Expand Up @@ -248,6 +261,10 @@ export class TableClient<T extends AnyTable> {
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<T>[],
options: (ReturningStatus | ReturningRows) &
Expand Down Expand Up @@ -308,6 +325,7 @@ export class TableClient<T extends AnyTable> {
return options.returning === "rows" ? rows : { rowsAffected };
}

/** Updates the row matching the primary key with the given patch. */
async update(
key: PrimaryKeyInput<T>,
patch: InferPatch<T>,
Expand Down Expand Up @@ -360,6 +378,7 @@ export class TableClient<T extends AnyTable> {
return row ? decodeRow(this.definition, row) : null;
}

/** Deletes the row matching the primary key. */
async delete(key: PrimaryKeyInput<T>, options: QueryOptions = {}): Promise<WriteResult> {
const where = whereKey(this.definition, key, 1);
const result = await execute(
Expand All @@ -375,6 +394,11 @@ export class TableClient<T extends AnyTable> {
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<T>[],
options: QueryOptions & {
Expand Down Expand Up @@ -450,6 +474,7 @@ export class TableClient<T extends AnyTable> {
return options.returning === "rows" ? returned : { rowsAffected };
}

/** Inserts a single row, updating non-primary-key columns on conflict. See {@link upsertMany}. */
async upsert(
input: InferInsert<T>,
options: QueryOptions & { readonly returning?: "status" | "row" } = {},
Expand All @@ -472,6 +497,7 @@ export class TableClient<T extends AnyTable> {
}
}

/** Maps each table in a schema record to its corresponding {@link TableClient}. */
export type DatabaseTables<T extends Record<string, AnyTable>> = {
readonly [K in keyof T]: TableClient<T[K]>;
};
Expand All @@ -482,6 +508,10 @@ type QueryFunctions<Q extends Record<string, RegisteredQuery<Record<string, unkn
: never;
};

/**
* Full client returned by {@link createDatabaseClient}: a {@link TableClient} per table, callable
* registered queries, a migrations API, transaction support, and connection close.
*/
export type DatabaseClient<
T extends Record<string, AnyTable>,
Q extends Record<string, RegisteredQuery<Record<string, unknown>>> = Record<never, never>,
Expand All @@ -495,6 +525,16 @@ export type DatabaseClient<
close(): Promise<void>;
};

/**
* 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<string, AnyTable>,
Q extends Record<string, RegisteredQuery<Record<string, unknown>>> = Record<never, never>,
Expand Down
10 changes: 10 additions & 0 deletions src/definition.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
Expand All @@ -23,6 +24,7 @@ export interface DatabaseToolingAdapter {
close?(): Promise<void>;
}

/** Result of {@link defineDatabase}: the tables, dialect, and everything needed to open a client. */
export interface DatabaseDefinition<
T extends Record<string, AnyTable>,
Q extends Record<string, RegisteredQuery<Record<string, unknown>>> = Record<never, never>,
Expand All @@ -41,12 +43,14 @@ export interface DatabaseDefinition<
open(target?: "target", options?: DatabaseOpenOptions): Promise<DatabaseClient<T, Q>>;
}

/** Codegen output (schema identity, bundled migrations, precompiled queries) passed to {@link defineDatabase}. */
export interface GeneratedDatabaseArtifact {
readonly schemaIdentity?: string;
readonly manifest?: MigrationManifest;
readonly queries?: Readonly<Record<string, unknown>>;
}

/** Options accepted by {@link defineDatabase}. */
export interface CleanDatabaseOptions<
T extends Record<string, AnyTable>,
Q extends Record<string, RegisteredQuery<Record<string, unknown>>> = Record<never, never>,
Expand All @@ -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<string, AnyTable>,
const Q extends Record<string, RegisteredQuery<Record<string, unknown>>> = Record<never, never>,
Expand Down
7 changes: 7 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/** Coarse classification assigned to a normalized {@link DatabaseError}. */
export type DatabaseErrorCategory =
| "constraint"
| "serialization"
Expand All @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
12 changes: 12 additions & 0 deletions src/migrations.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -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<MigrationPlan>;
/** Acquires the migration lock and applies all pending migrations in order. */
apply(options?: MigrationApplyOptions): Promise<MigrationApplyResult>;
/** Marks a failed or stuck-applying migration as applied or rolled back. */
resolve(id: string, resolution: "applied" | "rolled-back", options?: QueryOptions): Promise<void>;
}

Expand Down
12 changes: 12 additions & 0 deletions src/naming.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -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}`);
Expand Down
Loading
Loading