From c28bcab6b3e430f06d75cd2e8f41c3616e598b85 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Tue, 25 Aug 2026 14:10:09 +0100 Subject: [PATCH 1/3] feat(database-client): carry a row type through prepare --- .changeset/database-client-prepare-generic.md | 5 ++ packages/database-client/README.md | 13 ++++ packages/database-client/src/client.test.ts | 67 +++++++++++++++++++ packages/database-client/src/client.ts | 30 ++++----- 4 files changed, 100 insertions(+), 15 deletions(-) create mode 100644 .changeset/database-client-prepare-generic.md diff --git a/.changeset/database-client-prepare-generic.md b/.changeset/database-client-prepare-generic.md new file mode 100644 index 00000000..bd27dc5b --- /dev/null +++ b/.changeset/database-client-prepare-generic.md @@ -0,0 +1,5 @@ +--- +"@bunny.net/database-client": minor +--- + +Let `prepare()` type every row the statement returns diff --git a/packages/database-client/README.md b/packages/database-client/README.md index 919657b3..7aba6835 100644 --- a/packages/database-client/README.md +++ b/packages/database-client/README.md @@ -66,6 +66,8 @@ const alice = await byId.bind(1).first(); const bob = await byId.bind(2).first(); ``` +Pass a row type to have it flow through every execution of that statement. See [Types](#types). + ### `statement.bind(...values)` Binds parameters and returns a new statement. Accepts `null`, `boolean`, `number`, `bigint`, `string`, and `Uint8Array`. @@ -231,6 +233,17 @@ interface User { const users = await db.prepare("SELECT id, name FROM users").all(); ``` +Or type the statement once and let every execution of it inherit the shape: + +```ts +const byId = db.prepare("SELECT id, name FROM users WHERE id = ?"); + +const alice = await byId.bind(1).first(); // User | null +const both = await byId.bind(1).all(); // User[] +``` + +A type argument on the executor still wins over the statement's, so `all()` on a `Statement` gives you rows back untyped. + That type is an assertion. Nothing validates the rows against it at runtime, so it is only ever as accurate as your SQL. ## Edge Scripting diff --git a/packages/database-client/src/client.test.ts b/packages/database-client/src/client.test.ts index 645b0879..ad1d2406 100644 --- a/packages/database-client/src/client.test.ts +++ b/packages/database-client/src/client.test.ts @@ -342,6 +342,73 @@ describe("statement", () => { }); }); +interface User { + id: number; + name: string; +} + +describe("typed statements", () => { + test("prepare carries the row type to every executor", async () => { + const fake = fakeFetch([okExecute(["id", "name"], [[1, "a"]])]); + const db = connect({ url: URL_, fetch: fake.fetch }); + const byId = db.prepare("SELECT id, name FROM users WHERE id = ?"); + + const users = await byId.bind(1).all(); + const user = await byId.bind(1).first(); + + // Typed as User[] and User | null rather than Row, so these read without a cast. + expect(users[0]?.name).toBe("a"); + expect(user?.id).toBe(1); + }); + + test("a call-site type argument still wins", async () => { + const fake = fakeFetch([okExecute(["id"], [[1]])]); + const db = connect({ url: URL_, fetch: fake.fetch }); + + const rows = await db.prepare("SELECT id FROM users").all<{ + id: number; + }>(); + + expect(rows).toEqual([{ id: 1 }]); + }); + + test("a typed statement still passes to batch", async () => { + const fake = fakeFetch([ + { + type: "ok", + response: { + type: "batch", + result: { + step_results: [ + null, + okExecute(["id", "name"], [[1, "a"]]).response.result, + null, + null, + ], + step_errors: [null, null, null, null], + }, + }, + }, + ]); + const db = connect({ url: URL_, fetch: fake.fetch }); + + const [users] = await db.batch([ + db.prepare("SELECT id, name FROM users"), + ]); + + expect(users?.rows[0]?.name).toBe("a"); + }); + + test("column reads are unaffected by the row type", async () => { + const fake = fakeFetch([okExecute(["name"], [["a"]])]); + const db = connect({ url: URL_, fetch: fake.fetch }); + + const name = await db.prepare("SELECT name FROM users").first("name"); + + expect(name).toBe("a"); + }); +}); + describe("batch", () => { function okBatch(count: number) { const step = { diff --git a/packages/database-client/src/client.ts b/packages/database-client/src/client.ts index 4444defa..45072abf 100644 --- a/packages/database-client/src/client.ts +++ b/packages/database-client/src/client.ts @@ -82,8 +82,8 @@ function toResult(wire: WireStmtResult): Result { return { ...raw, rows } as Result; } -/** A SQL statement plus its bound arguments. Immutable and reusable. */ -export class Statement { +/** A SQL statement plus its bound arguments. Immutable and reusable. `T` is the row shape its executors return. */ +export class Statement { readonly #internals: StatementInternals; constructor(internals: StatementInternals) { @@ -91,7 +91,7 @@ export class Statement { } /** Return a copy of this statement with `values` bound: positionally for `?`, or one object for `:name`, `@name`, and `$name`. */ - bind(...values: unknown[]): Statement { + bind(...values: unknown[]): Statement { const named = values.find(isNamedArgs); if (named && values.length > 1) { throw new DatabaseError( @@ -100,7 +100,7 @@ export class Statement { ); } if (named) { - return new Statement({ + return new Statement({ ...this.#internals, args: [], // The server resolves a bare name against :name, @name, and $name, so the sigil is optional. @@ -110,7 +110,7 @@ export class Statement { })), }); } - return new Statement({ + return new Statement({ ...this.#internals, args: values.map(encodeValue), namedArgs: [], @@ -118,18 +118,18 @@ export class Statement { } /** Execute and return every row as an object. */ - async all(): Promise { - return (await this.run()).rows; + async all(): Promise { + return (await this.run()).rows; } /** Execute and return the first row, or the value of one column of it. */ - async first(): Promise; + async first(): Promise; async first(column: string): Promise; - async first(column?: string): Promise { + async first(column?: string): Promise { const result = await this.run(); const row = result.rows[0]; if (!row) return null; - if (column === undefined) return row as T; + if (column === undefined) return row as R; if (!Object.hasOwn(row, column)) { throw new DatabaseError( `column "${column}" is not in the result; got ${result.columns.join(", ")}`, @@ -150,8 +150,8 @@ export class Statement { } /** Execute and return rows together with write metadata. */ - async run(): Promise> { - return toResult(await this.#execute()); + async run(): Promise> { + return toResult(await this.#execute()); } /** @internal exposed so `batch()` can read the wire form. */ @@ -185,9 +185,9 @@ export class Database { this.#signal = config.signal; } - /** Create a statement from SQL. Bind arguments with `.bind()`. */ - prepare(sql: string): Statement { - return new Statement({ + /** Create a statement from SQL. Bind arguments with `.bind()`. Pass `T` to type every row it returns. */ + prepare(sql: string): Statement { + return new Statement({ sql, args: [], namedArgs: [], From 90c1a1bcf01b538a113184b78ae8723bb45fbe7d Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Thu, 27 Aug 2026 18:37:01 +0100 Subject: [PATCH 2/3] fix(database-client): infer the batch row type from its statements and mark the changeset patch --- .changeset/database-client-prepare-generic.md | 2 +- packages/database-client/README.md | 2 ++ packages/database-client/src/client.test.ts | 8 +++++--- packages/database-client/src/client.ts | 6 +++--- 4 files changed, 11 insertions(+), 7 deletions(-) diff --git a/.changeset/database-client-prepare-generic.md b/.changeset/database-client-prepare-generic.md index bd27dc5b..53673dfc 100644 --- a/.changeset/database-client-prepare-generic.md +++ b/.changeset/database-client-prepare-generic.md @@ -1,5 +1,5 @@ --- -"@bunny.net/database-client": minor +"@bunny.net/database-client": patch --- Let `prepare()` type every row the statement returns diff --git a/packages/database-client/README.md b/packages/database-client/README.md index c7ae0d7d..2a88b3d0 100644 --- a/packages/database-client/README.md +++ b/packages/database-client/README.md @@ -167,6 +167,8 @@ const [inserted, count] = await db.batch([ You get one `Result` per statement you passed, in order. `batchRaw()` is the same but with positional rows. If any statement fails the transaction rolls back and `batch()` throws that statement's error. +`batch()` infers its row type from the statements, so passing `prepare(...)` statements gives you `Result[]` without repeating the type. See [Types](#types). + `{ foreignKeys: false }` brackets the transaction with `PRAGMA foreign_keys=off` and `=on`. Schema changes need it: SQLite's table rebuild procedure and several `ALTER TABLE` forms require enforcement genuinely off, not merely deferred to commit. `bunny db migrations apply` runs this way. ```ts diff --git a/packages/database-client/src/client.test.ts b/packages/database-client/src/client.test.ts index 4fafa69d..6f9d44ad 100644 --- a/packages/database-client/src/client.test.ts +++ b/packages/database-client/src/client.test.ts @@ -372,7 +372,7 @@ describe("typed statements", () => { expect(rows).toEqual([{ id: 1 }]); }); - test("a typed statement still passes to batch", async () => { + test("batch infers the row type from its statements", async () => { const fake = fakeFetch([ { type: "ok", @@ -392,11 +392,13 @@ describe("typed statements", () => { ]); const db = connect({ url: URL_, fetch: fake.fetch }); - const [users] = await db.batch([ + const [users] = await db.batch([ db.prepare("SELECT id, name FROM users"), ]); - expect(users?.rows[0]?.name).toBe("a"); + // Typed as string | undefined rather than SqlValue, so batch inherited User. + const name: string | undefined = users?.rows[0]?.name; + expect(name).toBe("a"); }); test("column reads are unaffected by the row type", async () => { diff --git a/packages/database-client/src/client.ts b/packages/database-client/src/client.ts index 8cd57390..36d55e20 100644 --- a/packages/database-client/src/client.ts +++ b/packages/database-client/src/client.ts @@ -210,7 +210,7 @@ export class Database { /** Run every statement in one transaction. All succeed or none are applied. */ async batch( - statements: Statement[], + statements: Statement[], options: BatchOptions = {}, ): Promise[]> { return (await this.#batch(statements, options)).map((wire) => @@ -220,14 +220,14 @@ export class Database { /** Like `batch()`, but each result has positional rows. Keeps duplicate column names distinct. */ async batchRaw( - statements: Statement[], + statements: Statement[], options: BatchOptions = {}, ): Promise { return (await this.#batch(statements, options)).map(toRawResult); } async #batch( - statements: Statement[], + statements: Statement[], options: BatchOptions, ): Promise { if (statements.length === 0) return []; From 7ca96c0365a1248ecaa64dc0eef8ca05b5795c77 Mon Sep 17 00:00:00 2001 From: jamie-at-bunny Date: Thu, 27 Aug 2026 18:42:54 +0100 Subject: [PATCH 3/3] fix(database-client): keep each statement's row type through mixed batches --- .changeset/database-client-prepare-generic.md | 2 +- packages/database-client/README.md | 2 +- packages/database-client/src/client.test.ts | 34 ++++++++++++++++++- packages/database-client/src/client.ts | 19 +++++++---- packages/database-client/src/index.ts | 1 + 5 files changed, 48 insertions(+), 10 deletions(-) diff --git a/.changeset/database-client-prepare-generic.md b/.changeset/database-client-prepare-generic.md index 53673dfc..5346af59 100644 --- a/.changeset/database-client-prepare-generic.md +++ b/.changeset/database-client-prepare-generic.md @@ -2,4 +2,4 @@ "@bunny.net/database-client": patch --- -Let `prepare()` type every row the statement returns +Let `prepare()` type every row the statement returns, including through `batch()` diff --git a/packages/database-client/README.md b/packages/database-client/README.md index 2a88b3d0..db947d9a 100644 --- a/packages/database-client/README.md +++ b/packages/database-client/README.md @@ -167,7 +167,7 @@ const [inserted, count] = await db.batch([ You get one `Result` per statement you passed, in order. `batchRaw()` is the same but with positional rows. If any statement fails the transaction rolls back and `batch()` throws that statement's error. -`batch()` infers its row type from the statements, so passing `prepare(...)` statements gives you `Result[]` without repeating the type. See [Types](#types). +`batch()` infers each result's row type from its statement, so a `prepare(...)` statement comes back as `Result` even next to untyped ones. See [Types](#types). `{ foreignKeys: false }` brackets the transaction with `PRAGMA foreign_keys=off` and `=on`. Schema changes need it: SQLite's table rebuild procedure and several `ALTER TABLE` forms require enforcement genuinely off, not merely deferred to commit. `bunny db migrations apply` runs this way. diff --git a/packages/database-client/src/client.test.ts b/packages/database-client/src/client.test.ts index 6f9d44ad..2c778178 100644 --- a/packages/database-client/src/client.test.ts +++ b/packages/database-client/src/client.test.ts @@ -397,10 +397,42 @@ describe("typed statements", () => { ]); // Typed as string | undefined rather than SqlValue, so batch inherited User. - const name: string | undefined = users?.rows[0]?.name; + const name: string | undefined = users.rows[0]?.name; expect(name).toBe("a"); }); + test("a mixed batch keeps each statement's own row type", async () => { + const fake = fakeFetch([ + { + type: "ok", + response: { + type: "batch", + result: { + step_results: [ + null, + okExecute(["id", "name"], [[1, "a"]]).response.result, + okExecute(["c"], [[2]]).response.result, + null, + null, + ], + step_errors: [null, null, null, null, null], + }, + }, + }, + ]); + const db = connect({ url: URL_, fetch: fake.fetch }); + + const [users, counts] = await db.batch([ + db.prepare("SELECT id, name FROM users"), + db.prepare("SELECT COUNT(*) AS c FROM users"), + ]); + + // Result and Result respectively, so name reads as a string and c as a SqlValue. + const name: string | undefined = users.rows[0]?.name; + expect(name).toBe("a"); + expect(counts.rows[0]?.c).toBe(2); + }); + test("column reads are unaffected by the row type", async () => { const fake = fakeFetch([okExecute(["name"], [["a"]])]); const db = connect({ url: URL_, fetch: fake.fetch }); diff --git a/packages/database-client/src/client.ts b/packages/database-client/src/client.ts index 36d55e20..8c37087b 100644 --- a/packages/database-client/src/client.ts +++ b/packages/database-client/src/client.ts @@ -174,6 +174,11 @@ export class Statement { } } +/** Maps each statement in a batch to the `Result` of its row type. */ +export type BatchResults[]> = { + -readonly [K in keyof T]: T[K] extends Statement ? Result : never; +}; + /** A connection to a bunny.net database. Stateless: each call is one HTTPS request. */ export class Database { readonly #transport: Transport; @@ -209,25 +214,25 @@ export class Database { } /** Run every statement in one transaction. All succeed or none are applied. */ - async batch( - statements: Statement[], + async batch[]>( + statements: [...T], options: BatchOptions = {}, - ): Promise[]> { + ): Promise> { return (await this.#batch(statements, options)).map((wire) => - toResult(wire), - ); + toResult(wire), + ) as BatchResults; } /** Like `batch()`, but each result has positional rows. Keeps duplicate column names distinct. */ async batchRaw( - statements: Statement[], + statements: readonly Statement[], options: BatchOptions = {}, ): Promise { return (await this.#batch(statements, options)).map(toRawResult); } async #batch( - statements: Statement[], + statements: readonly Statement[], options: BatchOptions, ): Promise { if (statements.length === 0) return []; diff --git a/packages/database-client/src/index.ts b/packages/database-client/src/index.ts index 14dffbcf..b3903014 100644 --- a/packages/database-client/src/index.ts +++ b/packages/database-client/src/index.ts @@ -1,5 +1,6 @@ export { type BatchOptions, + type BatchResults, type Config, connect, Database,