keiro-migrations 0.13.0.0 → 0.14.0.0
raw patch · 8 files changed
+175/−23 lines, 8 filesPVP ok
version bump matches the API change (PVP)
API changes (from Hackage documentation)
Files
- CHANGELOG.md +8/−0
- expected-schema/native/keiro-v18.txt +8/−2
- keiro-migrations.cabal +1/−1
- migrations.native.lock +1/−0
- migrations/0031.sql +39/−0
- migrations/manifest +1/−0
- src/Keiro/Migrations/SchemaCheck.hs +1/−1
- test/Main.hs +116/−19
CHANGELOG.md view
@@ -6,6 +6,14 @@ ## Unreleased +## 0.14.0.0 — 2026-08-21++### New Features++- Migration `0031.sql` adds the terminal `rejected` outbox status audit columns,+ validates the bounded code/detail contract in PostgreSQL, and updates per-key and+ per-source head-of-line indexes so rejected rows release ordered successors.+ ## 0.13.0.0 — 2026-08-17 ### Breaking Changes
expected-schema/native/keiro-v18.txt view
@@ -98,6 +98,9 @@ column keiro_outbox.outbox_id uuid not null column keiro_outbox.payload_bytes bytea not null column keiro_outbox.published_at timestamp with time zone+column keiro_outbox.rejected_at timestamp with time zone+column keiro_outbox.rejection_code text+column keiro_outbox.rejection_detail text column keiro_outbox.schema_fingerprint text column keiro_outbox.schema_id bigint column keiro_outbox.schema_registry text@@ -376,6 +379,9 @@ constraint keiro_outbox.keiro_outbox_outbox_id_not_null NOT NULL outbox_id constraint keiro_outbox.keiro_outbox_payload_bytes_not_null NOT NULL payload_bytes constraint keiro_outbox.keiro_outbox_pkey PRIMARY KEY (outbox_id)+constraint keiro_outbox.keiro_outbox_rejection_audit_check CHECK ((((status = 'rejected'::text) AND (rejected_at IS NOT NULL) AND (rejection_code IS NOT NULL)) OR ((status <> 'rejected'::text) AND (rejected_at IS NULL) AND (rejection_code IS NULL) AND (rejection_detail IS NULL))))+constraint keiro_outbox.keiro_outbox_rejection_code_check CHECK (((rejection_code IS NULL) OR (rejection_code ~ '^[a-z][a-z0-9._-]{0,63}$'::text)))+constraint keiro_outbox.keiro_outbox_rejection_detail_check CHECK (((rejection_detail IS NULL) OR ((octet_length(rejection_detail) >= 1) AND (octet_length(rejection_detail) <= 1024)))) constraint keiro_outbox.keiro_outbox_schema_version_not_null NOT NULL schema_version constraint keiro_outbox.keiro_outbox_source_message_id_key UNIQUE (source, message_id) constraint keiro_outbox.keiro_outbox_source_not_null NOT NULL source@@ -598,12 +604,12 @@ index keiro_managed_read_objects_contract_idx CREATE INDEX keiro_managed_read_objects_contract_idx ON keiro.keiro_managed_read_objects USING btree (contract_id, contract_version) index keiro_managed_read_objects_pkey CREATE UNIQUE INDEX keiro_managed_read_objects_pkey ON keiro.keiro_managed_read_objects USING btree (object_schema, object_name, object_kind, object_signature) index keiro_outbox_claim_order_idx CREATE INDEX keiro_outbox_claim_order_idx ON keiro.keiro_outbox USING btree (created_at, outbox_id) WHERE (status = ANY (ARRAY['pending'::text, 'failed'::text]))-index keiro_outbox_head_of_line_idx CREATE INDEX keiro_outbox_head_of_line_idx ON keiro.keiro_outbox USING btree (source, message_key, created_at) WHERE ((status <> ALL (ARRAY['sent'::text, 'dead'::text])) AND (message_key IS NOT NULL))+index keiro_outbox_head_of_line_idx CREATE INDEX keiro_outbox_head_of_line_idx ON keiro.keiro_outbox USING btree (source, message_key, created_at) WHERE ((status <> ALL (ARRAY['sent'::text, 'dead'::text, 'rejected'::text])) AND (message_key IS NOT NULL)) index keiro_outbox_pending_idx CREATE INDEX keiro_outbox_pending_idx ON keiro.keiro_outbox USING btree (status, next_attempt_at, created_at) index keiro_outbox_pkey CREATE UNIQUE INDEX keiro_outbox_pkey ON keiro.keiro_outbox USING btree (outbox_id) index keiro_outbox_sent_gc_idx CREATE INDEX keiro_outbox_sent_gc_idx ON keiro.keiro_outbox USING btree (published_at) WHERE (status = 'sent'::text) index keiro_outbox_source_message_id_key CREATE UNIQUE INDEX keiro_outbox_source_message_id_key ON keiro.keiro_outbox USING btree (source, message_id)-index keiro_outbox_source_order_idx CREATE INDEX keiro_outbox_source_order_idx ON keiro.keiro_outbox USING btree (source, created_at, outbox_id) WHERE (status <> ALL (ARRAY['sent'::text, 'dead'::text]))+index keiro_outbox_source_order_idx CREATE INDEX keiro_outbox_source_order_idx ON keiro.keiro_outbox USING btree (source, created_at, outbox_id) WHERE (status <> ALL (ARRAY['sent'::text, 'dead'::text, 'rejected'::text])) index keiro_projection_dedup_applied_at_idx CREATE INDEX keiro_projection_dedup_applied_at_idx ON keiro.keiro_projection_dedup USING btree (applied_at) index keiro_projection_dedup_pkey CREATE UNIQUE INDEX keiro_projection_dedup_pkey ON keiro.keiro_projection_dedup USING btree (projection_name, event_id) index keiro_projection_group_cursors_pkey CREATE UNIQUE INDEX keiro_projection_group_cursors_pkey ON keiro.keiro_projection_group_cursors USING btree (group_id)
keiro-migrations.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name: keiro-migrations-version: 0.13.0.0+version: 0.14.0.0 synopsis: Schema migrations for keiro description: Embedded PostgreSQL schema migrations and a migration runner for the Keiro
migrations.native.lock view
@@ -28,3 +28,4 @@ 524e90271704112722bbfe0fc54f1c8dcc06b1f9e2ce1821e54dc232d434104f 0028.sql 40f62a18adf0457fc6d35e880eca8be1922f22a2fd8c893c13d59537fe941719 0029.sql 0e0b847088d178290e77913224df82435b86c00062feb47c79351ed014a605a1 0030.sql+d78b8400638b3a9005f4e964f2cb10d3863d60d1038735ed6fa09507a912a26e 0031.sql
+ migrations/0031.sql view
@@ -0,0 +1,39 @@+-- add terminal outbox rejection outcome++ALTER TABLE keiro.keiro_outbox+ ADD COLUMN rejected_at TIMESTAMPTZ,+ ADD COLUMN rejection_code TEXT,+ ADD COLUMN rejection_detail TEXT,+ ADD CONSTRAINT keiro_outbox_rejection_audit_check CHECK (+ (+ status = 'rejected'+ AND rejected_at IS NOT NULL+ AND rejection_code IS NOT NULL+ )+ OR+ (+ status <> 'rejected'+ AND rejected_at IS NULL+ AND rejection_code IS NULL+ AND rejection_detail IS NULL+ )+ ),+ ADD CONSTRAINT keiro_outbox_rejection_code_check CHECK (+ rejection_code IS NULL+ OR rejection_code ~ '^[a-z][a-z0-9._-]{0,63}$'+ ),+ ADD CONSTRAINT keiro_outbox_rejection_detail_check CHECK (+ rejection_detail IS NULL+ OR octet_length(rejection_detail) BETWEEN 1 AND 1024+ );++-- Rejected rows are terminal and must not remain in head-of-line indexes.+DROP INDEX IF EXISTS keiro.keiro_outbox_head_of_line_idx;+CREATE INDEX keiro_outbox_head_of_line_idx+ ON keiro.keiro_outbox (source, message_key, created_at)+ WHERE status NOT IN ('sent', 'dead', 'rejected') AND message_key IS NOT NULL;++DROP INDEX IF EXISTS keiro.keiro_outbox_source_order_idx;+CREATE INDEX keiro_outbox_source_order_idx+ ON keiro.keiro_outbox (source, created_at, outbox_id)+ WHERE status NOT IN ('sent', 'dead', 'rejected');
migrations/manifest view
@@ -28,3 +28,4 @@ 0028.sql 0029.sql 0030.sql+0031.sql
src/Keiro/Migrations/SchemaCheck.hs view
@@ -107,7 +107,7 @@ snapshotSchema schema = Text.unlines <$> Session.statement schema schemaSnapshotStatement --- | PostgreSQL 18 snapshot regenerated through migration 0030.+-- | PostgreSQL 18 snapshot regenerated through migration 0031. expectedSchemaSnapshot :: Text expectedSchemaSnapshot = $(embedTextFile "expected-schema/native/keiro-v18.txt")
test/Main.hs view
@@ -54,7 +54,7 @@ main :: IO () main = hspec $ do describe "native Keiro migration definition" $ do- it "tracks thirty native files in manifest order" $ do+ it "tracks thirty-one native files in manifest order" $ do directory <- findMigrationsDirectory manifest <- Text.lines <$> Text.IO.readFile (directory </> "manifest") manifest `shouldBe` Text.pack <$> nativeMigrationFiles@@ -67,7 +67,7 @@ bytes <- ByteString.readFile (directory </> nativeName) lookup legacyName lockEntries `shouldBe` Just (checksumText bytes) - it "builds component keiro with dependency kiroku and thirty migrations" $ do+ it "builds component keiro with dependency kiroku and thirty-one migrations" $ do plan <- requirePlan let PlanDescription components = planDescription plan case toList components of@@ -80,7 +80,7 @@ componentNameText keiroName `shouldBe` "keiro" dependencyName <- requireRight (componentName "kiroku") keiroDependencies `shouldBe` Set.singleton dependencyName- length keiroEntries `shouldBe` 30+ length keiroEntries `shouldBe` 31 actual -> expectationFailure ("unexpected plan description: " <> show actual) validateHistoryMappingTargets plan frameworkCoddHistoryMappings `shouldBe` Right () @@ -169,7 +169,7 @@ [("9999-fixture.sql", "-- Never set search_path in a migration.\nSELECT 1;")] `shouldBe` [] - it "passes all 30 embedded native bodies" $ do+ it "passes all 31 embedded native bodies" $ do lintViolations config (toList embeddedMigrationEntries) `shouldBe` [] describe "startup handshake" $ do@@ -183,7 +183,7 @@ plan >>= requireRight Keiro.pendingMigrations handshake `shouldBe` planMigrationIds plan- length (Keiro.pendingMigrations handshake) `shouldBe` 41+ length (Keiro.pendingMigrations handshake) `shouldBe` 42 Keiro.ledgerIssues handshake `shouldBe` [] handshakePassed handshake `shouldBe` False @@ -209,7 +209,7 @@ handshake <- missingMigrations defaultRunOptions provider plan >>= requireRight Keiro.pendingMigrations handshake `shouldBe` drop 11 (planMigrationIds plan)- length (Keiro.pendingMigrations handshake) `shouldBe` 30+ length (Keiro.pendingMigrations handshake) `shouldBe` 31 Keiro.ledgerIssues handshake `shouldBe` [] handshakePassed handshake `shouldBe` False @@ -292,12 +292,12 @@ assertSchema connection let provider = providerFor connection rerun <- runMigrationPlanWith defaultRunOptions provider plan >>= requireRight- reportOutcomes rerun `shouldBe` replicate 41 AlreadyApplied+ reportOutcomes rerun `shouldBe` replicate 42 AlreadyApplied verified <- verifyMigrationPlanWith defaultRunOptions provider plan >>= requireRight case verified of VerificationReport verificationIssues applied pending unknown -> do verificationIssues `shouldBe` []- length applied `shouldBe` 41+ length applied `shouldBe` 42 pending `shouldBe` [] unknown `shouldBe` [] either (expectationFailure . show) pure result@@ -346,7 +346,7 @@ (runMigrationPlan defaultRunOptions settings plan >>= requireRight) (runMigrationPlan defaultRunOptions settings plan >>= requireRight) sort [reportOutcomes first, reportOutcomes second]- `shouldBe` sort [replicate 41 AppliedNow, replicate 41 AlreadyApplied]+ `shouldBe` sort [replicate 42 AppliedNow, replicate 42 AlreadyApplied] it "upgrades singleton read-model rows into deterministic rebuild groups" $ do fullPlan <- requirePlan@@ -365,7 +365,7 @@ withConnection settings $ \connection -> useSession connection (Session.script legacyReadModelFixtureSql) report <- runMigrationPlan defaultRunOptions settings fullPlan >>= requireRight- Prelude.drop 32 (reportOutcomes report) `shouldBe` replicate 9 AppliedNow+ Prelude.drop 32 (reportOutcomes report) `shouldBe` replicate 10 AppliedNow withConnection settings $ \connection -> do rows <- useSession connection (Session.statement () legacyGroupUpgradeStatement) rows@@ -391,7 +391,7 @@ withConnection settings $ \connection -> useSession connection (Session.script preCanonicalRebuildFixtureSql) report <- runMigrationPlan defaultRunOptions settings fullPlan >>= requireRight- Prelude.drop 34 (reportOutcomes report) `shouldBe` replicate 7 AppliedNow+ Prelude.drop 34 (reportOutcomes report) `shouldBe` replicate 8 AppliedNow withConnection settings $ \connection -> do rows <- useSession connection (Session.statement () preCanonicalRebuildShapeStatement) rows@@ -416,7 +416,7 @@ withConnection settings $ \connection -> useSession connection (Session.script preStatusContractFixtureSql) report <- runMigrationPlan defaultRunOptions settings fullPlan >>= requireRight- Prelude.drop 36 (reportOutcomes report) `shouldBe` replicate 5 AppliedNow+ Prelude.drop 36 (reportOutcomes report) `shouldBe` replicate 6 AppliedNow withConnection settings $ \connection -> do facts <- useSession connection (Session.statement () preStatusContractFactsStatement) facts `shouldBe` ("unmanaged", 0, "unmanaged", True, True)@@ -439,7 +439,7 @@ withConnection settings $ \connection -> useSession connection (Session.statement () guardFunctionOidStatement) report <- runMigrationPlan defaultRunOptions settings fullPlan >>= requireRight- Prelude.drop 40 (reportOutcomes report) `shouldBe` [AppliedNow]+ Prelude.drop 40 (reportOutcomes report) `shouldBe` replicate 2 AppliedNow withConnection settings $ \connection -> do (afterOid, epochFenced, publicRevoked) <- useSession connection (Session.statement () upgradedGuardFactsStatement)@@ -447,6 +447,35 @@ epochFenced `shouldBe` True publicRevoked `shouldBe` True + it "0031 adds bounded terminal rejection audit constraints to existing outbox rows" $ do+ fullPlan <- requirePlan+ kiroku <- requireRight Kiroku.kirokuMigrations+ priorKeiro <-+ requireRight+ ( migrationComponentFromEmbeddedSql+ "keiro"+ (Set.singleton "kiroku")+ (NonEmpty.fromList (take 30 (toList embeddedMigrationEntries)))+ )+ priorPlan <- requireRight (frameworkMigrationPlan kiroku priorKeiro)+ withKeiroPg $ \database -> do+ let settings = Pg.connectionSettings database+ _ <- runMigrationPlan defaultRunOptions settings priorPlan >>= requireRight+ withConnection settings $ \connection ->+ useSession connection (Session.script preRejectionOutboxFixtureSql)+ report <- runMigrationPlan defaultRunOptions settings fullPlan >>= requireRight+ Prelude.drop 41 (reportOutcomes report) `shouldBe` [AppliedNow]+ withConnection settings $ \connection -> do+ useSession connection (Session.script validRejectionAuditSql)+ missingAudit <- Connection.use connection (Session.script missingRejectionAuditSql)+ missingAudit `shouldSatisfy` isLeft+ invalidCode <- Connection.use connection (Session.script invalidRejectionCodeSql)+ invalidCode `shouldSatisfy` isLeft+ oversizedDetail <- Connection.use connection (Session.script oversizedRejectionDetailSql)+ oversizedDetail `shouldSatisfy` isLeft+ auditOnSent <- Connection.use connection (Session.script rejectionAuditOnSentSql)+ auditOnSent `shouldSatisfy` isLeft+ it "enforces replay source, adapter, and verification membership constraints" $ do plan <- requirePlan result <- withMigratedDatabase plan $ \connection -> do@@ -608,7 +637,7 @@ `shouldBe` replicate 7 AlreadyApplied <> replicate 4 AppliedNow <> replicate 16 AlreadyApplied- <> replicate 14 AppliedNow+ <> replicate 15 AppliedNow verifiedAfterUp <- verifyMigrationPlan defaultRunOptions settings plan >>= requireRight@@ -808,6 +837,72 @@ ('upgrade-run-failed', 'upgrade-failed', repeat('b', 64), 'contract-v2:' || repeat('d', 64), 'keiro/projection-replay/v2', 6, 100, 'failed', now(), 'operator.abandoned', 'abandoned with the old binary'); """ +preRejectionOutboxFixtureSql :: Text+preRejectionOutboxFixtureSql =+ """+ INSERT INTO keiro.keiro_outbox+ (outbox_id, message_id, source, destination, event_type, schema_version,+ content_type, payload_bytes, occurred_at, status)+ SELECT+ ('018f0f18-0000-7000-8000-000000000b0' || ordinal::text)::uuid,+ 'migration-rejection-' || ordinal::text,+ 'migration-test',+ 'sink',+ 'Fixture',+ 1,+ 'application/json',+ '{}'::bytea,+ now(),+ 'publishing'+ FROM generate_series(1, 5) AS ordinal;+ """++validRejectionAuditSql :: Text+validRejectionAuditSql =+ """+ UPDATE keiro.keiro_outbox+ SET status = 'rejected',+ rejected_at = now(),+ rejection_code = 'unsupported.sink',+ rejection_detail = 'the sink cannot accept this event'+ WHERE message_id = 'migration-rejection-1';+ """++missingRejectionAuditSql :: Text+missingRejectionAuditSql =+ """+ UPDATE keiro.keiro_outbox+ SET status = 'rejected'+ WHERE message_id = 'migration-rejection-2';+ """++invalidRejectionCodeSql :: Text+invalidRejectionCodeSql =+ """+ UPDATE keiro.keiro_outbox+ SET status = 'rejected', rejected_at = now(), rejection_code = 'Invalid Code'+ WHERE message_id = 'migration-rejection-3';+ """++oversizedRejectionDetailSql :: Text+oversizedRejectionDetailSql =+ """+ UPDATE keiro.keiro_outbox+ SET status = 'rejected',+ rejected_at = now(),+ rejection_code = 'invalid.payload',+ rejection_detail = repeat('é', 513)+ WHERE message_id = 'migration-rejection-4';+ """++rejectionAuditOnSentSql :: Text+rejectionAuditOnSentSql =+ """+ UPDATE keiro.keiro_outbox+ SET status = 'sent', rejected_at = now(), rejection_code = 'invalid.destination'+ WHERE message_id = 'migration-rejection-5';+ """+ preStatusContractFixtureSql :: Text preStatusContractFixtureSql = """@@ -979,12 +1074,12 @@ `shouldBe` replicate 7 AlreadyApplied <> replicate 4 AppliedNow <> replicate 16 AlreadyApplied- <> replicate 14 AppliedNow+ <> replicate 15 AppliedNow verifiedAfterCanaries <- verifyMigrationPlan defaultRunOptions settings plan >>= requireRight case verifiedAfterCanaries of VerificationReport verificationIssues _ _ _ -> verificationIssues `shouldBe` [] rerun <- runMigrationPlan defaultRunOptions settings plan >>= requireRight- reportOutcomes rerun `shouldBe` replicate 41 AlreadyApplied+ reportOutcomes rerun `shouldBe` replicate 42 AlreadyApplied second <- importCoddHistory defaultImportOptions config provider plan frameworkCoddHistoryMappings >>= requireRight@@ -994,7 +1089,7 @@ sourceRows <- useSession connection (Session.statement () (sourceRowCountStatement sourceSchema)) sourceRows `shouldBe` 23 facts <- useSession connection (Session.statement () importFactsStatement)- facts `shouldBe` (41, 23, True)+ facts `shouldBe` (42, 23, True) postCoddImportPendingIssues :: IO [VerificationIssue] postCoddImportPendingIssues =@@ -1021,7 +1116,8 @@ ("keiro", "0027"), ("keiro", "0028"), ("keiro", "0029"),- ("keiro", "0030")+ ("keiro", "0030"),+ ("keiro", "0031") ] assertPoisonedLedger :: Settings.Settings -> Expectation@@ -1064,7 +1160,8 @@ "0027.sql", "0028.sql", "0029.sql",- "0030.sql"+ "0030.sql",+ "0031.sql" ] findMigrationsDirectory :: IO FilePath