Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commitfc2c3bf

feat(gate): fail the build when Drizzle drifts from the migrations

feat(gate): fail the build when Drizzle drifts from the migrations

The class that produced five silent runtime failures in this session: org
creation, team creation, notify.ts inserts, the notification read-state
split, and `team_repos` — a table declared in code, joined by orgs.tsx, that
had no migration and therefore did not exist.

The database is built solely by applying drizzle/*.sql in filename order
(drizzle.config.ts names only schema.ts; deploys run db:migrate, never
db:push), so the migrations ARE the schema of record. The gate replays their
DDL — CREATE TABLE, ADD COLUMN, DROP COLUMN, RENAME COLUMN — and compares the
result against every column Drizzle declares.

Enforced in one direction only. A column in code but not in the database is a
runtime error waiting to happen. The reverse is harmless and common after a
cleanup, so it is not failed.

Found by the gate on its first run: `status_checks`, declared in
schema-extensions.ts with no migration anywhere. Unlike team_repos this one
has ZERO consumers — nothing imports it — and the real table for that concept
is `commit_statuses` (migration 0033), used by routes/commit-statuses.ts. So
the call here is the opposite of team_repos: delete the dead declaration
rather than create an unused table.

The gate is verified rather than assumed: injecting a column with no
migration makes it fail with the exact column name, and removing it makes it
pass again. It also asserts the parsers still see >150 tables, because a
broken scan would otherwise make every assertion vacuously true — which the
first draft of this analyzer actually did, brace-matching its way past a
nested `{ onDelete }` and attributing several hundred columns from other
tables to branch_protection.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ccantynz-alt committed on July 28, 2026Parent: 6cbc9cb
2 files changed+10322fc2c3bf1155b9bc3d5b3e79e3b9eee20257c01ab
2 changed files+103−22
Modifiedsrc/__tests__/schema-single-source.test.ts+103−0View fileUnifiedSplit
151151 expect(insert).toContain("slug,");
152152 });
153153});
154
155// ─────────────────────────────────────────────────────────────────────────
156// Schema / migration drift
157// ─────────────────────────────────────────────────────────────────────────
158
159/**
160 * Every column Drizzle declares must exist in the migrated database.
161 *
162 * This is the class that produced five silent runtime failures: org creation,
163 * team creation, notify.ts inserts, the notification read-state split, and
164 * `team_repos` — a table declared in code that had no migration at all, while
165 * routes/orgs.tsx selected and joined it.
166 *
167 * The database is built solely by applying drizzle/*.sql in filename order
168 * (drizzle.config.ts names only schema.ts; deploys run db:migrate, never
169 * db:push), so the migrations ARE the schema of record. Replaying their DDL
170 * gives the real shape to compare against.
171 *
172 * Only one direction is enforced: a column in code but not in the database is
173 * a runtime error waiting to happen. The reverse — a column in the database
174 * that Drizzle no longer declares — is harmless and common after a cleanup, so
175 * it is reported by the scratch analyzer but not failed here.
176 */
177function declaredTables(): Map<string, Set<string>> {
178 const out = new Map<string, Set<string>>();
179 for (const f of schemaFiles()) {
180 const src = code(readFileSync(f, "utf8"));
181 // Split on pgTable( boundaries. Brace-matching is not safe here: a nested
182 // `{ onDelete: "cascade" }` or a template literal makes a depth counter
183 // run away and swallow the following tables' columns.
184 for (const part of src.split(/pgTable\(/).slice(1)) {
185 const name = part.match(/^\s*["'`]([a-z0-9_]+)["'`]/);
186 if (!name) continue;
187 const end = part.search(/\n\);/);
188 const body = end > -1 ? part.slice(0, end) : part;
189 if (!out.has(name[1])) out.set(name[1], new Set());
190 for (const c of body.matchAll(/:\s*\w+\(\s*["'`]([a-z0-9_]+)["'`]/g)) {
191 out.get(name[1])!.add(c[1]);
192 }
193 }
194 }
195 return out;
196}
197
198function migratedTables(): Map<string, Set<string>> {
199 const dir = "drizzle";
200 const out = new Map<string, Set<string>>();
201 for (const f of readdirSync(dir).filter((x) => x.endsWith(".sql")).sort()) {
202 const sql = readFileSync(join(dir, f), "utf8").replace(/--[^\n]*/g, "");
203
204 for (const m of sql.matchAll(
205 /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?"?([a-z0-9_]+)"?\s*\(([\s\S]*?)\n\s*\);/gi
206 )) {
207 if (!out.has(m[1])) out.set(m[1], new Set());
208 const set = out.get(m[1])!;
209 for (const line of m[2].split("\n")) {
210 const t = line.trim();
211 if (!t || /^(CONSTRAINT|PRIMARY\s+KEY|FOREIGN\s+KEY|UNIQUE|CHECK)\b/i.test(t)) continue;
212 const c = t.match(/^"?([a-z0-9_]+)"?\s+/);
213 if (c) set.add(c[1]);
214 }
215 }
216
217 for (const m of sql.matchAll(/ALTER\s+TABLE\s+"?([a-z0-9_]+)"?\s+([\s\S]*?);/gi)) {
218 if (!out.has(m[1])) out.set(m[1], new Set());
219 const set = out.get(m[1])!;
220 for (const a of m[2].matchAll(/ADD\s+COLUMN\s+(?:IF\s+NOT\s+EXISTS\s+)?"?([a-z0-9_]+)"?/gi)) set.add(a[1]);
221 for (const d of m[2].matchAll(/DROP\s+COLUMN\s+(?:IF\s+EXISTS\s+)?"?([a-z0-9_]+)"?/gi)) set.delete(d[1]);
222 for (const r of m[2].matchAll(/RENAME\s+COLUMN\s+"?([a-z0-9_]+)"?\s+TO\s+"?([a-z0-9_]+)"?/gi)) {
223 set.delete(r[1]); set.add(r[2]);
224 }
225 }
226 }
227 return out;
228}
229
230describe("schema matches the migrations", () => {
231 const declared = declaredTables();
232 const migrated = migratedTables();
233
234 it("parses a plausible number of tables (guards against the scan breaking)", () => {
235 // A broken parser would make the assertions below vacuously pass.
236 expect(declared.size).toBeGreaterThan(150);
237 expect(migrated.size).toBeGreaterThan(150);
238 });
239
240 it("every declared table has a migration that creates it", () => {
241 // `team_repos` failed this: declared, joined by orgs.tsx, never created.
242 const missing = [...declared.keys()].filter((t) => !migrated.has(t)).sort();
243 expect(missing).toEqual([]);
244 });
245
246 it("every declared column exists in the migrated schema", () => {
247 const gaps: string[] = [];
248 for (const [table, cols] of [...declared].sort()) {
249 const have = migrated.get(table);
250 if (!have) continue; // covered by the previous assertion
251 const missing = [...cols].filter((c) => !have.has(c)).sort();
252 if (missing.length) gaps.push(`${table}: ${missing.join(", ")}`);
253 }
254 expect(gaps).toEqual([]);
255 });
256});
Modifiedsrc/db/schema-extensions.ts+0−22View fileUnifiedSplit
2424
2525// ─── Status Checks (CI Integration) ────────────────────────────────────────
2626
27export const statusChecks = pgTable(
28 "status_checks",
29 {
30 id: uuid("id").primaryKey().defaultRandom(),
31 repositoryId: uuid("repository_id")
32 .notNull()
33 .references(() => repositories.id, { onDelete: "cascade" }),
34 commitSha: text("commit_sha").notNull(),
35 context: text("context").notNull(), // e.g. "ci/build", "gatetest/scan"
36 state: text("state").notNull().default("pending"), // pending, success, failure, error
37 description: text("description"),
38 targetUrl: text("target_url"), // link to CI build
39 createdBy: text("created_by"), // token name or integration name
40 createdAt: timestamp("created_at").defaultNow().notNull(),
41 updatedAt: timestamp("updated_at").defaultNow().notNull(),
42 },
43 (table) => [
44 index("status_checks_repo_sha").on(table.repositoryId, table.commitSha),
45 index("status_checks_repo_context").on(table.repositoryId, table.context),
46 ]
47);
4827
4928// ─── Notifications ──────────────────────────────────────────────────────────
5029
9170// ─── Type Exports ───────────────────────────────────────────────────────────
9271
9372export type BranchProtectionRule = typeof branchProtection.$inferSelect;
94export type StatusCheck = typeof statusChecks.$inferSelect;
9573export type Notification = typeof notifications.$inferSelect;
9674export type Organization = typeof organizations.$inferSelect;
9775export type OrgMember = typeof orgMembers.$inferSelect;
9876