Files
stack/packages/storage/src/migrate-tier.integration.test.ts
jason.woltje 15d849c166
Some checks failed
ci/woodpecker/push/ci Pipeline was successful
ci/woodpecker/push/publish Pipeline failed
test(storage): integration test for migrate-tier (FED-M1-08) + camelCase column fix (#477)
2026-04-20 01:40:02 +00:00

325 lines
11 KiB
TypeScript

/**
* 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<typeof postgres> | 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<typeof postgres>): Promise<void> {
// 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<Array<{ n: string }>>(
`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<Array<{ n: string }>>(
`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<Array<{ n: string }>>(
`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<Array<{ n: string }>>(
`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<Array<{ n: string }>>(
`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<Array<{ id: string; email: string; name: string }>>(
`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<Array<{ id: string; title: string; user_id: string }>>(
`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);
});