325 lines
11 KiB
TypeScript
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);
|
|
});
|