Blame · Line-by-line history
schema-single-source.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.
| 45ac593 | 1 | /** |
| 2 | * One Drizzle definition per physical table. | |
| 3 | * | |
| 4 | * `organizations` was declared TWICE — in schema.ts (slug + created_by_id | |
| 5 | * NOT NULL, matching migration 0003 and therefore the real database) and in | |
| 6 | * schema-extensions.ts (display_name/website/location/is_verified, neither | |
| 7 | * NOT NULL column). Both mapped to the same physical table with irreconcilable | |
| 8 | * shapes. | |
| 9 | * | |
| 10 | * drizzle.config.ts includes only schema.ts and deploys run `db:migrate`, | |
| 11 | * never `db:push`, so the schema-extensions version was never migrated: it | |
| 12 | * described a table that has never existed. routes/orgs.tsx imported it, so | |
| 13 | * POST /orgs/new wrote columns Postgres does not have while omitting two that | |
| 14 | * are NOT NULL. Organization creation could never have succeeded, and because | |
| 15 | * each file typechecked against its own definition, tsc could not see it. | |
| 16 | * | |
| 17 | * The duplicate-table check below is the one that matters: it fails the build | |
| 18 | * for ANY table declared in more than one place, so this class cannot recur. | |
| 19 | */ | |
| 20 | ||
| 21 | import { describe, expect, it } from "bun:test"; | |
| 22 | import { readFileSync, readdirSync } from "fs"; | |
| 23 | import { join } from "path"; | |
| 24 | ||
| 25 | const DB_DIR = "src/db"; | |
| 26 | ||
| 27 | /** | |
| 28 | * Strip comments before scanning. Without this the check matches its own | |
| 29 | * documentation: these files explain the duplicate-table bug in prose that | |
| 30 | * necessarily contains `pgTable("organizations"`, and a naive scan then | |
| 31 | * reports the very thing the comment says was fixed. | |
| 32 | */ | |
| 33 | function code(src: string): string { | |
| 34 | return src | |
| 35 | .replace(/\/\*[\s\S]*?\*\//g, "") | |
| 36 | .replace(/^\s*\/\/.*$/gm, ""); | |
| 37 | } | |
| 38 | ||
| 39 | function schemaFiles(): string[] { | |
| 40 | return readdirSync(DB_DIR) | |
| 41 | .filter((f) => f.endsWith(".ts") && f.includes("schema")) | |
| 42 | .map((f) => join(DB_DIR, f)); | |
| 43 | } | |
| 44 | ||
| 45 | describe("no physical table is declared twice", () => { | |
| 46 | it("every pgTable name is unique across all schema files", () => { | |
| 47 | const seen = new Map<string, string[]>(); | |
| 48 | for (const f of schemaFiles()) { | |
| 49 | const src = code(readFileSync(f, "utf8")); | |
| 50 | for (const m of src.matchAll(/pgTable\(\s*["'`]([^"'`]+)["'`]/g)) { | |
| 51 | const table = m[1]; | |
| 52 | if (!seen.has(table)) seen.set(table, []); | |
| 53 | seen.get(table)!.push(f.replace(/\\/g, "/")); | |
| 54 | } | |
| 55 | } | |
| 56 | const dupes = [...seen.entries()] | |
| 57 | .filter(([, files]) => files.length > 1) | |
| 58 | .map(([t, files]) => `${t} declared in ${files.join(" AND ")}`); | |
| 59 | expect(dupes).toEqual([]); | |
| 60 | }); | |
| 61 | ||
| 62 | it("schema-extensions re-exports the org/team tables rather than redeclaring them", () => { | |
| 63 | const src = code(readFileSync("src/db/schema-extensions.ts", "utf8")); | |
| 64 | // Matched loosely on purpose: asserting an exact import string breaks the | |
| 65 | // moment the list wraps across lines, which says nothing about the bug. | |
| 66 | expect(src).toMatch(/from\s+["']\.\/schema["']/); | |
| 67 | for (const t of ["organizations", "teams", "team_members", "team_repos", "org_members", "branch_protection"]) { | |
| 68 | expect(src).not.toMatch(new RegExp(`pgTable\\(\\s*["'\`]${t}["'\`]`)); | |
| 69 | } | |
| 70 | }); | |
| 71 | }); | |
| 72 | ||
| 73 | describe("the organizations table matches its migration", () => { | |
| 74 | const schema = readFileSync("src/db/schema.ts", "utf8"); | |
| 75 | const table = schema.slice( | |
| 76 | schema.indexOf('export const organizations = pgTable("organizations"'), | |
| 77 | schema.indexOf('export const orgMembers') | |
| 78 | ); | |
| 79 | ||
| 80 | it("keeps the NOT NULL columns migration 0003 created", () => { | |
| 81 | expect(table).toContain('slug: text("slug").notNull()'); | |
| 82 | expect(table).toContain('createdById: uuid("created_by_id")'); | |
| 83 | expect(table).toContain(".notNull()"); | |
| 84 | }); | |
| 85 | ||
| 86 | it("carries the columns migration 0113 added", () => { | |
| 87 | // Added rather than dropped, so the create form's display name and | |
| 88 | // website are not silently discarded. | |
| 89 | for (const col of ["display_name", "website", "location", "is_verified"]) { | |
| 90 | expect(table).toContain(col); | |
| 91 | } | |
| 92 | }); | |
| 93 | ||
| 94 | it("has a migration that adds those columns", () => { | |
| 95 | const sql = readFileSync("drizzle/0113_orgs_reconcile.sql", "utf8"); | |
| 96 | for (const col of ["display_name", "website", "location", "is_verified"]) { | |
| 97 | expect(sql).toContain(`ADD COLUMN IF NOT EXISTS "${col}"`); | |
| 98 | } | |
| 99 | // Additive and idempotent — safe against a populated table and a re-run. | |
| 100 | expect(sql).not.toMatch(/DROP COLUMN|DROP TABLE/i); | |
| 101 | }); | |
| 102 | }); | |
| 103 | ||
| 104 | describe("POST /orgs/new supplies every required column", () => { | |
| 105 | const src = readFileSync("src/routes/orgs.tsx", "utf8"); | |
| 106 | const insert = src.slice( | |
| 107 | src.indexOf(".insert(organizations)"), | |
| 108 | src.indexOf(".insert(organizations)") + 700 | |
| 109 | ); | |
| 110 | ||
| 111 | it("provides slug and createdById", () => { | |
| 112 | expect(insert).toContain("slug:"); | |
| 113 | expect(insert).toContain("createdById:"); | |
| 114 | }); | |
| 115 | ||
| 116 | it("derives the slug from the validated name", () => { | |
| 117 | // `name` is already constrained to /^[a-zA-Z0-9._-]+$/ and collision- | |
| 118 | // checked against both users and orgs, so lowercasing it is a safe slug. | |
| 119 | expect(insert).toContain("slug: name.toLowerCase()"); | |
| 120 | }); | |
| 121 | }); | |
| 122 | ||
| 123 | describe("teams feature matches the database", () => { | |
| 124 | const schema = readFileSync("src/db/schema.ts", "utf8"); | |
| 125 | ||
| 126 | it("teams carries the permission column the UI renders", () => { | |
| 127 | // orgs.tsx renders `team.permission` and the create form collects it, but | |
| 128 | // the column existed only in the rival definition and was never migrated. | |
| 129 | // Anchored with a regex, not a literal newline — these files are CRLF. | |
| 130 | const t = schema.slice(schema.search(/pgTable\(\s*"teams"/), schema.indexOf("export const teamMembers")); | |
| 131 | expect(t).toContain('permission: text("permission")'); | |
| 132 | }); | |
| 133 | ||
| 134 | it("team_repos is declared in schema.ts so it gets a migration", () => { | |
| 135 | // It previously lived ONLY in schema-extensions, which drizzle.config.ts | |
| 136 | // does not include — so the table never existed, while orgs.tsx selected | |
| 137 | // and joined it to render a team's repo list. | |
| 138 | expect(schema).toMatch(/pgTable\(\s*"team_repos"/); | |
| 139 | }); | |
| 140 | ||
| 141 | it("migration 0114 creates team_repos and adds teams.permission", () => { | |
| 142 | const sql = readFileSync("drizzle/0114_teams_reconcile.sql", "utf8"); | |
| 143 | expect(sql).toContain('CREATE TABLE IF NOT EXISTS "team_repos"'); | |
| 144 | expect(sql).toContain('ALTER TABLE "teams" ADD COLUMN IF NOT EXISTS "permission"'); | |
| 145 | expect(sql).not.toMatch(/DROP COLUMN|DROP TABLE/i); | |
| 146 | }); | |
| 147 | ||
| 148 | it("team creation supplies the NOT NULL slug", () => { | |
| 149 | const orgs = readFileSync("src/routes/orgs.tsx", "utf8"); | |
| 150 | const insert = orgs.slice(orgs.indexOf(".insert(teams)"), orgs.indexOf(".insert(teams)") + 400); | |
| 151 | expect(insert).toContain("slug,"); | |
| 152 | }); | |
| 153 | }); |