Skip to content

Commit 16db06f

Browse files
chitcommitclaude
andauthored
chore(tests): vitest globalSetup applies migrations against DATABASE_URL (#116)
Makes the integration suite hermetic against fresh Neon branches. Specs already gate on DATABASE_URL but assumed the branch was migrated. The repo's db:migrate script (drizzle-kit) isn't fully wired (no dbCredentials in drizzle.config.ts) and the migrations/ directory mixes two histories: drizzle-kit-generated migrations tracked in meta/_journal.json plus hand-rolled SQL with alternative + additive schema deltas. This setup script applies (1) journaled drizzle migrations in journal order and (2) post-consolidation additive hand-rolled migrations (0017_*, 0018_*) that the integration suite depends on. PostgreSQL "already exists" SQLSTATE codes are tolerated so partially-migrated parent branches converge cleanly. Skips entirely when DATABASE_URL is unset or SKIP_INTEGRATION=1. Before (fresh Neon branch): 4/4 spec files failed beforeAll with 'relation "cc_goals" does not exist'; 26/26 tests skipped. After (same fresh branch): 3/4 spec files fully pass, 1 file (workspace-studio-ingest) runs and surfaces 4 unrelated app-side failures (ON CONFLICT arbiter mismatch with the 0018 index — a pre-existing route bug, not a migration issue). 22/26 tests pass. Fixes the integration harness gap for: tests/daemon/leader.spec.ts tests/daemon/leader-session.spec.ts tests/meta/intent-lifecycle.spec.ts tests/routes/workspace-studio-ingest.spec.ts Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 300dbb9 commit 16db06f

2 files changed

Lines changed: 153 additions & 0 deletions

File tree

tests/setup/global-setup.ts

Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
/**
2+
* Vitest globalSetup — applies SQL migrations against DATABASE_URL before any
3+
* integration tests run, so specs hitting a fresh Neon branch don't fail
4+
* `beforeAll` with `relation "cc_*" does not exist`.
5+
*
6+
* The repo's migrations directory mixes two histories:
7+
* 1. drizzle-kit-generated migrations tracked in `migrations/meta/_journal.json`
8+
* — the canonical schema as deployed.
9+
* 2. hand-rolled SQL files (0001_command_*, 0002_command_*, ..., 0015_*) that
10+
* represent an earlier, alternative history of the same tables. These
11+
* conflict with the drizzle path (e.g., 0005_schema_alignment.sql
12+
* references the pre-consolidation `source_tx_id` column).
13+
* 3. Additive, post-consolidation hand-rolled files (0017+ onwards) that
14+
* patch the drizzle schema with idempotency / decay columns the
15+
* integration suite relies on.
16+
*
17+
* Strategy: apply (1) in journal order, then (3) in name order. We tolerate
18+
* "already exists" Postgres error codes so partially migrated branches
19+
* (e.g., parent branches where some journaled migrations were applied out of
20+
* band) converge cleanly. Everything else fails loudly.
21+
*
22+
* Skips entirely when DATABASE_URL is unset or SKIP_INTEGRATION=1.
23+
*/
24+
import { readFileSync, readdirSync } from 'node:fs';
25+
import { join, dirname } from 'node:path';
26+
import { fileURLToPath } from 'node:url';
27+
import { Pool } from '@neondatabase/serverless';
28+
29+
const __dirname = dirname(fileURLToPath(import.meta.url));
30+
const MIGRATIONS_DIR = join(__dirname, '..', '..', 'migrations');
31+
32+
// Post-consolidation, additive hand-rolled migrations that complement the
33+
// journaled drizzle schema (not part of the alternative-history set).
34+
const ADDITIVE_PREFIXES = ['0017_', '0018_'];
35+
36+
// Postgres SQLSTATE codes for "this object already exists" — safe to skip
37+
// when re-applying overlapping migration sets across branches.
38+
const ALREADY_EXISTS_CODES = new Set([
39+
'42P07', // duplicate_table
40+
'42710', // duplicate_object (constraint, type, trigger, etc.)
41+
'42701', // duplicate_column
42+
'42P06', // duplicate_schema
43+
'42723', // duplicate_function
44+
'42P16', // invalid_object_definition (e.g., duplicate index name)
45+
]);
46+
47+
/**
48+
* Split a SQL script on top-level `;` terminators, respecting `$$ ... $$`
49+
* dollar-quoted blocks so semicolons inside PL/pgSQL function bodies don't
50+
* prematurely terminate a statement.
51+
*/
52+
function splitOnSemicolons(sql: string): string[] {
53+
const out: string[] = [];
54+
let buf = '';
55+
let inDollar = false;
56+
for (let i = 0; i < sql.length; i++) {
57+
const ch = sql[i];
58+
if (ch === '$' && sql[i + 1] === '$') {
59+
inDollar = !inDollar;
60+
buf += '$$';
61+
i++;
62+
continue;
63+
}
64+
if (ch === ';' && !inDollar) {
65+
const trimmed = buf.trim();
66+
if (trimmed) out.push(trimmed);
67+
buf = '';
68+
continue;
69+
}
70+
buf += ch;
71+
}
72+
const tail = buf.trim();
73+
if (tail) out.push(tail);
74+
return out;
75+
}
76+
77+
async function applyFile(pool: Pool, file: string): Promise<void> {
78+
const path = join(MIGRATIONS_DIR, file);
79+
const body = readFileSync(path, 'utf8');
80+
81+
// drizzle-kit migrations use `--> statement-breakpoint`; hand-rolled files
82+
// use bare `;` terminators.
83+
const statements = body.includes('--> statement-breakpoint')
84+
? body.split('--> statement-breakpoint').map((s) => s.trim()).filter(Boolean)
85+
: splitOnSemicolons(body);
86+
87+
let applied = 0;
88+
let skipped = 0;
89+
for (const stmt of statements) {
90+
if (!stmt.replace(/--.*$/gm, '').trim()) continue;
91+
try {
92+
await pool.query(stmt);
93+
applied++;
94+
} catch (err) {
95+
const code = (err as { code?: string }).code;
96+
if (code && ALREADY_EXISTS_CODES.has(code)) {
97+
skipped++;
98+
continue;
99+
}
100+
console.error(`[vitest globalSetup] failed applying ${file}:`, err);
101+
throw err;
102+
}
103+
}
104+
console.log(
105+
`[vitest globalSetup] ${file}${applied} applied, ${skipped} skipped (already exists)`,
106+
);
107+
}
108+
109+
export default async function globalSetup(): Promise<void> {
110+
const databaseUrl = process.env.DATABASE_URL;
111+
const skip = process.env.SKIP_INTEGRATION === '1';
112+
113+
if (!databaseUrl || skip) {
114+
console.log(
115+
`[vitest globalSetup] Skipping migrations (DATABASE_URL=${databaseUrl ? 'set' : 'unset'}, SKIP_INTEGRATION=${process.env.SKIP_INTEGRATION ?? 'unset'})`,
116+
);
117+
return;
118+
}
119+
120+
const pool = new Pool({ connectionString: databaseUrl });
121+
122+
try {
123+
// 1. Apply journaled drizzle migrations in journal order.
124+
const journalPath = join(MIGRATIONS_DIR, 'meta', '_journal.json');
125+
const journal = JSON.parse(readFileSync(journalPath, 'utf8')) as {
126+
entries: Array<{ tag: string }>;
127+
};
128+
console.log(
129+
`[vitest globalSetup] Applying ${journal.entries.length} journaled migrations…`,
130+
);
131+
for (const entry of journal.entries) {
132+
await applyFile(pool, `${entry.tag}.sql`);
133+
}
134+
135+
// 2. Apply additive post-consolidation hand-rolled migrations.
136+
const additive = readdirSync(MIGRATIONS_DIR)
137+
.filter((f) => f.endsWith('.sql') && ADDITIVE_PREFIXES.some((p) => f.startsWith(p)))
138+
.sort();
139+
if (additive.length > 0) {
140+
console.log(
141+
`[vitest globalSetup] Applying ${additive.length} additive migration(s): ${additive.join(', ')}`,
142+
);
143+
for (const file of additive) {
144+
await applyFile(pool, file);
145+
}
146+
}
147+
148+
console.log('[vitest globalSetup] migrations applied.');
149+
} finally {
150+
await pool.end();
151+
}
152+
}

vitest.config.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ export default defineConfig({
99
testTimeout: 15000,
1010
pool: 'threads',
1111
maxWorkers: 1,
12+
globalSetup: ['./tests/setup/global-setup.ts'],
1213
},
1314
resolve: {
1415
alias: {

0 commit comments

Comments
 (0)