Blame · Line-by-line history
count-bigint.test.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| 02aa828 | 1 | /** |
| 2 | * Postgres count() returns bigint, which the driver hands back as a STRING. | |
| 3 | * | |
| 4 | * Drizzle's `sql<number>` is a compile-time cast only — it does not coerce at | |
| 5 | * runtime — so `sql<number>`count(*)`` yields "0", not 0. Every consumer that | |
| 6 | * compared with === or did arithmetic was silently broken: | |
| 7 | * | |
| 8 | * - onboarding.tsx: `repoCount === 0` is "0" === 0 → false, so the entire | |
| 9 | * first-run branch was unreachable and brand-new users never saw the | |
| 10 | * getting-started flow. | |
| 11 | * - notifications.tsx: `unreadCount === 0` → the "You are all caught up" | |
| 12 | * state could never render. | |
| 13 | * - inbox.tsx: GET /api/inbox/count returned a JSON string, diverging from | |
| 14 | * /api/notifications/count which returns a number. | |
| 15 | * - pulls.tsx previously rendered "0"+"0"+"0" as "000" (fixed earlier). | |
| 16 | * | |
| 17 | * The established fix in this codebase is `count(*)::int` — already used at | |
| 18 | * 100+ sites. These stragglers are now consistent with it. | |
| 19 | */ | |
| 20 | ||
| 21 | import { describe, expect, it } from "bun:test"; | |
| 22 | import { readdirSync, readFileSync, statSync } from "fs"; | |
| 23 | import { join } from "path"; | |
| 24 | ||
| 25 | function walk(dir: string): string[] { | |
| 26 | const out: string[] = []; | |
| 27 | for (const e of readdirSync(dir)) { | |
| 28 | const p = join(dir, e); | |
| 29 | if (statSync(p).isDirectory()) { | |
| 30 | if (e === "__tests__") continue; | |
| 31 | out.push(...walk(p)); | |
| 32 | } else if (e.endsWith(".ts") || e.endsWith(".tsx")) out.push(p); | |
| 33 | } | |
| 34 | return out; | |
| 35 | } | |
| 36 | ||
| 37 | const FILES = walk("src"); | |
| 38 | ||
| 39 | describe("no uncast bigint aggregates typed as number", () => { | |
| 40 | it("every sql<number>count(*) casts to ::int", () => { | |
| 41 | const offenders: string[] = []; | |
| 42 | for (const f of FILES) { | |
| 43 | const src = readFileSync(f, "utf8"); | |
| 44 | src.split("\n").forEach((line, i) => { | |
| 45 | // Typed as number but no cast — the exact bug shape. | |
| 46 | if (/sql<number>`\s*count\(\*\)\s*`/.test(line)) { | |
| 47 | offenders.push(`${f.replace(/\\/g, "/")}:${i + 1}`); | |
| 48 | } | |
| 49 | }); | |
| 50 | } | |
| 51 | expect(offenders).toEqual([]); | |
| 52 | }); | |
| 53 | ||
| 54 | it("no sql<number> wraps count()/sum() of a column without a cast either", () => { | |
| 55 | const offenders: string[] = []; | |
| 56 | for (const f of FILES) { | |
| 57 | const src = readFileSync(f, "utf8"); | |
| 58 | src.split("\n").forEach((line, i) => { | |
| 59 | const m = line.match(/sql<number>`([^`]*)`/); | |
| 60 | if (!m) return; | |
| 61 | const expr = m[1]; | |
| 62 | if (!/\b(count|sum)\s*\(/i.test(expr)) return; | |
| 63 | if (expr.includes("::")) return; // cast present | |
| 64 | offenders.push(`${f.replace(/\\/g, "/")}:${i + 1} → ${expr.trim()}`); | |
| 65 | }); | |
| 66 | } | |
| 67 | expect(offenders).toEqual([]); | |
| 68 | }); | |
| 69 | }); | |
| 70 | ||
| 71 | describe("the specific consumers that were broken", () => { | |
| 72 | it("onboarding's first-run branch can now be reached", () => { | |
| 73 | const src = readFileSync("src/routes/onboarding.tsx", "utf8"); | |
| 74 | expect(src).toContain("count(*)::int"); | |
| 75 | // The comparison itself is fine once the value is a real number. | |
| 76 | expect(src).toContain("repoCount === 0"); | |
| 77 | }); | |
| 78 | ||
| 79 | it("notifications' all-caught-up branch can now be reached", () => { | |
| 80 | const src = readFileSync("src/routes/notifications.tsx", "utf8"); | |
| 81 | expect(src).toContain("count(*)::int"); | |
| 82 | expect(src).toContain("unreadCount === 0"); | |
| 83 | }); | |
| 84 | ||
| 85 | it("inbox's count endpoint returns a number", () => { | |
| 86 | const src = readFileSync("src/routes/inbox.tsx", "utf8"); | |
| 87 | expect(src).toContain("count(*)::int"); | |
| 88 | }); | |
| 89 | }); | |
| 90 | ||
| 91 | describe("ORDER BY aggregates are deliberately left alone", () => { | |
| 92 | it("bare sql`count(*)` in an orderBy is not flagged", () => { | |
| 93 | // Sorting on bigint is correct; only *selected* values reach JS and need | |
| 94 | // the cast. Keeping these uncast avoids churn with no behaviour change. | |
| 95 | const pulls = readFileSync("src/routes/pulls.tsx", "utf8"); | |
| 96 | expect(pulls).toContain("desc(sql`count(*)`)"); | |
| 97 | }); | |
| 98 | }); |