Spaces:
Paused
Paused
| import { createHash } from "node:crypto"; | |
| import fs from "node:fs"; | |
| import { afterEach, describe, expect, it } from "vitest"; | |
| import postgres from "postgres"; | |
| import { | |
| applyPendingMigrations, | |
| inspectMigrations, | |
| } from "./client.js"; | |
| import { | |
| getEmbeddedPostgresTestSupport, | |
| startEmbeddedPostgresTestDatabase, | |
| } from "./test-embedded-postgres.js"; | |
| const cleanups: Array<() => Promise<void>> = []; | |
| const embeddedPostgresSupport = await getEmbeddedPostgresTestSupport(); | |
| const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip; | |
| async function createTempDatabase(): Promise<string> { | |
| const db = await startEmbeddedPostgresTestDatabase("paperclip-db-client-"); | |
| cleanups.push(db.cleanup); | |
| return db.connectionString; | |
| } | |
| async function migrationHash(migrationFile: string): Promise<string> { | |
| const content = await fs.promises.readFile( | |
| new URL(`./migrations/${migrationFile}`, import.meta.url), | |
| "utf8", | |
| ); | |
| return createHash("sha256").update(content).digest("hex"); | |
| } | |
| afterEach(async () => { | |
| while (cleanups.length > 0) { | |
| const cleanup = cleanups.pop(); | |
| await cleanup?.(); | |
| } | |
| }); | |
| if (!embeddedPostgresSupport.supported) { | |
| console.warn( | |
| `Skipping embedded Postgres migration tests on this host: ${embeddedPostgresSupport.reason ?? "unsupported environment"}`, | |
| ); | |
| } | |
| describeEmbeddedPostgres("applyPendingMigrations", () => { | |
| it( | |
| "applies an inserted earlier migration without replaying later legacy migrations", | |
| async () => { | |
| const connectionString = await createTempDatabase(); | |
| await applyPendingMigrations(connectionString); | |
| const sql = postgres(connectionString, { max: 1, onnotice: () => {} }); | |
| try { | |
| const richMagnetoHash = await migrationHash("0030_rich_magneto.sql"); | |
| await sql.unsafe( | |
| `DELETE FROM "drizzle"."__drizzle_migrations" WHERE hash = '${richMagnetoHash}'`, | |
| ); | |
| await sql.unsafe(`DROP TABLE "company_logos"`); | |
| } finally { | |
| await sql.end(); | |
| } | |
| const pendingState = await inspectMigrations(connectionString); | |
| expect(pendingState).toMatchObject({ | |
| status: "needsMigrations", | |
| pendingMigrations: ["0030_rich_magneto.sql"], | |
| reason: "pending-migrations", | |
| }); | |
| await applyPendingMigrations(connectionString); | |
| const finalState = await inspectMigrations(connectionString); | |
| expect(finalState.status).toBe("upToDate"); | |
| const verifySql = postgres(connectionString, { max: 1, onnotice: () => {} }); | |
| try { | |
| const rows = await verifySql.unsafe<{ table_name: string }[]>( | |
| ` | |
| SELECT table_name | |
| FROM information_schema.tables | |
| WHERE table_schema = 'public' | |
| AND table_name IN ('company_logos', 'execution_workspaces') | |
| ORDER BY table_name | |
| `, | |
| ); | |
| expect(rows.map((row) => row.table_name)).toEqual([ | |
| "company_logos", | |
| "execution_workspaces", | |
| ]); | |
| } finally { | |
| await verifySql.end(); | |
| } | |
| }, | |
| 20_000, | |
| ); | |
| it( | |
| "replays migration 0044 safely when its schema changes already exist", | |
| async () => { | |
| const connectionString = await createTempDatabase(); | |
| await applyPendingMigrations(connectionString); | |
| const sql = postgres(connectionString, { max: 1, onnotice: () => {} }); | |
| try { | |
| const illegalToadHash = await migrationHash("0044_illegal_toad.sql"); | |
| await sql.unsafe( | |
| `DELETE FROM "drizzle"."__drizzle_migrations" WHERE hash = '${illegalToadHash}'`, | |
| ); | |
| const columns = await sql.unsafe<{ column_name: string }[]>( | |
| ` | |
| SELECT column_name | |
| FROM information_schema.columns | |
| WHERE table_schema = 'public' | |
| AND table_name = 'instance_settings' | |
| AND column_name = 'general' | |
| `, | |
| ); | |
| expect(columns).toHaveLength(1); | |
| } finally { | |
| await sql.end(); | |
| } | |
| const pendingState = await inspectMigrations(connectionString); | |
| expect(pendingState).toMatchObject({ | |
| status: "needsMigrations", | |
| pendingMigrations: ["0044_illegal_toad.sql"], | |
| reason: "pending-migrations", | |
| }); | |
| await applyPendingMigrations(connectionString); | |
| const finalState = await inspectMigrations(connectionString); | |
| expect(finalState.status).toBe("upToDate"); | |
| }, | |
| 20_000, | |
| ); | |
| it( | |
| "enforces a unique board_api_keys.key_hash after migration 0044", | |
| async () => { | |
| const connectionString = await createTempDatabase(); | |
| await applyPendingMigrations(connectionString); | |
| const sql = postgres(connectionString, { max: 1, onnotice: () => {} }); | |
| try { | |
| await sql.unsafe(` | |
| INSERT INTO "user" ("id", "name", "email", "email_verified", "created_at", "updated_at") | |
| VALUES ('user-1', 'User One', 'user@example.com', true, now(), now()) | |
| `); | |
| await sql.unsafe(` | |
| INSERT INTO "board_api_keys" ("id", "user_id", "name", "key_hash", "created_at") | |
| VALUES ('00000000-0000-0000-0000-000000000001', 'user-1', 'Key One', 'dup-hash', now()) | |
| `); | |
| await expect( | |
| sql.unsafe(` | |
| INSERT INTO "board_api_keys" ("id", "user_id", "name", "key_hash", "created_at") | |
| VALUES ('00000000-0000-0000-0000-000000000002', 'user-1', 'Key Two', 'dup-hash', now()) | |
| `), | |
| ).rejects.toThrow(); | |
| } finally { | |
| await sql.end(); | |
| } | |
| }, | |
| 20_000, | |
| ); | |
| it( | |
| "replays migration 0046 safely when document revision columns already exist", | |
| async () => { | |
| const connectionString = await createTempDatabase(); | |
| await applyPendingMigrations(connectionString); | |
| const sql = postgres(connectionString, { max: 1, onnotice: () => {} }); | |
| try { | |
| const smoothSentinelsHash = await migrationHash("0046_smooth_sentinels.sql"); | |
| await sql.unsafe( | |
| `DELETE FROM "drizzle"."__drizzle_migrations" WHERE hash = '${smoothSentinelsHash}'`, | |
| ); | |
| const columns = await sql.unsafe<{ column_name: string; is_nullable: string; column_default: string | null }[]>( | |
| ` | |
| SELECT column_name, is_nullable, column_default | |
| FROM information_schema.columns | |
| WHERE table_schema = 'public' | |
| AND table_name = 'document_revisions' | |
| AND column_name IN ('title', 'format') | |
| ORDER BY column_name | |
| `, | |
| ); | |
| expect(columns).toHaveLength(2); | |
| } finally { | |
| await sql.end(); | |
| } | |
| const pendingState = await inspectMigrations(connectionString); | |
| expect(pendingState).toMatchObject({ | |
| status: "needsMigrations", | |
| pendingMigrations: ["0046_smooth_sentinels.sql"], | |
| reason: "pending-migrations", | |
| }); | |
| await applyPendingMigrations(connectionString); | |
| const finalState = await inspectMigrations(connectionString); | |
| expect(finalState.status).toBe("upToDate"); | |
| const verifySql = postgres(connectionString, { max: 1, onnotice: () => {} }); | |
| try { | |
| const columns = await verifySql.unsafe<{ column_name: string; is_nullable: string; column_default: string | null }[]>( | |
| ` | |
| SELECT column_name, is_nullable, column_default | |
| FROM information_schema.columns | |
| WHERE table_schema = 'public' | |
| AND table_name = 'document_revisions' | |
| AND column_name IN ('title', 'format') | |
| ORDER BY column_name | |
| `, | |
| ); | |
| expect(columns).toEqual([ | |
| expect.objectContaining({ | |
| column_name: "format", | |
| is_nullable: "NO", | |
| }), | |
| expect.objectContaining({ | |
| column_name: "title", | |
| is_nullable: "YES", | |
| }), | |
| ]); | |
| expect(columns[0]?.column_default).toContain("'markdown'"); | |
| } finally { | |
| await verifySql.end(); | |
| } | |
| }, | |
| 20_000, | |
| ); | |
| it( | |
| "replays migration 0047 safely when feedback tables and run columns already exist", | |
| async () => { | |
| const connectionString = await createTempDatabase(); | |
| await applyPendingMigrations(connectionString); | |
| const sql = postgres(connectionString, { max: 1, onnotice: () => {} }); | |
| try { | |
| const overjoyedGrootHash = await migrationHash("0047_overjoyed_groot.sql"); | |
| await sql.unsafe( | |
| `DELETE FROM "drizzle"."__drizzle_migrations" WHERE hash = '${overjoyedGrootHash}'`, | |
| ); | |
| const tables = await sql.unsafe<{ table_name: string }[]>( | |
| ` | |
| SELECT table_name | |
| FROM information_schema.tables | |
| WHERE table_schema = 'public' | |
| AND table_name IN ('feedback_exports', 'feedback_votes') | |
| ORDER BY table_name | |
| `, | |
| ); | |
| expect(tables.map((row) => row.table_name)).toEqual([ | |
| "feedback_exports", | |
| "feedback_votes", | |
| ]); | |
| const columns = await sql.unsafe<{ table_name: string; column_name: string }[]>( | |
| ` | |
| SELECT table_name, column_name | |
| FROM information_schema.columns | |
| WHERE table_schema = 'public' | |
| AND ( | |
| (table_name = 'companies' AND column_name IN ( | |
| 'feedback_data_sharing_enabled', | |
| 'feedback_data_sharing_consent_at', | |
| 'feedback_data_sharing_consent_by_user_id', | |
| 'feedback_data_sharing_terms_version' | |
| )) | |
| OR (table_name = 'document_revisions' AND column_name = 'created_by_run_id') | |
| OR (table_name = 'issue_comments' AND column_name = 'created_by_run_id') | |
| ) | |
| ORDER BY table_name, column_name | |
| `, | |
| ); | |
| expect(columns).toHaveLength(6); | |
| } finally { | |
| await sql.end(); | |
| } | |
| const pendingState = await inspectMigrations(connectionString); | |
| expect(pendingState).toMatchObject({ | |
| status: "needsMigrations", | |
| pendingMigrations: ["0047_overjoyed_groot.sql"], | |
| reason: "pending-migrations", | |
| }); | |
| await applyPendingMigrations(connectionString); | |
| const finalState = await inspectMigrations(connectionString); | |
| expect(finalState.status).toBe("upToDate"); | |
| const verifySql = postgres(connectionString, { max: 1, onnotice: () => {} }); | |
| try { | |
| const constraints = await verifySql.unsafe<{ conname: string }[]>( | |
| ` | |
| SELECT conname | |
| FROM pg_constraint | |
| WHERE conname IN ( | |
| 'feedback_exports_company_id_companies_id_fk', | |
| 'feedback_exports_feedback_vote_id_feedback_votes_id_fk', | |
| 'feedback_exports_issue_id_issues_id_fk', | |
| 'feedback_votes_company_id_companies_id_fk', | |
| 'feedback_votes_issue_id_issues_id_fk' | |
| ) | |
| ORDER BY conname | |
| `, | |
| ); | |
| expect(constraints.map((row) => row.conname)).toEqual([ | |
| "feedback_exports_company_id_companies_id_fk", | |
| "feedback_exports_feedback_vote_id_feedback_votes_id_fk", | |
| "feedback_exports_issue_id_issues_id_fk", | |
| "feedback_votes_company_id_companies_id_fk", | |
| "feedback_votes_issue_id_issues_id_fk", | |
| ]); | |
| } finally { | |
| await verifySql.end(); | |
| } | |
| }, | |
| 20_000, | |
| ); | |
| it( | |
| "replays migration 0048 safely when routines.variables already exists", | |
| async () => { | |
| const connectionString = await createTempDatabase(); | |
| await applyPendingMigrations(connectionString); | |
| const sql = postgres(connectionString, { max: 1, onnotice: () => {} }); | |
| try { | |
| const flashyMarrowHash = await migrationHash("0048_flashy_marrow.sql"); | |
| await sql.unsafe( | |
| `DELETE FROM "drizzle"."__drizzle_migrations" WHERE hash = '${flashyMarrowHash}'`, | |
| ); | |
| const columns = await sql.unsafe<{ column_name: string }[]>( | |
| ` | |
| SELECT column_name | |
| FROM information_schema.columns | |
| WHERE table_schema = 'public' | |
| AND table_name = 'routines' | |
| AND column_name = 'variables' | |
| `, | |
| ); | |
| expect(columns).toHaveLength(1); | |
| } finally { | |
| await sql.end(); | |
| } | |
| const pendingState = await inspectMigrations(connectionString); | |
| expect(pendingState).toMatchObject({ | |
| status: "needsMigrations", | |
| pendingMigrations: ["0048_flashy_marrow.sql"], | |
| reason: "pending-migrations", | |
| }); | |
| await applyPendingMigrations(connectionString); | |
| const finalState = await inspectMigrations(connectionString); | |
| expect(finalState.status).toBe("upToDate"); | |
| const verifySql = postgres(connectionString, { max: 1, onnotice: () => {} }); | |
| try { | |
| const columns = await verifySql.unsafe<{ column_name: string; is_nullable: string; data_type: string }[]>( | |
| ` | |
| SELECT column_name, is_nullable, data_type | |
| FROM information_schema.columns | |
| WHERE table_schema = 'public' | |
| AND table_name = 'routines' | |
| AND column_name = 'variables' | |
| `, | |
| ); | |
| expect(columns).toEqual([ | |
| expect.objectContaining({ | |
| column_name: "variables", | |
| is_nullable: "NO", | |
| data_type: "jsonb", | |
| }), | |
| ]); | |
| } finally { | |
| await verifySql.end(); | |
| } | |
| }, | |
| 20_000, | |
| ); | |
| }); | |