Files
STCourses/packages/feature-ai/test/upgrade.test.ts
T
Lucas WintherandClaude Opus 5.5 d34a5c1f0c feat(ai): citations column on ai_messages (contract request 5)
New packages/feature-ai/migrations/0001_ai_message_citations.sql adds
ai_messages.citations_json and folds v1 'sources' rows into the assistant
row before them. The feature writes and reads the column directly.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-30 20:28:23 +01:00

42 lines
2.5 KiB
TypeScript

import { expect } from "bun:test";
import { migrate, type Db } from "@stc/db";
// Core migrations only: the ai_* tables exactly as v1 shipped them.
import { forEachDb as forEachV1Db } from "../../db/test/helpers";
import { migrationsDirs } from "../src/migrations";
const T = "2026-10-01T00:00:00.000Z";
const aiMigrations = { "feature-ai": migrationsDirs };
async function seedMessage(db: Db, id: string, conv: string, role: string, content: string) {
await db.sql`INSERT INTO ai_messages (id, conversation_id, role, content, created_at) VALUES (${id}, ${conv}, ${role}, ${content}, ${T})`;
}
forEachV1Db("v1 upgrade: sources rows fold into citations_json on the assistant row before them", async (db) => {
const cites = JSON.stringify([{ path: "lessons/04-exercise.md", title: "Adding numbers", lessonId: "m2-ex-add" }]);
await db.sql`INSERT INTO ai_conversations (id, user_id, course_id, title, created_at) VALUES ('c1', 'u1', 'sample', 'Hi', ${T})`;
await db.sql`INSERT INTO ai_conversations (id, user_id, course_id, title, created_at) VALUES ('c2', 'u1', 'sample', 'Two', ${T})`;
// ids sort in the order v1 wrote them (UUIDv7); c1: user, assistant, sources, user, assistant (no sources)
await seedMessage(db, "0001-a", "c1", "user", "How do I add?");
await seedMessage(db, "0001-b", "c1", "assistant", "Which operator?");
await seedMessage(db, "0001-c", "c1", "sources", cites);
await seedMessage(db, "0001-d", "c1", "user", "And then?");
await seedMessage(db, "0001-e", "c1", "assistant", "Try it.");
// c2: an orphan sources row (no assistant before it) and an assistant whose next row belongs to c1's ids range
await seedMessage(db, "0002-a", "c2", "sources", "[]");
await seedMessage(db, "0002-b", "c2", "assistant", "Alone.");
const applied = await migrate(db, aiMigrations);
expect(applied).toContain("0001_ai_message_citations.sql");
const rows = await db.sql`SELECT id, role, content, citations_json FROM ai_messages ORDER BY id`;
expect(rows.map((r: { id: string; role: string }) => `${r.id}:${r.role}`)).toEqual([
"0001-a:user", "0001-b:assistant", "0001-d:user", "0001-e:assistant", "0002-b:assistant",
]);
const byId = Object.fromEntries(rows.map((r: { id: string; citations_json: string | null }) => [r.id, r.citations_json]));
expect(JSON.parse(byId["0001-b"]!)).toEqual(JSON.parse(cites));
expect(byId["0001-e"]).toBeNull();
expect(byId["0002-b"]).toBeNull();
expect(byId["0001-a"]).toBeNull();
expect(await migrate(db, aiMigrations)).toEqual([]);
});