From f3aba284d5cf69a373eb3c7c0af3824d18f14675 Mon Sep 17 00:00:00 2001 From: Ryan Rasti Date: Wed, 22 Jul 2026 16:31:16 -0700 Subject: [PATCH] Database.defaultConnection: implicit conn for the one-connection model MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit attach() registers each pool-backed connection; defaultConnection resolves it when exactly one is attached (the Durable Object model — one object, one conn) and throws otherwise; close() deregisters. All execute-family terminators (QueryBuilder execute/hydrate/one/maybeOne/ live + insert/update/delete execute/hydrate) take an optional Connection, defaulting through the builder's own Database provenance. Explicit conn still wins — transaction bodies keep passing tx, which is required since the default resolves the pool-backed connection, not the active txn. Covered by the defaultConnection describe block in database.test.ts. Co-Authored-By: Claude Fable 5 --- src/builder/delete.ts | 12 ++++---- src/builder/insert.ts | 12 ++++---- src/builder/query.ts | 39 ++++++++++++++---------- src/builder/update.ts | 12 ++++---- src/database.test.ts | 69 ++++++++++++++++++++++++++++++++++++++++++- src/database.ts | 35 +++++++++++++++++++++- 6 files changed, 143 insertions(+), 36 deletions(-) diff --git a/src/builder/delete.ts b/src/builder/delete.ts index f8b1f66c..265d5f05 100644 --- a/src/builder/delete.ts +++ b/src/builder/delete.ts @@ -128,14 +128,14 @@ export class DeleteBuilder z.instanceof(Connection))) - override async execute(conn: Connection): Promise[]> { - return conn.execute(this); + @expose(z.lazy(() => z.instanceof(Connection)).optional()) + override async execute(conn?: Connection): Promise[]> { + return (conn ?? this.table.database.defaultConnection).execute(this); } - @expose(z.lazy(() => z.instanceof(Connection))) - async hydrate(conn: Connection): Promise { - return conn.hydrate(this); + @expose(z.lazy(() => z.instanceof(Connection)).optional()) + async hydrate(conn?: Connection): Promise { + return (conn ?? this.table.database.defaultConnection).hydrate(this); } @expose() diff --git a/src/builder/insert.ts b/src/builder/insert.ts index 295fe4b0..009068d5 100644 --- a/src/builder/insert.ts +++ b/src/builder/insert.ts @@ -160,14 +160,14 @@ export class InsertBuilder z.instanceof(Connection))) - override async execute(conn: Connection): Promise[]> { - return conn.execute(this); + @expose(z.lazy(() => z.instanceof(Connection)).optional()) + override async execute(conn?: Connection): Promise[]> { + return (conn ?? this.table.database.defaultConnection).execute(this); } - @expose(z.lazy(() => z.instanceof(Connection))) - async hydrate(conn: Connection): Promise { - return conn.hydrate(this); + @expose(z.lazy(() => z.instanceof(Connection)).optional()) + async hydrate(conn?: Connection): Promise { + return (conn ?? this.table.database.defaultConnection).hydrate(this); } @expose() diff --git a/src/builder/query.ts b/src/builder/query.ts index 7938b12e..a6f5b0de 100644 --- a/src/builder/query.ts +++ b/src/builder/query.ts @@ -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( @@ -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): Promise[]> { - 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): Promise[]> { + return (conn ?? this.opts.database.defaultConnection).execute(this); } // 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): AsyncIterable[]> { - return conn.live(this) as AsyncIterable[]>; + @expose(zConn) + live(conn?: Connection): AsyncIterable[]> { + return (conn ?? this.opts.database.defaultConnection).live(this) as AsyncIterable[]>; } - @expose(z.lazy(() => z.instanceof(Connection))) - async hydrate(conn: Connection): Promise { - return conn.hydrate(this); + @expose(zConn) + async hydrate(conn?: Connection): Promise { + return (conn ?? this.opts.database.defaultConnection).hydrate(this); } - @expose(z.lazy(() => z.instanceof(Connection))) - async one(conn: Connection): Promise { - const [row] = await conn.hydrate(this.limit(1)); + @expose(zConn) + async one(conn?: Connection): Promise { + 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): Promise { - const [row] = await conn.hydrate(this.limit(1)); + @expose(zConn) + async maybeOne(conn?: Connection): Promise { + const [row] = await (conn ?? this.opts.database.defaultConnection).hydrate(this.limit(1)); return row ?? null; } diff --git a/src/builder/update.ts b/src/builder/update.ts index 29c97c94..d6936560 100644 --- a/src/builder/update.ts +++ b/src/builder/update.ts @@ -168,14 +168,14 @@ export class UpdateBuilder z.instanceof(Connection))) - override async execute(conn: Connection): Promise[]> { - return conn.execute(this); + @expose(z.lazy(() => z.instanceof(Connection)).optional()) + override async execute(conn?: Connection): Promise[]> { + return (conn ?? this.table.database.defaultConnection).execute(this); } - @expose(z.lazy(() => z.instanceof(Connection))) - async hydrate(conn: Connection): Promise { - return conn.hydrate(this); + @expose(z.lazy(() => z.instanceof(Connection)).optional()) + async hydrate(conn?: Connection): Promise { + return (conn ?? this.table.database.defaultConnection).hydrate(this); } @expose() diff --git a/src/database.test.ts b/src/database.test.ts index 307f9d94..cfd354b9 100644 --- a/src/database.test.ts +++ b/src/database.test.ts @@ -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"; @@ -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`); + }); +}); diff --git a/src/database.ts b/src/database.ts index 545aa828..f806aebe 100644 --- a/src/database.ts +++ b/src/database.ts @@ -48,6 +48,9 @@ const ISOLATION: { [K in TransactionIsolation]: { rank: number; begin: Sql } } = export class Database { readonly name?: string; readonly dialect: DialectName; + // Pool-backed connections currently attached (transaction-bound + // Connections are never registered). Basis for `defaultConnection`. + readonly #attached: Connection[] = []; constructor(opts: { dialect: DialectName; name?: string }) { this.dialect = opts.dialect; @@ -83,7 +86,36 @@ export class Database { `Driver dialect '${driver.dialect}' does not match Database dialect '${this.dialect}'.`, ); } - return new Connection(this, driver, undefined, undefined, liveOpts); + const conn = new Connection(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 { + 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): void { + const i = this.#attached.indexOf(conn); + if (i !== -1) { + this.#attached.splice(i, 1); + } } // Entry point for non-Table Fromables (SRFs, Values, subqueries). @@ -314,6 +346,7 @@ export class Connection { 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(); }