Skip to content

Commit 2ce35a1

Browse files
dmealingclaude
andcommitted
fix(runtime-ts): read Cloudflare D1's affected-row count
extractRowCount handled libsql `rowsAffected`, node-postgres `rowCount`, bun:sqlite `changes` and mysql2's header, but not drizzle-orm/d1's `{ success, meta: { changes }, results }`. Every D1 delete therefore counted 0 rows, and the generated Fastify and Hono DELETE routes answered 404 after deleting the row. Found on an adopter's Workers admin panel; it predates 1.0. The shared helper now reads meta.changes, and the drizzle ObjectManager driver, which had a second copy of the same function missing the same shape (and bun:sqlite's), now imports the shared one. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01M1mRRB1K2WdYy82kntMQdx
1 parent 61194c1 commit 2ce35a1

6 files changed

Lines changed: 53 additions & 23 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,13 @@ here.**
4545

4646
### Fixed
4747

48+
- **Generated DELETE routes on Cloudflare D1 answer 204, not 404.** The affected-row count
49+
read libsql, Postgres, bun:sqlite and mysql2 result shapes, but not D1's
50+
`{ meta: { changes } }`, so every D1 delete counted 0 rows and the route answered 404 after
51+
deleting the row (an admin panel showed an error and kept the row on screen). The drizzle
52+
ObjectManager driver had its own copy of the same count and now shares the fixed one.
53+
**Upgrading:** a project with an ejected adapter copy under `codegen/runtime/` takes it with
54+
`meta eject routes-hono --force` (or merges the one-line change by hand).
4855
- **The missing-FK-index advisory suggests a name unique to the table.** It suggested
4956
`by<Field>`, so two entities with the same FK field (`Citation.auditEntryId`,
5057
`Approval.auditEntryId`) were both told `byAuditEntryId`. Postgres index names are schema-wide,

‎examples/advanced-modeling/codegen/runtime/drizzle-fastify/util.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,9 @@ export async function firstRow(db: unknown, src: unknown, cond: unknown): Promis
117117

118118
/**
119119
* Rows a DELETE/UPDATE affected, across the driver result shapes: libsql `rowsAffected`,
120-
* node-postgres `rowCount`, bun:sqlite / better-sqlite3 `changes`, and mysql2's
121-
* `[ResultSetHeader, fields]` tuple.
120+
* node-postgres `rowCount`, bun:sqlite / better-sqlite3 `changes`, Cloudflare D1's
121+
* `{ meta: { changes } }`, and mysql2's `[ResultSetHeader, fields]` tuple. The one place
122+
* this is decided — the routes and the drizzle driver both call it.
122123
*/
123124
export function extractRowCount(result: unknown): number {
124125
if (typeof result === "number") return result;
@@ -134,6 +135,11 @@ export function extractRowCount(result: unknown): number {
134135
if (typeof obj.rowCount === "number") return obj.rowCount;
135136
// bun:sqlite / better-sqlite3 run() result shape.
136137
if (typeof obj.changes === "number") return obj.changes;
138+
// Cloudflare D1 (drizzle-orm/d1): `{ success, meta: { changes, … }, results }`. Missing
139+
// it read every D1 delete as 0 rows, and the generated DELETE answered 404 after
140+
// deleting the row.
141+
const meta = (result as { meta?: { changes?: unknown } }).meta;
142+
if (meta !== undefined && typeof meta.changes === "number") return meta.changes;
137143
}
138144
return 0;
139145
}

‎examples/showcase/codegen/runtime/drizzle-fastify/util.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,9 @@ export async function firstRow(db: unknown, src: unknown, cond: unknown): Promis
117117

118118
/**
119119
* Rows a DELETE/UPDATE affected, across the driver result shapes: libsql `rowsAffected`,
120-
* node-postgres `rowCount`, bun:sqlite / better-sqlite3 `changes`, and mysql2's
121-
* `[ResultSetHeader, fields]` tuple.
120+
* node-postgres `rowCount`, bun:sqlite / better-sqlite3 `changes`, Cloudflare D1's
121+
* `{ meta: { changes } }`, and mysql2's `[ResultSetHeader, fields]` tuple. The one place
122+
* this is decided — the routes and the drizzle driver both call it.
122123
*/
123124
export function extractRowCount(result: unknown): number {
124125
if (typeof result === "number") return result;
@@ -134,6 +135,11 @@ export function extractRowCount(result: unknown): number {
134135
if (typeof obj.rowCount === "number") return obj.rowCount;
135136
// bun:sqlite / better-sqlite3 run() result shape.
136137
if (typeof obj.changes === "number") return obj.changes;
138+
// Cloudflare D1 (drizzle-orm/d1): `{ success, meta: { changes, … }, results }`. Missing
139+
// it read every D1 delete as 0 rows, and the generated DELETE answered 404 after
140+
// deleting the row.
141+
const meta = (result as { meta?: { changes?: unknown } }).meta;
142+
if (meta !== undefined && typeof meta.changes === "number") return meta.changes;
137143
}
138144
return 0;
139145
}

‎server/typescript/packages/runtime-ts/src/drivers/drizzle-driver.ts‎

Lines changed: 1 addition & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import {
3131
getTableColumns,
3232
type SQL,
3333
} from "drizzle-orm";
34+
import { extractRowCount } from "../drizzle-fastify/util.js";
3435
import type {
3536
PersistenceDriver,
3637
SelectSpec,
@@ -408,23 +409,6 @@ function jsKeyedFromSelect(row: Row, _selectMap: Record<string, AnyColumn>): Row
408409
return row;
409410
}
410411

411-
function extractRowCount(result: unknown): number {
412-
// Drizzle's update/delete return shape varies by backend. We support the
413-
// common shapes; unknown shapes fall through to 0.
414-
if (typeof result === "number") return result;
415-
// mysql2: `[ResultSetHeader, fields]`, the count on the header.
416-
if (Array.isArray(result) && typeof (result[0] as { affectedRows?: unknown } | undefined)?.affectedRows === "number") {
417-
return (result[0] as { affectedRows: number }).affectedRows;
418-
}
419-
if (Array.isArray(result)) return result.length;
420-
if (result && typeof result === "object") {
421-
const obj = result as { rowsAffected?: number | bigint; rowCount?: number };
422-
if (typeof obj.rowsAffected === "number") return obj.rowsAffected;
423-
if (typeof obj.rowsAffected === "bigint") return Number(obj.rowsAffected);
424-
if (typeof obj.rowCount === "number") return obj.rowCount;
425-
}
426-
return 0;
427-
}
428412

429413
function buildExpression(w: WhereClause, cols: Map<string, AnyColumn>): unknown {
430414
switch (w.kind) {

‎server/typescript/packages/runtime-ts/src/drizzle-fastify/util.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,8 +117,9 @@ export async function firstRow(db: unknown, src: unknown, cond: unknown): Promis
117117

118118
/**
119119
* Rows a DELETE/UPDATE affected, across the driver result shapes: libsql `rowsAffected`,
120-
* node-postgres `rowCount`, bun:sqlite / better-sqlite3 `changes`, and mysql2's
121-
* `[ResultSetHeader, fields]` tuple.
120+
* node-postgres `rowCount`, bun:sqlite / better-sqlite3 `changes`, Cloudflare D1's
121+
* `{ meta: { changes } }`, and mysql2's `[ResultSetHeader, fields]` tuple. The one place
122+
* this is decided — the routes and the drizzle driver both call it.
122123
*/
123124
export function extractRowCount(result: unknown): number {
124125
if (typeof result === "number") return result;
@@ -134,6 +135,11 @@ export function extractRowCount(result: unknown): number {
134135
if (typeof obj.rowCount === "number") return obj.rowCount;
135136
// bun:sqlite / better-sqlite3 run() result shape.
136137
if (typeof obj.changes === "number") return obj.changes;
138+
// Cloudflare D1 (drizzle-orm/d1): `{ success, meta: { changes, … }, results }`. Missing
139+
// it read every D1 delete as 0 rows, and the generated DELETE answered 404 after
140+
// deleting the row.
141+
const meta = (result as { meta?: { changes?: unknown } }).meta;
142+
if (meta !== undefined && typeof meta.changes === "number") return meta.changes;
137143
}
138144
return 0;
139145
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Rows a DELETE/UPDATE affected, per driver result shape. Cloudflare D1 (drizzle-orm/d1)
2+
// resolves a run() to `{ success, meta: { changes, … }, results }` — no `rowsAffected`,
3+
// `rowCount` or top-level `changes` — so the count fell through to 0 and every generated
4+
// DELETE answered 404 after deleting the row (found on an adopter's Workers admin panel).
5+
import { describe, test, expect } from "bun:test";
6+
import { extractRowCount } from "../src/drizzle-fastify/util.js";
7+
8+
describe("extractRowCount", () => {
9+
test("Cloudflare D1: meta.changes", () => {
10+
expect(extractRowCount({ success: true, meta: { changes: 1, last_row_id: 3, duration: 0.2 }, results: [] })).toBe(1);
11+
expect(extractRowCount({ success: true, meta: { changes: 0 }, results: [] })).toBe(0);
12+
});
13+
test("the existing shapes still read", () => {
14+
expect(extractRowCount({ rowsAffected: 2 })).toBe(2);
15+
expect(extractRowCount({ rowsAffected: 2n })).toBe(2);
16+
expect(extractRowCount({ rowCount: 3 })).toBe(3);
17+
expect(extractRowCount({ changes: 4 })).toBe(4);
18+
expect(extractRowCount([{ affectedRows: 5 }, []])).toBe(5);
19+
expect(extractRowCount(6)).toBe(6);
20+
});
21+
});

0 commit comments

Comments
 (0)