import { mkdtempSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { sql } from 'drizzle-orm'; import { afterEach, beforeEach, describe, expect, it } from 'vitest'; import { createPgliteDb } from './client-pglite.js'; import { runPgliteMigrations } from './migrate.js'; import type { DbHandle } from './client.js'; interface PgliteExec { exec(query: string): Promise; } describe('runPgliteMigrations', () => { let dataDir: string; let handle: DbHandle; beforeEach(() => { dataDir = mkdtempSync(join(tmpdir(), 'mosaic-db-migrate-test-')); handle = createPgliteDb(dataDir); }); afterEach(async () => { await handle.close(); rmSync(dataDir, { recursive: true, force: true }); }); it('creates the BetterAuth tables required by the gateway', async () => { await runPgliteMigrations(handle); const result = (await handle.db.execute(sql` SELECT table_name FROM information_schema.tables WHERE table_schema = 'public' ORDER BY table_name `)) as unknown as { rows: Array<{ table_name: string }> }; const tables = result.rows.map((r) => r.table_name); // Auth tables — required for sign-in / bootstrap to function. expect(tables).toContain('users'); expect(tables).toContain('sessions'); expect(tables).toContain('accounts'); expect(tables).toContain('verifications'); // Schema sanity check — admin token table consumed by mosaic gateway config. expect(tables).toContain('admin_tokens'); }); it('is idempotent — running twice does not error', async () => { await runPgliteMigrations(handle); await expect(runPgliteMigrations(handle)).resolves.toBeUndefined(); }); it('gives accounts an issuer column (#1395) — better-auth >=1.7 requires it', async () => { await runPgliteMigrations(handle); const result = (await handle.db.execute(sql` SELECT column_name, is_nullable, data_type FROM information_schema.columns WHERE table_name = 'accounts' AND column_name = 'issuer' `)) as unknown as { rows: Array<{ column_name: string; is_nullable: string; data_type: string }>; }; // Nullable by design: the 1.5.x line this repo's lockfile resolves to does // not write the field; 1.7+ populates it. One schema serves both. expect(result.rows).toHaveLength(1); expect(result.rows[0]?.is_nullable).toBe('YES'); expect(result.rows[0]?.data_type).toBe('text'); }); it('backfills ONLY credential rows with the synthetic issuer (#1395 upgrade path)', async () => { // Simulate an upgraded install: migrate through 0016 only, seed pre-issuer // rows (one credential, one oauth), then apply 0017 and discriminate. const client = (handle.db as unknown as { $client: PgliteExec }).$client; // Migrate to 0016 by replaying every ledger file except 0017 — the ledger // table gates re-application, so a plain replay of 0000..0016 is enough. const fs = await import('node:fs'); const path = await import('node:path'); const dir = path.join(import.meta.dirname, '..', 'drizzle'); const files = fs .readdirSync(dir) .filter((f) => /^\d{4}_.*\.sql$/.test(f) && f < '0017') .sort(); for (const f of files) { const raw = fs.readFileSync(path.join(dir, f), 'utf-8'); for (const stmt of raw.split('--> statement-breakpoint')) { const trimmed = stmt.trim(); if (trimmed) await client.exec(trimmed); } } await client.exec(` INSERT INTO users (id, name, email, email_verified, created_at, updated_at) VALUES ('u1', 'Legacy User', 'legacy@example.test', true, now(), now()); INSERT INTO accounts (id, account_id, provider_id, user_id, created_at, updated_at) VALUES ('a1', 'legacy@example.test', 'credential', 'u1', now(), now()), ('a2', 'oauth-provider-1', 'google', 'u1', now(), now()); `); // Apply 0017 (column + backfill). const sql0017 = fs.readFileSync(path.join(dir, '0017_accounts_issuer.sql'), 'utf-8'); for (const stmt of sql0017.split('--> statement-breakpoint')) { const trimmed = stmt.trim(); if (trimmed) await client.exec(trimmed); } const rows = (await handle.db.execute(sql` SELECT provider_id, issuer FROM accounts ORDER BY id `)) as unknown as { rows: Array<{ provider_id: string; issuer: string | null }> }; const byProvider = new Map(rows.rows.map((r) => [r.provider_id, r.issuer])); // Credential rows get better-auth's synthetic local issuer — the value // sign-in filters on (better-auth dist createLocalAccountIssuer). expect(byProvider.get('credential')).toBe('local:credential'); // OAuth rows are LEFT NULL: better-auth owns their issuer semantics going // forward (each provider's real issuer on its next flow). expect(byProvider.get('google')).toBeNull(); }); it('surfaces statement-level error context on failure and leaves no ledger row', async () => { // Pre-create a `users` table that conflicts with migration 0000's CREATE TABLE, // forcing it to fail without IF NOT EXISTS. const client = (handle.db as unknown as { $client: PgliteExec }).$client; await client.exec('CREATE TABLE users (sentinel text)'); await expect(runPgliteMigrations(handle)).rejects.toThrow( /migration hash=[a-f0-9]+ statement #\d+ failed/, ); // Ledger should be empty — partial application must not pretend to be complete. const ledger = (await handle.db.execute( sql`SELECT count(*)::int AS count FROM drizzle.__drizzle_migrations`, )) as unknown as { rows: Array<{ count: number }> }; expect(ledger.rows[0]?.count).toBe(0); }); });