packages feed

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 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