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
12 changes: 6 additions & 6 deletions src/builder/delete.ts
Original file line number Diff line number Diff line change
Expand Up @@ -128,14 +128,14 @@ export class DeleteBuilder<Name extends string, T extends TableBase, R extends R
return [this.finalize()];
}

@expose(z.lazy(() => z.instanceof(Connection)))
override async execute(conn: Connection<any>): Promise<RowTypeToTsType<R>[]> {
return conn.execute(this);
@expose(z.lazy(() => z.instanceof(Connection)).optional())
override async execute(conn?: Connection<any>): Promise<RowTypeToTsType<R>[]> {
return (conn ?? this.table.database.defaultConnection).execute(this);
}
Comment on lines +131 to 134

@expose(z.lazy(() => z.instanceof(Connection)))
async hydrate(conn: Connection<any>): Promise<R[]> {
return conn.hydrate<any, any, R>(this);
@expose(z.lazy(() => z.instanceof(Connection)).optional())
async hydrate(conn?: Connection<any>): Promise<R[]> {
return (conn ?? this.table.database.defaultConnection).hydrate<any, any, R>(this);
}

@expose()
Expand Down
12 changes: 6 additions & 6 deletions src/builder/insert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -160,14 +160,14 @@ export class InsertBuilder<Name extends string, T extends TableBase, R extends R
return [this.finalize()];
}

@expose(z.lazy(() => z.instanceof(Connection)))
override async execute(conn: Connection<any>): Promise<RowTypeToTsType<R>[]> {
return conn.execute(this);
@expose(z.lazy(() => z.instanceof(Connection)).optional())
override async execute(conn?: Connection<any>): Promise<RowTypeToTsType<R>[]> {
return (conn ?? this.table.database.defaultConnection).execute(this);
}
Comment on lines +163 to 166

@expose(z.lazy(() => z.instanceof(Connection)))
async hydrate(conn: Connection<any>): Promise<R[]> {
return conn.hydrate<any, any, R>(this);
@expose(z.lazy(() => z.instanceof(Connection)).optional())
async hydrate(conn?: Connection<any>): Promise<R[]> {
return (conn ?? this.table.database.defaultConnection).hydrate<any, any, R>(this);
}

@expose()
Expand Down
39 changes: 23 additions & 16 deletions src/builder/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ import { isTableClass, TableBase } from "../table";
import z from "zod";
import { Values } from "./values";

// Optional Connection — the execute-family default: omitted, terminators
// fall back to the builder's Database.defaultConnection.
const zConn = z.lazy(() => z.instanceof(Connection)).optional();

// Compile a row type into a SQL select list: col AS "name", ...
export const compileSelectList = (output: RowType, omitAliases = false): Sql => {
return sql.join(
Expand Down Expand Up @@ -398,37 +402,40 @@ export class QueryBuilder<

// Fluent terminators. Narrow the Sql.execute() return type from
// QueryResult to a row array, and expose the hydrated / single-row
// variants as chainable terminators too.
@expose(z.lazy(() => z.instanceof(Connection)))
override async execute(conn: Connection<any>): Promise<RowTypeToTsType<O>[]> {
return conn.execute(this);
// variants as chainable terminators too. `conn` is optional everywhere:
// omitted, it resolves to the builder's Database.defaultConnection —
// unambiguous in the one-connection (Durable Object) model, a loud
// error otherwise.
@expose(zConn)
override async execute(conn?: Connection<any>): Promise<RowTypeToTsType<O>[]> {
return (conn ?? this.opts.database.defaultConnection).execute(this);
Comment on lines +409 to +411
}

// Streaming terminator. Mirrors `execute` but yields the rowset on
// every committed mutation that touches one of the live-tagged
// tables this query reads from. Caller iterates with `for await`.
@expose(z.lazy(() => z.instanceof(Connection)))
live(conn: Connection<any>): AsyncIterable<RowTypeToTsType<O>[]> {
return conn.live(this) as AsyncIterable<RowTypeToTsType<O>[]>;
@expose(zConn)
live(conn?: Connection<any>): AsyncIterable<RowTypeToTsType<O>[]> {
return (conn ?? this.opts.database.defaultConnection).live(this) as AsyncIterable<RowTypeToTsType<O>[]>;
}

@expose(z.lazy(() => z.instanceof(Connection)))
async hydrate(conn: Connection<any>): Promise<O[]> {
return conn.hydrate<O, GB, Card>(this);
@expose(zConn)
async hydrate(conn?: Connection<any>): Promise<O[]> {
return (conn ?? this.opts.database.defaultConnection).hydrate<O, GB, Card>(this);
}

@expose(z.lazy(() => z.instanceof(Connection)))
async one(conn: Connection<any>): Promise<O> {
const [row] = await conn.hydrate(this.limit(1));
@expose(zConn)
async one(conn?: Connection<any>): Promise<O> {
const [row] = await (conn ?? this.opts.database.defaultConnection).hydrate(this.limit(1));
if (!row) {
throw new Error("QueryBuilder.one(): query returned no rows");
}
return row;
}

@expose(z.lazy(() => z.instanceof(Connection)))
async maybeOne(conn: Connection<any>): Promise<O | null> {
const [row] = await conn.hydrate(this.limit(1));
@expose(zConn)
async maybeOne(conn?: Connection<any>): Promise<O | null> {
const [row] = await (conn ?? this.opts.database.defaultConnection).hydrate(this.limit(1));
return row ?? null;
}

Expand Down
12 changes: 6 additions & 6 deletions src/builder/update.ts
Original file line number Diff line number Diff line change
Expand Up @@ -168,14 +168,14 @@ export class UpdateBuilder<Name extends string, T extends TableBase, R extends R
return [this.finalize()];
}

@expose(z.lazy(() => z.instanceof(Connection)))
override async execute(conn: Connection<any>): Promise<RowTypeToTsType<R>[]> {
return conn.execute(this);
@expose(z.lazy(() => z.instanceof(Connection)).optional())
override async execute(conn?: Connection<any>): Promise<RowTypeToTsType<R>[]> {
return (conn ?? this.table.database.defaultConnection).execute(this);
}
Comment on lines +171 to 174

@expose(z.lazy(() => z.instanceof(Connection)))
async hydrate(conn: Connection<any>): Promise<R[]> {
return conn.hydrate<any, any, R>(this);
@expose(z.lazy(() => z.instanceof(Connection)).optional())
async hydrate(conn?: Connection<any>): Promise<R[]> {
return (conn ?? this.table.database.defaultConnection).hydrate<any, any, R>(this);
}

@expose()
Expand Down
69 changes: 68 additions & 1 deletion src/database.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { test, expect, beforeAll, afterAll } from "vitest";
import { test, describe, expect, beforeAll, afterAll } from "vitest";
import { Int8, Text } from "./types/postgres";
import { sql } from "./builder/sql";
import { setupDb, db, conn } from "./test-helpers";
Expand Down Expand Up @@ -131,3 +131,70 @@ test("nested transaction rejects any explicit level inside an ambient outer", as
await tx.transaction(async () => {});
});
});

// Database.defaultConnection: when exactly one connection is attached (the
// Durable Object model — one object, one connection), the execute-family
// terminators fall back to it, so `.execute()` needs no argument. Ambiguous
// (zero or several attached) throws. This is dialect-agnostic bookkeeping;
// exercised here over the pg harness.
describe("defaultConnection", () => {
// Defined inside each test, not at describe scope: poolDb is only assigned
// in beforeAll, which runs after the describe body is collected.
const makeWidgets = () =>
class Widgets extends poolDb.Table("widgets_dc") {
id = Int8.column({ nonNull: true, generated: true });
name = Text.column({ nonNull: true });
};

test("no connection attached → throws", () => {
const empty = new Database({ dialect: "postgres" });
expect(() => empty.defaultConnection).toThrow(/no connection attached/);
});

test("exactly one attached → terminators resolve it with no argument", async () => {
// poolDb has a single attached connection (poolConn, from beforeAll).
expect(poolDb.defaultConnection).toBe(poolConn);

const Widgets = makeWidgets();
await poolConn.execute(sql`CREATE TABLE widgets_dc (
id int8 GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL
)`);
await Widgets.insert({ name: "a" }).execute(); // no conn arg
const rows = await Widgets.from()
.select(({ widgets_dc }) => ({ name: widgets_dc.name }))
.execute(); // no conn arg
expect(rows).toEqual([{ name: "a" }]);
await poolConn.execute(sql`DROP TABLE widgets_dc`);
});

test("ambiguous (two attached) → throws until one closes", async () => {
// Fresh db + its own drivers so close() doesn't touch the shared pool.
const fdb = new Database({ dialect: "postgres" });
const d1 = await PgDriver.create(requireDatabaseUrl(), { max: 1 });
const d2 = await PgDriver.create(requireDatabaseUrl(), { max: 1 });
const c1 = fdb.attach(d1);
const c2 = fdb.attach(d2);
expect(() => fdb.defaultConnection).toThrow(/2 connections attached/);

// Connection.close() deregisters (then closes its driver), restoring an
// unambiguous default.
await c2.close();
expect(fdb.defaultConnection).toBe(c1);
await c1.close();
});

test("explicit conn still wins over the default", async () => {
const Widgets = makeWidgets();
await poolConn.execute(sql`CREATE TABLE widgets_dc (
id int8 GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
name text NOT NULL
)`);
await Widgets.insert({ name: "b" }).execute(poolConn); // explicit
const rows = await Widgets.from()
.select(({ widgets_dc }) => ({ name: widgets_dc.name }))
.execute(poolConn);
expect(rows).toEqual([{ name: "b" }]);
await poolConn.execute(sql`DROP TABLE widgets_dc`);
});
});
35 changes: 34 additions & 1 deletion src/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ const ISOLATION: { [K in TransactionIsolation]: { rank: number; begin: Sql } } =
export class Database<C = any> {
readonly name?: string;
readonly dialect: DialectName;
// Pool-backed connections currently attached (transaction-bound
// Connections are never registered). Basis for `defaultConnection`.
readonly #attached: Connection<C>[] = [];

constructor(opts: { dialect: DialectName; name?: string }) {
this.dialect = opts.dialect;
Expand Down Expand Up @@ -83,7 +86,36 @@ export class Database<C = any> {
`Driver dialect '${driver.dialect}' does not match Database dialect '${this.dialect}'.`,
);
}
return new Connection<C>(this, driver, undefined, undefined, liveOpts);
const conn = new Connection<C>(this, driver, undefined, undefined, liveOpts);
this.#attached.push(conn);
return conn;
}

/**
* The implicit execution context: when exactly one connection is
* attached (the Durable Object model — one object, one connection),
* the execute-family terminators fall back to it, so callers (and
* remote clients) can write `.execute()` with no token. Ambiguous
* (zero or several attached) throws — pass a Connection explicitly
* then, as transaction bodies always should (`tx`).
*/
get defaultConnection(): Connection<C> {
if (this.#attached.length === 1) {
return this.#attached[0]!;
}
throw new Error(
this.#attached.length === 0
? "defaultConnection: no connection attached — call db.attach(driver) first"
: `defaultConnection: ${this.#attached.length} connections attached — pass one explicitly`,
);
}

/** @internal Connection.close() deregisters itself. */
_detach(conn: Connection<C>): void {
const i = this.#attached.indexOf(conn);
if (i !== -1) {
this.#attached.splice(i, 1);
}
}

// Entry point for non-Table Fromables (SRFs, Values, subqueries).
Expand Down Expand Up @@ -314,6 +346,7 @@ export class Connection<C = undefined> {
if (this.#executor.bound) {
throw new Error("close() must be called on a pool-backed Connection, not inside a transaction");
}
this.database._detach(this);
await this.#bus?.stop();
await this.driver.close();
}
Expand Down
Loading