From 30697c80c68d5676a829df7cf71a777a100c9847 Mon Sep 17 00:00:00 2001 From: qyinm Date: Wed, 19 Aug 2026 17:23:02 +0900 Subject: [PATCH] fix(api): unblock V23 generation input backfill --- .../V23__routine_agent_read_only_schema.sql | 8 + ...onInputBackfillMigrationIntegrationTest.kt | 160 ++++++++++++++++++ 2 files changed, 168 insertions(+) create mode 100644 apps/api/src/test/kotlin/com/plot/api/routine/V23GenerationInputBackfillMigrationIntegrationTest.kt diff --git a/apps/api/src/main/resources/db/migration/V23__routine_agent_read_only_schema.sql b/apps/api/src/main/resources/db/migration/V23__routine_agent_read_only_schema.sql index 174b29d..168de0f 100644 --- a/apps/api/src/main/resources/db/migration/V23__routine_agent_read_only_schema.sql +++ b/apps/api/src/main/resources/db/migration/V23__routine_agent_read_only_schema.sql @@ -351,12 +351,20 @@ alter table generation_inputs add column agent_run_id uuid, add column agent_run_input_id uuid; +-- generation_inputs is immutable for application writes. This migration alone +-- enriches legacy rows with their already-recorded generation-run provenance. +-- PostgreSQL runs this SQL migration transactionally, so an interrupted +-- backfill also restores the trigger before another process can write rows. +alter table generation_inputs disable trigger generation_inputs_append_only; + update generation_inputs input set source_scope_id = run.source_scope_id from generation_runs run where run.workspace_id = input.workspace_id and run.id = input.generation_run_id; +alter table generation_inputs enable trigger generation_inputs_append_only; + alter table generation_inputs add constraint generation_inputs_source_scope_fk foreign key (workspace_id, source_scope_id) diff --git a/apps/api/src/test/kotlin/com/plot/api/routine/V23GenerationInputBackfillMigrationIntegrationTest.kt b/apps/api/src/test/kotlin/com/plot/api/routine/V23GenerationInputBackfillMigrationIntegrationTest.kt new file mode 100644 index 0000000..5016739 --- /dev/null +++ b/apps/api/src/test/kotlin/com/plot/api/routine/V23GenerationInputBackfillMigrationIntegrationTest.kt @@ -0,0 +1,160 @@ +package com.plot.api.routine + +import com.plot.api.TestcontainersConfiguration +import java.sql.Timestamp +import java.time.Instant +import java.util.UUID +import javax.sql.DataSource +import kotlin.test.assertEquals +import kotlin.test.assertFailsWith +import org.flywaydb.core.Flyway +import org.flywaydb.core.api.MigrationVersion +import org.junit.jupiter.api.AfterEach +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.Test +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.boot.test.context.SpringBootTest +import org.springframework.context.annotation.Import +import org.springframework.dao.DataIntegrityViolationException +import org.springframework.jdbc.core.JdbcTemplate + +@SpringBootTest +@Import(TestcontainersConfiguration::class) +class V23GenerationInputBackfillMigrationIntegrationTest { + + @Autowired private lateinit var dataSource: DataSource + @Autowired private lateinit var jdbcTemplate: JdbcTemplate + + private lateinit var schema: String + + @BeforeEach + fun createIsolatedSchema() { + schema = "v23_backfill_${UUID.randomUUID().toString().replace("-", "")}" + jdbcTemplate.execute("create schema $schema") + } + + @AfterEach + fun dropIsolatedSchema() { + jdbcTemplate.execute("drop schema $schema cascade") + } + + @Test + fun `V23 backfills legacy inputs without weakening their append-only guard`() { + migrateTo("22") + val fixture = insertLegacyGenerationInput() + + migrateTo("23") + + assertEquals( + fixture.sourceScopeId, + jdbcTemplate.queryForObject( + "select source_scope_id from $schema.generation_inputs where id = ?", + UUID::class.java, + fixture.generationInputId, + ), + ) + assertFailsWith { + jdbcTemplate.update( + "update $schema.generation_inputs set source_scope_id = null where id = ?", + fixture.generationInputId, + ) + } + } + + private fun migrateTo(version: String) { + Flyway.configure() + .dataSource(dataSource) + .schemas(schema) + .defaultSchema(schema) + .locations("classpath:db/migration") + .target(MigrationVersion.fromVersion(version)) + .group(true) + .load() + .migrate() + } + + private fun insertLegacyGenerationInput(): LegacyInputFixture { + val now = Timestamp.from(Instant.parse("2026-08-19T00:00:00Z")) + val userId = UUID.randomUUID() + val workspaceId = UUID.randomUUID() + val namespaceId = UUID.randomUUID() + val sourceScopeId = UUID.randomUUID() + val writingBlockId = UUID.randomUUID() + val generationRunId = UUID.randomUUID() + val generationInputId = UUID.randomUUID() + + jdbcTemplate.update( + "insert into $schema.users (id, email, display_name, status, created_at, updated_at) values (?, ?, 'Migration Test', 'ACTIVE', ?, ?)", + userId, + "${UUID.randomUUID()}@example.com", + now, + now, + ) + jdbcTemplate.update( + "insert into $schema.workspaces (id, name, slug, created_by_user_id, status, created_at, updated_at) values (?, 'Migration Workspace', ?, ?, 'ACTIVE', ?, ?)", + workspaceId, + "migration-${UUID.randomUUID()}", + userId, + now, + now, + ) + jdbcTemplate.update( + "insert into $schema.source_namespaces (id, workspace_id, provider, namespace_kind, external_namespace_key, display_name, status, created_at, updated_at) values (?, ?, 'GITHUB', 'REPOSITORY', ?, 'Migration repository', 'ACTIVE', ?, ?)", + namespaceId, + workspaceId, + "namespace:$namespaceId", + now, + now, + ) + jdbcTemplate.update( + "insert into $schema.source_scopes (id, workspace_id, source_namespace_id, provider, scope_semantics, scope_kind, external_scope_key, display_name, status, status_changed_at, created_at, updated_at) values (?, ?, ?, 'GITHUB', 'CONTAINER', 'REPOSITORY', ?, 'Migration repository', 'ACTIVE', ?, ?, ?)", + sourceScopeId, + workspaceId, + namespaceId, + "scope:$sourceScopeId", + now, + now, + now, + ) + jdbcTemplate.update( + "insert into $schema.writing_blocks (id, workspace_id, source_namespace_id, external_object_key, source_origin, source_kind, title, body, url, canonical_url, platform, content_hash, ingested_at, status, created_by_user_id, created_at, updated_at) values (?, ?, ?, ?, 'migration', 'commit', 'Migration input', 'Legacy input', ?, ?, 'github', 'legacy-input', ?, 'ACTIVE', ?, ?, ?)", + writingBlockId, + workspaceId, + namespaceId, + "commit:$writingBlockId", + "https://github.com/acme/plot/commit/$writingBlockId", + "https://github.com/acme/plot/commit/$writingBlockId", + now, + userId, + now, + now, + ) + jdbcTemplate.update( + "insert into $schema.generation_runs (id, workspace_id, source_scope_id, created_by_user_id, idempotency_key, request_fingerprint, status, workflow_version, prompt_version, output_schema_version, budget_version, provider, model_name, budget_snapshot, created_at, updated_at, finished_at) values (?, ?, ?, ?, 'migration-run', 'migration-fingerprint', 'READY', 'workflow-v1', 'prompt-v1', 'schema-v1', 'budget-v1', 'test', 'test-model', '{}'::jsonb, ?, ?, ?)", + generationRunId, + workspaceId, + sourceScopeId, + userId, + now, + now, + now, + ) + jdbcTemplate.update( + "insert into $schema.generation_inputs (id, workspace_id, generation_run_id, writing_block_id, order_index, source_provider, source_kind, source_label, snapshot_title, snapshot_body, snapshot_excerpt, original_url, source_created_at, source_updated_at, content_hash, captured_at) values (?, ?, ?, ?, 0, 'GITHUB', 'COMMIT', 'Migration input', 'Migration input', 'Legacy input', 'Legacy input', 'https://github.com/acme/plot/commit/legacy', ?, ?, 'legacy-input', ?)", + generationInputId, + workspaceId, + generationRunId, + writingBlockId, + now, + now, + now, + ) + + return LegacyInputFixture(generationInputId, sourceScopeId) + } + + private data class LegacyInputFixture( + val generationInputId: UUID, + val sourceScopeId: UUID, + ) +}