/** * FED-M1-08 — Integration test: PGlite → federated Postgres+pgvector migration. * * Prereq: docker compose -f docker-compose.federated.yml --profile federated up -d * Run: FEDERATED_INTEGRATION=1 pnpm --filter @mosaicstack/storage test src/migrate-tier.integration.test.ts * * Skipped when FEDERATED_INTEGRATION !== '1'. * * Strategy: users.id (TEXT PK) uses the recognisable prefix `fed-m1-08-` for * easy cleanup. UUID-PKed tables (teams, conversations, messages, team_members) * use deterministic valid UUIDs in the `f0000xxx-…` namespace. Cleanup is * explicit DELETE by id — no full-table truncation. */ import fs from 'node:fs/promises'; import os from 'node:os'; import path from 'node:path'; import { users, teams, teamMembers, conversations, messages } from '@mosaicstack/db'; import { createPgliteDbWithVector, runPgliteMigrations } from './test-utils/pglite-with-vector.js'; import postgres from 'postgres'; import { afterAll, describe, expect, it } from 'vitest'; import { DrizzleMigrationSource, PostgresMigrationTarget, runMigrateTier } from './migrate-tier.js'; /* ------------------------------------------------------------------ */ /* Constants */ /* ------------------------------------------------------------------ */ const run = process.env['FEDERATED_INTEGRATION'] === '1'; const FEDERATED_PG_URL = 'postgresql://mosaic:mosaic@localhost:5433/mosaic'; /** * Deterministic IDs for the test's seed data. * * users.id is TEXT (any string) — we use a recognisable prefix for easy cleanup. * All other tables use UUID primary keys — must be valid UUID v4 format. * The 4th segment starts with '4' (version 4) and 5th starts with '8' (variant). */ const IDS = { // text PK — can be any string user1: 'fed-m1-08-user-1', user2: 'fed-m1-08-user-2', // UUID PKs — must be valid UUID format team1: 'f0000001-0000-4000-8000-000000000001', teamMember1: 'f0000002-0000-4000-8000-000000000001', teamMember2: 'f0000002-0000-4000-8000-000000000002', conv1: 'f0000003-0000-4000-8000-000000000001', conv2: 'f0000003-0000-4000-8000-000000000002', msg1: 'f0000004-0000-4000-8000-000000000001', msg2: 'f0000004-0000-4000-8000-000000000002', msg3: 'f0000004-0000-4000-8000-000000000003', msg4: 'f0000004-0000-4000-8000-000000000004', msg5: 'f0000004-0000-4000-8000-000000000005', } as const; /* ------------------------------------------------------------------ */ /* Shared handles for afterAll cleanup */ /* ------------------------------------------------------------------ */ let targetSql: ReturnType | undefined; let pgliteDataDir: string | undefined; afterAll(async () => { if (targetSql) { await cleanTarget(targetSql).catch(() => {}); await targetSql.end({ timeout: 5 }).catch(() => {}); } if (pgliteDataDir) { await fs.rm(pgliteDataDir, { recursive: true, force: true }).catch(() => {}); } }); /* ------------------------------------------------------------------ */ /* Helpers */ /* ------------------------------------------------------------------ */ /** Delete all test-owned rows from target in safe FK order. */ async function cleanTarget(sql: ReturnType): Promise { // Reverse FK order: messages → conversations → team_members → teams → users await sql.unsafe(`DELETE FROM messages WHERE id = ANY($1)`, [ [IDS.msg1, IDS.msg2, IDS.msg3, IDS.msg4, IDS.msg5], ] as never[]); await sql.unsafe(`DELETE FROM conversations WHERE id = ANY($1)`, [ [IDS.conv1, IDS.conv2], ] as never[]); await sql.unsafe(`DELETE FROM team_members WHERE id = ANY($1)`, [ [IDS.teamMember1, IDS.teamMember2], ] as never[]); await sql.unsafe(`DELETE FROM teams WHERE id = $1`, [IDS.team1] as never[]); await sql.unsafe(`DELETE FROM users WHERE id = ANY($1)`, [[IDS.user1, IDS.user2]] as never[]); } /* ------------------------------------------------------------------ */ /* Test suite */ /* ------------------------------------------------------------------ */ describe.skipIf(!run)('migrate-tier — PGlite → federated PG', () => { it('seeds PGlite, runs migrate-tier, asserts row counts and sample rows on target', async () => { /* ---- 1. Create a temp PGlite db ---------------------------------- */ pgliteDataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'fed-m1-08-')); const handle = createPgliteDbWithVector(pgliteDataDir); // Run Drizzle migrations against PGlite. // eslint-disable-next-line @typescript-eslint/no-explicit-any await runPgliteMigrations(handle.db as any); /* ---- 2. Seed representative data --------------------------------- */ const now = new Date(); const db = handle.db; // users (2 rows) // eslint-disable-next-line @typescript-eslint/no-explicit-any await (db as any).insert(users).values([ { id: IDS.user1, name: 'Fed Test User One', email: 'fed-m1-08-user1@test.invalid', emailVerified: false, role: 'member', createdAt: now, updatedAt: now, }, { id: IDS.user2, name: 'Fed Test User Two', email: 'fed-m1-08-user2@test.invalid', emailVerified: false, role: 'member', createdAt: now, updatedAt: now, }, ]); // teams (1 row) // eslint-disable-next-line @typescript-eslint/no-explicit-any await (db as any).insert(teams).values([ { id: IDS.team1, name: 'Fed M1-08 Team', slug: 'fed-m1-08-team', ownerId: IDS.user1, managerId: IDS.user1, createdAt: now, updatedAt: now, }, ]); // team_members (2 rows linking both users to the team) // eslint-disable-next-line @typescript-eslint/no-explicit-any await (db as any).insert(teamMembers).values([ { id: IDS.teamMember1, teamId: IDS.team1, userId: IDS.user1, role: 'manager', joinedAt: now, }, { id: IDS.teamMember2, teamId: IDS.team1, userId: IDS.user2, role: 'member', joinedAt: now, }, ]); // conversations (2 rows) // eslint-disable-next-line @typescript-eslint/no-explicit-any await (db as any).insert(conversations).values([ { id: IDS.conv1, title: 'Fed M1-08 Conversation Alpha', userId: IDS.user1, archived: false, createdAt: now, updatedAt: now, }, { id: IDS.conv2, title: 'Fed M1-08 Conversation Beta', userId: IDS.user2, archived: false, createdAt: now, updatedAt: now, }, ]); // messages (5 rows across both conversations) // eslint-disable-next-line @typescript-eslint/no-explicit-any await (db as any).insert(messages).values([ { id: IDS.msg1, conversationId: IDS.conv1, role: 'user', content: 'Hello from conv1 msg1', createdAt: now, }, { id: IDS.msg2, conversationId: IDS.conv1, role: 'assistant', content: 'Reply in conv1 msg2', createdAt: now, }, { id: IDS.msg3, conversationId: IDS.conv1, role: 'user', content: 'Follow-up in conv1 msg3', createdAt: now, }, { id: IDS.msg4, conversationId: IDS.conv2, role: 'user', content: 'Hello from conv2 msg4', createdAt: now, }, { id: IDS.msg5, conversationId: IDS.conv2, role: 'assistant', content: 'Reply in conv2 msg5', createdAt: now, }, ]); /* ---- 3. Pre-clean the target so the test is repeatable ----------- */ targetSql = postgres(FEDERATED_PG_URL, { max: 3, connect_timeout: 10, idle_timeout: 30, }); await cleanTarget(targetSql); /* ---- 4. Build source / target adapters and run migration --------- */ const source = new DrizzleMigrationSource(db, /* sourceHasVector= */ false); const target = new PostgresMigrationTarget(FEDERATED_PG_URL); try { await runMigrateTier( source, target, { targetUrl: FEDERATED_PG_URL, dryRun: false, allowNonEmpty: true, batchSize: 500, onProgress: (_msg) => { // Uncomment for debugging: console.log(_msg); }, }, /* sourceHasVector= */ false, ); } finally { await target.close(); } /* ---- 5. Assert: row counts in target match seed ------------------ */ const countUsers = await targetSql.unsafe>( `SELECT COUNT(*)::text AS n FROM users WHERE id = ANY($1)`, [[IDS.user1, IDS.user2]] as never[], ); expect(Number(countUsers[0]?.n)).toBe(2); const countTeams = await targetSql.unsafe>( `SELECT COUNT(*)::text AS n FROM teams WHERE id = $1`, [IDS.team1] as never[], ); expect(Number(countTeams[0]?.n)).toBe(1); const countTeamMembers = await targetSql.unsafe>( `SELECT COUNT(*)::text AS n FROM team_members WHERE id = ANY($1)`, [[IDS.teamMember1, IDS.teamMember2]] as never[], ); expect(Number(countTeamMembers[0]?.n)).toBe(2); const countConvs = await targetSql.unsafe>( `SELECT COUNT(*)::text AS n FROM conversations WHERE id = ANY($1)`, [[IDS.conv1, IDS.conv2]] as never[], ); expect(Number(countConvs[0]?.n)).toBe(2); const countMsgs = await targetSql.unsafe>( `SELECT COUNT(*)::text AS n FROM messages WHERE id = ANY($1)`, [[IDS.msg1, IDS.msg2, IDS.msg3, IDS.msg4, IDS.msg5]] as never[], ); expect(Number(countMsgs[0]?.n)).toBe(5); /* ---- 6. Assert: sample row field values --------------------------- */ // User 1: check email and name const userRows = await targetSql.unsafe>( `SELECT id, email, name FROM users WHERE id = $1`, [IDS.user1] as never[], ); expect(userRows[0]?.email).toBe('fed-m1-08-user1@test.invalid'); expect(userRows[0]?.name).toBe('Fed Test User One'); // Conversation 1: check title and user_id const convRows = await targetSql.unsafe>( `SELECT id, title, user_id FROM conversations WHERE id = $1`, [IDS.conv1] as never[], ); expect(convRows[0]?.title).toBe('Fed M1-08 Conversation Alpha'); expect(convRows[0]?.user_id).toBe(IDS.user1); /* ---- 7. Cleanup: delete test rows from target -------------------- */ await cleanTarget(targetSql); // Close PGlite await handle.close(); }, 60_000); });