diff --git a/packages/db/src/client.test.ts b/packages/db/src/client.test.ts index 75e7effcc5..fc1837962f 100644 --- a/packages/db/src/client.test.ts +++ b/packages/db/src/client.test.ts @@ -29,6 +29,48 @@ async function migrationHash(migrationFile: string): Promise { return createHash("sha256").update(content).digest("hex"); } +const userVisibleUpdatedAtTables = new Set([ + "companies", + "heartbeat_runs", + "issue_comments", + "issues", + "routine_runs", + "routines", +]); + +const migrationUpdatedAtUpdateAllowlist = new Map>([ + [ + "0105_instance_scoped_environments.sql", + new Set(["issues"]), + ], + [ + "0131_repair_run_responsible_user_context_refs.sql", + new Set(["heartbeat_runs"]), + ], +]); + +function findUserVisibleUpdatedAtBackfillViolations( + migrationFile: string, + content: string, +): string[] { + const allowedTables = migrationUpdatedAtUpdateAllowlist.get(migrationFile) ?? new Set(); + const violations: string[] = []; + + for (const statement of content.split("--> statement-breakpoint")) { + const updateMatch = statement.match(/\bUPDATE\s+"([^"]+)"/i); + if (!updateMatch) continue; + + const tableName = updateMatch[1]; + if (!userVisibleUpdatedAtTables.has(tableName)) continue; + if (!/\bSET\b[\s\S]*"updated_at"\s*=/i.test(statement)) continue; + if (allowedTables.has(tableName)) continue; + + violations.push(`${migrationFile}: UPDATE "${tableName}" sets updated_at`); + } + + return violations; +} + afterEach(async () => { while (cleanups.length > 0) { const cleanup = cleanups.pop(); @@ -43,6 +85,35 @@ if (!embeddedPostgresSupport.supported) { } describeEmbeddedPostgres("applyPendingMigrations", () => { + it("rejects unallowlisted migration backfills that bump updated_at on user-visible tables", async () => { + const entries = await fs.promises.readdir(new URL("./migrations", import.meta.url), { + withFileTypes: true, + }); + const violations: string[] = []; + + for (const entry of entries) { + if (!entry.isFile() || !entry.name.endsWith(".sql")) continue; + const content = await fs.promises.readFile( + new URL(`./migrations/${entry.name}`, import.meta.url), + "utf8", + ); + violations.push(...findUserVisibleUpdatedAtBackfillViolations(entry.name, content)); + } + + expect(violations).toEqual([]); + expect( + findUserVisibleUpdatedAtBackfillViolations( + "9999_bad_backfill.sql", + ` + UPDATE "issues" AS i + SET "responsible_user_id" = 'owner-user', + "updated_at" = now() + WHERE i."responsible_user_id" IS NULL; + `, + ), + ).toEqual(['9999_bad_backfill.sql: UPDATE "issues" sets updated_at']); + }); + it( "applies an inserted earlier migration without replaying later legacy migrations", async () => { @@ -542,6 +613,139 @@ describeEmbeddedPostgres("applyPendingMigrations", () => { 20_000, ); + it( + "replays migration 0130 without bumping issue updated_at for inbox archives", + async () => { + const connectionString = await createTempDatabase(); + + await applyPendingMigrations(connectionString); + + const sql = postgres(connectionString, { max: 1, onnotice: () => {} }); + try { + const runResponsibleUserHash = await migrationHash( + "0130_run_responsible_user_invariant.sql", + ); + + await sql.unsafe(` + INSERT INTO "companies" ("id", "name", "issue_prefix", "created_at", "updated_at") + VALUES ( + '00000000-0000-0000-0000-000000000120', + 'Migration Inbox Co', + 'TST120', + '2026-03-26T09:00:00.000Z', + '2026-03-26T09:00:00.000Z' + ) + `); + await sql.unsafe(` + INSERT INTO "company_memberships" ( + "id", + "company_id", + "principal_type", + "principal_id", + "status", + "membership_role", + "created_at", + "updated_at" + ) + VALUES ( + '00000000-0000-0000-0000-000000000121', + '00000000-0000-0000-0000-000000000120', + 'user', + 'owner-user', + 'active', + 'owner', + '2026-03-26T09:00:00.000Z', + '2026-03-26T09:00:00.000Z' + ) + `); + await sql.unsafe(` + INSERT INTO "issues" ( + "id", + "company_id", + "title", + "status", + "responsible_user_id", + "created_at", + "updated_at" + ) + VALUES ( + '00000000-0000-0000-0000-000000000122', + '00000000-0000-0000-0000-000000000120', + 'Archived issue needing responsible user backfill', + 'todo', + NULL, + '2026-03-26T10:00:00.000Z', + '2026-03-26T10:00:00.000Z' + ) + `); + await sql.unsafe(` + INSERT INTO "issue_inbox_archives" ( + "id", + "company_id", + "issue_id", + "user_id", + "archived_at", + "created_at", + "updated_at" + ) + VALUES ( + '00000000-0000-0000-0000-000000000123', + '00000000-0000-0000-0000-000000000120', + '00000000-0000-0000-0000-000000000122', + 'owner-user', + '2026-03-26T12:00:00.000Z', + '2026-03-26T12:00:00.000Z', + '2026-03-26T12:00:00.000Z' + ) + `); + await sql.unsafe( + `DELETE FROM "drizzle"."__drizzle_migrations" WHERE hash = '${runResponsibleUserHash}'`, + ); + } finally { + await sql.end(); + } + + const pendingState = await inspectMigrations(connectionString); + expect(pendingState).toMatchObject({ + status: "needsMigrations", + pendingMigrations: ["0130_run_responsible_user_invariant.sql"], + reason: "pending-migrations", + }); + + await applyPendingMigrations(connectionString); + + const verifySql = postgres(connectionString, { max: 1, onnotice: () => {} }); + try { + const rows = await verifySql.unsafe<{ + responsible_user_id: string | null; + updated_at: Date; + inbox_archive_still_current: boolean; + }[]>(` + SELECT + i."responsible_user_id", + i."updated_at", + EXISTS ( + SELECT 1 + FROM "issue_inbox_archives" AS archive + WHERE archive."company_id" = i."company_id" + AND archive."issue_id" = i."id" + AND archive."user_id" = 'owner-user' + AND archive."archived_at" >= i."updated_at" + ) AS "inbox_archive_still_current" + FROM "issues" AS i + WHERE i."id" = '00000000-0000-0000-0000-000000000122' + `); + expect(rows).toHaveLength(1); + expect(rows[0]?.responsible_user_id).toBe("owner-user"); + expect(rows[0]?.updated_at.toISOString()).toBe("2026-03-26T10:00:00.000Z"); + expect(rows[0]?.inbox_archive_still_current).toBe(true); + } finally { + await verifySql.end(); + } + }, + 20_000, + ); + it( "replays the run responsible user repair migration when heartbeat run issue refs are identifiers", async () => { diff --git a/packages/db/src/migrations/0130_run_responsible_user_invariant.sql b/packages/db/src/migrations/0130_run_responsible_user_invariant.sql index 1ae59fa5a9..c5cc6d5eee 100644 --- a/packages/db/src/migrations/0130_run_responsible_user_invariant.sql +++ b/packages/db/src/migrations/0130_run_responsible_user_invariant.sql @@ -11,8 +11,7 @@ WITH owner_defaults AS ( ORDER BY "company_id", "created_at" ASC, "id" ASC ) UPDATE "companies" AS c -SET "default_responsible_user_id" = owner_defaults."user_id", - "updated_at" = now() +SET "default_responsible_user_id" = owner_defaults."user_id" FROM owner_defaults WHERE c."id" = owner_defaults."company_id" AND c."default_responsible_user_id" IS NULL; @@ -50,15 +49,13 @@ resolved_issue_users AS ( ORDER BY "issue_id", "depth" ASC ) UPDATE "issues" AS i -SET "responsible_user_id" = resolved_issue_users."user_id", - "updated_at" = now() +SET "responsible_user_id" = resolved_issue_users."user_id" FROM resolved_issue_users WHERE i."id" = resolved_issue_users."issue_id" AND i."responsible_user_id" IS NULL; --> statement-breakpoint UPDATE "issues" AS i -SET "responsible_user_id" = c."default_responsible_user_id", - "updated_at" = now() +SET "responsible_user_id" = c."default_responsible_user_id" FROM "companies" AS c WHERE i."company_id" = c."id" AND i."responsible_user_id" IS NULL @@ -76,8 +73,7 @@ WITH routine_responsible_users AS ( WHERE r."responsible_user_id" IS NULL ) UPDATE "routines" AS r -SET "responsible_user_id" = routine_responsible_users."user_id", - "updated_at" = now() +SET "responsible_user_id" = routine_responsible_users."user_id" FROM routine_responsible_users WHERE r."id" = routine_responsible_users."id" AND routine_responsible_users."user_id" IS NOT NULL; @@ -114,15 +110,13 @@ WITH routine_run_responsible_users AS ( WHERE rr."responsible_user_id" IS NULL ) UPDATE "routine_runs" AS rr -SET "responsible_user_id" = routine_run_responsible_users."user_id", - "updated_at" = now() +SET "responsible_user_id" = routine_run_responsible_users."user_id" FROM routine_run_responsible_users WHERE rr."id" = routine_run_responsible_users."id" AND routine_run_responsible_users."user_id" IS NOT NULL; --> statement-breakpoint UPDATE "heartbeat_runs" AS h -SET "responsible_user_id" = original."responsible_user_id", - "updated_at" = now() +SET "responsible_user_id" = original."responsible_user_id" FROM "heartbeat_runs" AS original WHERE h."retry_of_run_id" = original."id" AND h."company_id" = original."company_id" @@ -192,15 +186,13 @@ resolved_run_users AS ( ORDER BY "run_id", "ref_priority" ASC, "match_priority" ASC ) UPDATE "heartbeat_runs" AS h -SET "responsible_user_id" = resolved_run_users."responsible_user_id", - "updated_at" = now() +SET "responsible_user_id" = resolved_run_users."responsible_user_id" FROM resolved_run_users WHERE h."id" = resolved_run_users."run_id" AND h."responsible_user_id" IS NULL; --> statement-breakpoint UPDATE "heartbeat_runs" AS h -SET "responsible_user_id" = awr."requested_by_actor_id", - "updated_at" = now() +SET "responsible_user_id" = awr."requested_by_actor_id" FROM "agent_wakeup_requests" AS awr WHERE h."wakeup_request_id" = awr."id" AND h."company_id" = awr."company_id" @@ -209,8 +201,7 @@ WHERE h."wakeup_request_id" = awr."id" AND awr."requested_by_actor_id" IS NOT NULL; --> statement-breakpoint UPDATE "heartbeat_runs" AS h -SET "responsible_user_id" = c."default_responsible_user_id", - "updated_at" = now() +SET "responsible_user_id" = c."default_responsible_user_id" FROM "companies" AS c WHERE h."company_id" = c."id" AND h."responsible_user_id" IS NULL