mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 18:19:05 +02:00
fix(db): preserve published instruction migration history
Restore the exact published SQL hashes so cloud preflight accepts upgrades from the already deployed instruction feature. Cover immutable SQL and journal hashes plus repeated migrator runs preserving revisions, pending edits, receipts, and company constraints. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
a450a48be4
commit
ec9f5f6c12
3 files changed
+49
-43
No files matched your search
@@ -3,18 +3,38 @@ import { readFile } from "node:fs/promises";
|
||||
import postgres from "postgres";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { applyPendingMigrations, inspectMigrations } from "./client.js";
|
||||
import { getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./test-embedded-postgres.js";
|
||||
import { EMBEDDED_POSTGRES_TEST_TIMEOUT_MS, getEmbeddedPostgresTestSupport, startEmbeddedPostgresTestDatabase } from "./test-embedded-postgres.js";
|
||||
|
||||
const files = ["0285_legal_thunderbolts.sql", "0286_amusing_shiver_man.sql"];
|
||||
const migrations = await Promise.all(files.map(async file => ({
|
||||
file, sql: await readFile(new URL(`./migrations/${file}`, import.meta.url), "utf8"),
|
||||
})));
|
||||
// These migrations shipped in preview 14c3d810c9e05625121b3d27767aea9317b03125.
|
||||
// Cloud upgrades require the existing SQL and journal rows to remain byte-identical.
|
||||
const published = [
|
||||
{ file: files[0]!, sqlHash: "5af82470982b273fb052c7e62c3695f56a9a7f2e6af29a6876f300f168167d11", journalHash: "00a680c4ca07980c76c03ddf58782da8168de7add5ed955b36affeaca1a2465b" },
|
||||
{ file: files[1]!, sqlHash: "6400eb3bc26df081f0fb50bdd5a3b7bd45540c8148a0ea3d67564f462bf8180c", journalHash: "83f1e59908d11f3e4d43fb7b97ee6d829092a7038f8a45910f30cce15945ebe0" },
|
||||
];
|
||||
const journal = JSON.parse(await readFile(new URL("./migrations/meta/_journal.json", import.meta.url), "utf8")) as {
|
||||
entries: Array<{ idx: number; when: number; tag: string; breakpoints: boolean }>;
|
||||
};
|
||||
|
||||
describe("published instruction migration history", () => {
|
||||
it.each(published)("preserves the published SQL and journal hashes for $file", (entry) => {
|
||||
const migration = migrations.find(candidate => candidate.file === entry.file)!;
|
||||
const row = journal.entries.find(candidate => `${candidate.tag}.sql` === entry.file)!;
|
||||
expect(createHash("sha256").update(migration.sql).digest("hex")).toBe(entry.sqlHash);
|
||||
expect(createHash("sha256").update(JSON.stringify([row.idx, row.when, row.tag, row.breakpoints])).digest("hex"))
|
||||
.toBe(entry.journalHash);
|
||||
});
|
||||
});
|
||||
|
||||
const support = await getEmbeddedPostgresTestSupport();
|
||||
const describePostgres = support.supported ? describe : describe.skip;
|
||||
|
||||
describePostgres("instruction revision migrations", () => {
|
||||
it("replays over existing revisions and pending copies without losing bytes or company constraints", async () => {
|
||||
const database = await startEmbeddedPostgresTestDatabase("instruction-migration-replay-");
|
||||
it("keeps applied migration receipts, revisions, and pending copies intact on repeated upgrades", async () => {
|
||||
const database = await startEmbeddedPostgresTestDatabase("instruction-migration-upgrade-");
|
||||
const sql = postgres(database.connectionString, { max: 1, onnotice: () => {} });
|
||||
try {
|
||||
const companyId = randomUUID(), otherCompanyId = randomUUID(), agentId = randomUUID();
|
||||
@@ -34,25 +54,25 @@ describePostgres("instruction revision migrations", () => {
|
||||
await sql`INSERT INTO agent_instruction_working_copies
|
||||
(run_id, company_id, agent_id, responsible_user_id, entry_file, base_revision_id, base_hash, local_root, execution_root, location, state, candidate_base64, candidate_hash)
|
||||
VALUES (${runId}, ${companyId}, ${agentId}, 'editor', 'AGENTS.md', ${revisionId}, ${contentHash}, '/private/copy', '/private/copy', 'local', 'pending_commit', ${contentBase64}, ${contentHash})`;
|
||||
const before = {
|
||||
const snapshot = async () => ({
|
||||
journal: await sql`SELECT * FROM drizzle.__drizzle_migrations ORDER BY id`,
|
||||
relations: await sql`SELECT oid::text, relname FROM pg_class WHERE relname IN
|
||||
('agent_instruction_revisions', 'agent_instruction_heads', 'agent_instruction_working_copies') ORDER BY relname`,
|
||||
constraints: await sql`SELECT oid::text, conname, pg_get_constraintdef(oid) AS definition FROM pg_constraint
|
||||
WHERE conrelid IN ('agent_instruction_revisions'::regclass, 'agent_instruction_heads'::regclass,
|
||||
'agent_instruction_working_copies'::regclass) ORDER BY conname`,
|
||||
revisions: await sql`SELECT * FROM agent_instruction_revisions`,
|
||||
heads: await sql`SELECT * FROM agent_instruction_heads`,
|
||||
copies: await sql`SELECT * FROM agent_instruction_working_copies`,
|
||||
};
|
||||
for (const migration of migrations) {
|
||||
await sql`DELETE FROM drizzle.__drizzle_migrations WHERE hash = ${createHash("sha256").update(migration.sql).digest("hex")}`;
|
||||
}
|
||||
expect(await inspectMigrations(database.connectionString)).toMatchObject({ status: "needsMigrations", pendingMigrations: files });
|
||||
await applyPendingMigrations(database.connectionString);
|
||||
// Also replay the SQL directly with every object already present.
|
||||
await sql.begin(async tx => {
|
||||
for (const migration of migrations) for (const statement of migration.sql.split("--> statement-breakpoint")) {
|
||||
if (statement.trim()) await tx.unsafe(statement);
|
||||
}
|
||||
});
|
||||
expect(await sql`SELECT * FROM agent_instruction_revisions`).toEqual(before.revisions);
|
||||
expect(await sql`SELECT * FROM agent_instruction_heads`).toEqual(before.heads);
|
||||
expect(await sql`SELECT * FROM agent_instruction_working_copies`).toEqual(before.copies);
|
||||
const before = await snapshot();
|
||||
expect(before.relations).toHaveLength(3);
|
||||
for (const entry of published) expect(before.journal.some(row => row.hash === entry.sqlHash)).toBe(true);
|
||||
for (let attempt = 0; attempt < 2; attempt += 1) {
|
||||
expect(await inspectMigrations(database.connectionString)).toMatchObject({ status: "upToDate" });
|
||||
await applyPendingMigrations(database.connectionString);
|
||||
expect(await snapshot()).toEqual(before);
|
||||
}
|
||||
await expect(sql`UPDATE agent_instruction_heads SET company_id = ${otherCompanyId} WHERE revision_id = ${revisionId}`)
|
||||
.rejects.toMatchObject({ code: "23503" });
|
||||
await expect(sql`UPDATE agent_instruction_working_copies SET company_id = ${otherCompanyId} WHERE run_id = ${runId}`)
|
||||
@@ -61,5 +81,5 @@ describePostgres("instruction revision migrations", () => {
|
||||
await sql.end();
|
||||
await database.cleanup();
|
||||
}
|
||||
}, 30_000);
|
||||
}, EMBEDDED_POSTGRES_TEST_TIMEOUT_MS);
|
||||
});
|
||||
@@ -1,5 +1,4 @@
|
||||
-- Keep pre-release or renumbered migration replay safe without replacing stored instruction bytes.
|
||||
CREATE TABLE IF NOT EXISTS "agent_instruction_heads" (
|
||||
CREATE TABLE "agent_instruction_heads" (
|
||||
"company_id" uuid NOT NULL,
|
||||
"agent_id" uuid NOT NULL,
|
||||
"entry_file" text NOT NULL,
|
||||
@@ -8,7 +7,7 @@ CREATE TABLE IF NOT EXISTS "agent_instruction_heads" (
|
||||
CONSTRAINT "agent_instruction_heads_identity_uq" UNIQUE("company_id","agent_id","entry_file")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE IF NOT EXISTS "agent_instruction_revisions" (
|
||||
CREATE TABLE "agent_instruction_revisions" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"agent_id" uuid NOT NULL,
|
||||
@@ -28,12 +27,6 @@ CREATE TABLE IF NOT EXISTS "agent_instruction_revisions" (
|
||||
CONSTRAINT "agent_instruction_revisions_identity_uq" UNIQUE("company_id","agent_id","entry_file","id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "agent_instruction_heads" ADD CONSTRAINT "agent_instruction_heads_company_id_agent_id_entry_file_revision_id_agent_instruction_revisions_company_id_agent_id_entry_file_id_fk" FOREIGN KEY ("company_id","agent_id","entry_file","revision_id") REFERENCES "public"."agent_instruction_revisions"("company_id","agent_id","entry_file","id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "agent_instruction_revisions" ADD CONSTRAINT "agent_instruction_revisions_company_id_agent_id_agents_company_id_id_fk" FOREIGN KEY ("company_id","agent_id") REFERENCES "public"."agents"("company_id","id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "agent_instruction_revisions_history_idx" ON "agent_instruction_revisions" USING btree ("company_id","agent_id","entry_file","created_at","id");
|
||||
ALTER TABLE "agent_instruction_heads" ADD CONSTRAINT "agent_instruction_heads_company_id_agent_id_entry_file_revision_id_agent_instruction_revisions_company_id_agent_id_entry_file_id_fk" FOREIGN KEY ("company_id","agent_id","entry_file","revision_id") REFERENCES "public"."agent_instruction_revisions"("company_id","agent_id","entry_file","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "agent_instruction_revisions" ADD CONSTRAINT "agent_instruction_revisions_company_id_agent_id_agents_company_id_id_fk" FOREIGN KEY ("company_id","agent_id") REFERENCES "public"."agents"("company_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "agent_instruction_revisions_history_idx" ON "agent_instruction_revisions" USING btree ("company_id","agent_id","entry_file","created_at","id");
|
||||
@@ -1,5 +1,4 @@
|
||||
-- Keep pre-release or renumbered migration replay safe without replacing stored instruction bytes.
|
||||
CREATE TABLE IF NOT EXISTS "agent_instruction_working_copies" (
|
||||
CREATE TABLE "agent_instruction_working_copies" (
|
||||
"run_id" uuid PRIMARY KEY NOT NULL,
|
||||
"company_id" uuid NOT NULL,
|
||||
"agent_id" uuid NOT NULL,
|
||||
@@ -23,13 +22,7 @@ CREATE TABLE IF NOT EXISTS "agent_instruction_working_copies" (
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "agent_instruction_working_copies" ADD CONSTRAINT "agent_instruction_working_copies_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
DO $$ BEGIN
|
||||
ALTER TABLE "agent_instruction_working_copies" ADD CONSTRAINT "agent_instruction_working_copies_company_id_agent_id_agents_company_id_id_fk" FOREIGN KEY ("company_id","agent_id") REFERENCES "public"."agents"("company_id","id") ON DELETE cascade ON UPDATE no action;
|
||||
EXCEPTION WHEN duplicate_object THEN NULL;
|
||||
END $$;--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "agent_instruction_copies_pending_idx" ON "agent_instruction_working_copies" USING btree ("state","next_attempt_at");--> statement-breakpoint
|
||||
CREATE INDEX IF NOT EXISTS "agent_instruction_copies_agent_idx" ON "agent_instruction_working_copies" USING btree ("company_id","agent_id","created_at");
|
||||
ALTER TABLE "agent_instruction_working_copies" ADD CONSTRAINT "agent_instruction_working_copies_run_id_heartbeat_runs_id_fk" FOREIGN KEY ("run_id") REFERENCES "public"."heartbeat_runs"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "agent_instruction_working_copies" ADD CONSTRAINT "agent_instruction_working_copies_company_id_agent_id_agents_company_id_id_fk" FOREIGN KEY ("company_id","agent_id") REFERENCES "public"."agents"("company_id","id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "agent_instruction_copies_pending_idx" ON "agent_instruction_working_copies" USING btree ("state","next_attempt_at");--> statement-breakpoint
|
||||
CREATE INDEX "agent_instruction_copies_agent_idx" ON "agent_instruction_working_copies" USING btree ("company_id","agent_id","created_at");
|
||||
Reference in new issue
Block a user