Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.

schema-single-source.test.tsBlame153 lines · 1 contributor
45ac593ccantynz-alt1/**
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
21import { describe, expect, it } from "bun:test";
22import { readFileSync, readdirSync } from "fs";
23import { join } from "path";
24
25const 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 */
33function code(src: string): string {
34 return src
35 .replace(/\/\*[\s\S]*?\*\//g, "")
36 .replace(/^\s*\/\/.*$/gm, "");
37}
38
39function 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
45describe("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
73describe("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
104describe("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
123describe("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});