133 lines
6.7 KiB
TypeScript
133 lines
6.7 KiB
TypeScript
import { createHash } from "node:crypto";
|
||
import { readFile } from "node:fs/promises";
|
||
import { describe, expect, it } from "vitest";
|
||
import { eq, sql } from "drizzle-orm";
|
||
import {
|
||
applyPendingMigrations,
|
||
createDb,
|
||
heartbeatRunEvents,
|
||
heartbeatRuns,
|
||
} from "@paperclipai/db";
|
||
import { startEmbeddedPostgresTestDatabase } from "./helpers/embedded-postgres.js";
|
||
|
||
describe("P6-18 / MIG-01..04 native finalization migration", () => {
|
||
it("repairs only later duplicates and preserves legacy event bytes and cursors", async () => {
|
||
const temporary = await startEmbeddedPostgresTestDatabase("paperclip-native-migration-");
|
||
const migration = await readFile(
|
||
new URL("../../../packages/db/src/migrations/0227_modern_pandemic.sql", import.meta.url),
|
||
"utf8",
|
||
);
|
||
const migrationHash = createHash("sha256").update(migration).digest("hex");
|
||
const sequenceMigration = await readFile(
|
||
new URL("../../../packages/db/src/migrations/0235_heartbeat_run_event_sequence_uniqueness.sql", import.meta.url),
|
||
"utf8",
|
||
);
|
||
const sequenceMigrationHash = createHash("sha256")
|
||
.update(sequenceMigration)
|
||
.digest("hex");
|
||
const rawDb = createDb(temporary.connectionString);
|
||
try {
|
||
const companyId = "10000000-0000-4000-8000-000000000001";
|
||
const agentId = "10000000-0000-4000-8000-000000000002";
|
||
const runId = "10000000-0000-4000-8000-000000000003";
|
||
// Reconstruct the actual pre-0227 shape rather than extracting selected
|
||
// repair statements from the migration under test.
|
||
await rawDb.execute(sql.raw(`
|
||
DROP TABLE IF EXISTS status_decision_effects, status_decisions, work_assessments,
|
||
native_run_finalizations, native_run_results, completion_contracts CASCADE;
|
||
DROP TRIGGER IF EXISTS paperclip_issue_status_version_trigger ON issues;
|
||
DROP FUNCTION IF EXISTS paperclip_bump_issue_status_version();
|
||
DROP INDEX IF EXISTS heartbeat_run_events_run_seq_uq;
|
||
CREATE INDEX IF NOT EXISTS heartbeat_run_events_run_seq_idx
|
||
ON heartbeat_run_events (run_id, seq);
|
||
DROP INDEX IF EXISTS heartbeat_run_events_run_source_event_uq;
|
||
DROP INDEX IF EXISTS heartbeat_run_events_run_source_seq_uq;
|
||
ALTER TABLE heartbeat_run_events
|
||
DROP COLUMN IF EXISTS source_instance_id,
|
||
DROP COLUMN IF EXISTS source_event_id,
|
||
DROP COLUMN IF EXISTS source_seq,
|
||
DROP COLUMN IF EXISTS source_payload_sha256,
|
||
DROP COLUMN IF EXISTS protocol_schema_version;
|
||
ALTER TABLE heartbeat_run_events ALTER COLUMN seq TYPE integer;
|
||
ALTER TABLE heartbeat_runs
|
||
DROP COLUMN IF EXISTS runtime_mode,
|
||
DROP COLUMN IF EXISTS runtime_mode_resolver_version,
|
||
DROP COLUMN IF EXISTS runtime_mode_reason,
|
||
DROP COLUMN IF EXISTS runtime_mode_resolved_at,
|
||
DROP COLUMN IF EXISTS runner_profile_json,
|
||
DROP COLUMN IF EXISTS runner_instance_id,
|
||
DROP COLUMN IF EXISTS native_session_id,
|
||
DROP COLUMN IF EXISTS driver_kind,
|
||
DROP COLUMN IF EXISTS driver_version,
|
||
DROP COLUMN IF EXISTS completion_contract_id,
|
||
DROP COLUMN IF EXISTS completion_contract_sha256,
|
||
DROP COLUMN IF EXISTS next_event_seq,
|
||
DROP COLUMN IF EXISTS native_phase,
|
||
DROP COLUMN IF EXISTS native_phase_updated_at;
|
||
ALTER TABLE issues
|
||
DROP COLUMN IF EXISTS status_version,
|
||
DROP COLUMN IF EXISTS last_status_decision_id;
|
||
`));
|
||
await rawDb.execute(sql`DELETE FROM "drizzle"."__drizzle_migrations" WHERE "hash" = ${migrationHash}`);
|
||
await rawDb.execute(sql`DELETE FROM "drizzle"."__drizzle_migrations" WHERE "hash" = ${sequenceMigrationHash}`);
|
||
await rawDb.execute(sql`
|
||
INSERT INTO companies (id, name, issue_prefix)
|
||
VALUES (${companyId}, 'Migration fixture', 'MIG')
|
||
`);
|
||
await rawDb.execute(sql`
|
||
INSERT INTO agents (id, company_id, name)
|
||
VALUES (${agentId}, ${companyId}, 'Migration agent')
|
||
`);
|
||
await rawDb.execute(sql`
|
||
INSERT INTO heartbeat_runs (id, company_id, agent_id, status)
|
||
VALUES (${runId}, ${companyId}, ${agentId}, 'succeeded')
|
||
`);
|
||
await rawDb.execute(sql`
|
||
INSERT INTO heartbeat_run_events
|
||
(company_id, run_id, agent_id, seq, event_type, stream, level, message, payload, created_at)
|
||
VALUES
|
||
(${companyId}, ${runId}, ${agentId}, 1, 'legacy.start', 'system', 'info', 'one', ${JSON.stringify({ bytes: "α-1" })}::jsonb, '2026-08-01T00:00:01.000Z'),
|
||
(${companyId}, ${runId}, ${agentId}, 5, 'legacy.log', 'stdout', 'info', 'first-five', ${JSON.stringify({ bytes: "β-5a" })}::jsonb, '2026-08-01T00:00:02.000Z'),
|
||
(${companyId}, ${runId}, ${agentId}, 5, 'legacy.log', 'stderr', 'warn', 'duplicate-five', ${JSON.stringify({ bytes: "γ-5b" })}::jsonb, '2026-08-01T00:00:03.000Z'),
|
||
(${companyId}, ${runId}, ${agentId}, 9, 'legacy.end', 'system', 'info', 'nine', ${JSON.stringify({ bytes: "δ-9" })}::jsonb, '2026-08-01T00:00:04.000Z')
|
||
`);
|
||
const beforeResult = await rawDb.execute(sql`
|
||
SELECT * FROM heartbeat_run_events WHERE run_id = ${runId} ORDER BY id
|
||
`);
|
||
const before = [...beforeResult] as unknown as Record<string, unknown>[];
|
||
|
||
await applyPendingMigrations(temporary.connectionString);
|
||
const db = createDb(temporary.connectionString);
|
||
|
||
const after = await db.select().from(heartbeatRunEvents)
|
||
.where(eq(heartbeatRunEvents.runId, runId)).orderBy(heartbeatRunEvents.id);
|
||
expect(after.map((row) => row.seq)).toEqual([1, 5, 10, 9]);
|
||
expect((await db.select({ nextEventSeq: heartbeatRuns.nextEventSeq }).from(heartbeatRuns)
|
||
.where(eq(heartbeatRuns.id, runId)))[0]?.nextEventSeq).toBe(11);
|
||
|
||
// The repaired duplicate's cursor is the only changed byte-equivalent read field.
|
||
const legacyColumns = (row: Record<string, unknown>) => ({
|
||
id: String(row.id),
|
||
companyId: row.companyId ?? row.company_id,
|
||
runId: row.runId ?? row.run_id,
|
||
agentId: row.agentId ?? row.agent_id,
|
||
eventType: row.eventType ?? row.event_type,
|
||
stream: row.stream,
|
||
level: row.level,
|
||
message: row.message,
|
||
payload: row.payload,
|
||
createdAt: new Date(String(row.createdAt ?? row.created_at)).toISOString(),
|
||
});
|
||
expect(after.map((row) => legacyColumns(row))).toEqual(before.map(legacyColumns));
|
||
expect(after[0]?.seq).toBe(Number(before[0]?.seq));
|
||
expect(after[1]?.seq).toBe(Number(before[1]?.seq));
|
||
expect(after[3]?.seq).toBe(Number(before[3]?.seq));
|
||
await expect(db.insert(heartbeatRunEvents).values({
|
||
companyId, runId, agentId, seq: 5, eventType: "must-conflict",
|
||
})).rejects.toThrow();
|
||
} finally {
|
||
await temporary.cleanup();
|
||
}
|
||
}, 60_000);
|
||
});
|