module Main (
main,
)
where
import Codd (ApplyResult (..), CoddSettings (..), VerifySchemas (LaxCheck, StrictCheck))
import Codd.Extras.Guards
import Codd.Parsing (connStringParser)
import Codd.Types (ConnectionString, SchemaAlgo (..), SchemaSelection (..), SqlSchema (..), TxnIsolationLvl (..), singleTryPolicy)
import Contravariant.Extras (contrazip3)
import Control.Concurrent.Async (concurrently)
import Control.Exception (finally)
import Control.Monad (filterM)
import Data.Attoparsec.Text (endOfInput, parseOnly)
import Data.Int (Int32)
import Data.List (isSuffixOf, sort)
import Data.Text (Text)
import Data.Text qualified as T
import Data.Text.IO qualified as TIO
import Data.Time (UTCTime (..), fromGregorian, secondsToDiffTime)
import EphemeralPg qualified as Pg
import Hasql.Connection.Settings qualified as Conn
import Hasql.Decoders qualified as D
import Hasql.Encoders qualified as E
import Hasql.Pool qualified as Pool
import Hasql.Pool.Config qualified as Pool.Config
import Hasql.Session qualified as Session
import Hasql.Statement (Statement, preparable)
import Keiro.Migrations.LegacyCodd (
MigrationStatus (..),
VerifyOutcome (..),
embeddedMigrationNames,
embeddedMigrationSources,
kirokuEmbeddedMigrationNames,
migrationStatus,
missingMigrations,
runAllKeiroMigrations,
runAllKeiroMigrationsNoCheck,
runKirokuMigrationsNoCheck,
verifySchema,
)
import Keiro.Migrations.New (migrationFileName, migrationSlug, newMigrationFile)
import System.Directory (doesDirectoryExist, doesFileExist, listDirectory)
import System.FilePath (takeFileName)
import System.IO.Temp (withSystemTempDirectory)
import Test.Hspec
{- | Pin the ephemeral PostgreSQL superuser to the fixed name @keiro@ so the
captured snapshot identity (roles, database owner, per-object owners) is
deterministic across machines and CI rather than the local OS username. This is
the portability fix for the strict drift gate.
-}
keiroPgConfig :: Pg.Config
keiroPgConfig = Pg.defaultConfig{Pg.user = "keiro"}
{- | Start a cached ephemeral server whose PostgreSQL superuser is the fixed
name @keiro@. Mirrors 'Pg.withCached' but pins the user; 'Pg.withCachedConfig'
is not exported, so we use 'Pg.startCached' + 'finally'.
-}
withKeiroPg :: (Pg.Database -> IO a) -> IO (Either Pg.StartError a)
withKeiroPg action = do
started <- Pg.startCached keiroPgConfig Pg.defaultCacheConfig
case started of
Left err -> pure (Left err)
Right db -> Right <$> (action db `finally` Pg.stop db)
main :: IO ()
main =
hspec $ do
migrationFileNameSpec
migrationIntegritySpec
scaffolderSpec
migrationUpgradeSpec
describe "Keiro codd migrations" $ do
it "applies Kiroku and Keiro migrations to a fresh database and is repeatable" $ do
result <- withKeiroPg $ \db -> do
let connStr = Pg.connectionString db
coddSettings = testCoddSettings connStr "keiro-migrations/expected-schema"
runAllKeiroMigrationsNoCheck coddSettings (secondsToDiffTime 5)
assertTablesExist connStr "kiroku" kirokuTables
assertTablesExist connStr "keiro" keiroTables
assertTablesAbsent connStr "kiroku" keiroTables
assertTablesAbsent connStr "public" keiroTables
assertTablesAbsent connStr "public" kirokuTables
runAllKeiroMigrationsNoCheck coddSettings (secondsToDiffTime 5)
assertTablesExist connStr "kiroku" kirokuTables
assertTablesExist connStr "keiro" keiroTables
assertTablesAbsent connStr "kiroku" keiroTables
assertTablesAbsent connStr "public" keiroTables
assertColumnExists connStr "keiro" "keiro_timers" "last_error"
case result of
Left err -> expectationFailure ("Failed to start ephemeral PostgreSQL: " <> show err)
Right () -> pure ()
it "matches the checked-in expected schema" $ do
expectedSchemaDir <- findExpectedSchemaDir
result <- withKeiroPg $ \db -> do
let coddSettings = testCoddSettings (Pg.connectionString db) expectedSchemaDir
runAllKeiroMigrations coddSettings (secondsToDiffTime 5) StrictCheck
case result of
Left err -> expectationFailure ("Failed to start ephemeral PostgreSQL: " <> show err)
Right (SchemasMatch _) -> pure ()
Right SchemasNotVerified -> expectationFailure "StrictCheck did not verify schemas"
Right (SchemasDiffer _) -> expectationFailure "StrictCheck returned a schema mismatch without throwing"
it "reports schema drift under LaxCheck" $ do
expectedSchemaDir <- findExpectedSchemaDir
result <- withKeiroPg $ \db -> do
let connStr = Pg.connectionString db
coddSettings = testCoddSettings connStr expectedSchemaDir
runAllKeiroMigrationsNoCheck coddSettings (secondsToDiffTime 5)
runDb
connStr
"drift drill"
(Session.script "ALTER TABLE keiro.keiro_timers ALTER COLUMN last_error SET NOT NULL;")
runAllKeiroMigrations coddSettings (secondsToDiffTime 5) LaxCheck
case result of
Left err -> expectationFailure ("Failed to start ephemeral PostgreSQL: " <> show err)
Right (SchemasDiffer _) -> pure ()
Right SchemasNotVerified -> expectationFailure "LaxCheck did not verify schemas"
Right (SchemasMatch _) -> expectationFailure "LaxCheck did not report schema drift"
{- | Guard against the recurring mistake of hand-assigning rounded, sentinel
migration timestamps (e.g. @2026-05-17-00-00-00-...@, @...-01-00-00-...@).
Migrations must be created with @keiro-migrate new@ ("Keiro.Migrations.New"),
which stamps the real current UTC time to the second, so filenames sort in true
authoring order and never collide in codd's timestamp-keyed ledger.
-}
migrationFileNameSpec :: Spec
migrationFileNameSpec =
describe "migration file names" $ do
it "carry real UTC authoring timestamps, not hand-assigned sentinels" $ do
files <- migrationFiles
files `shouldNotBe` []
sentinelViolations files `shouldHaveNoViolations` "sentinel timestamp violations"
it "have unique, strictly increasing timestamps" $ do
files <- migrationFiles
duplicateTimestampViolations files `shouldHaveNoViolations` "duplicate timestamp violations"
migrationIntegritySpec :: Spec
migrationIntegritySpec =
describe "migration integrity guards" $ do
it "embeds exactly the checked-in sql-migrations directory" $ do
diskNames <- sort <$> migrationFiles
embeddedMigrationNames `shouldBe` diskNames
it "matches the checked-in SHA-256 manifest" $ do
manifestPath <- findLockfile
parsed <- parseChecksumManifest <$> TIO.readFile manifestPath
case parsed of
Left err -> expectationFailure (T.unpack err)
Right manifest ->
checksumViolations manifest embeddedMigrationSources
`shouldHaveNoViolations` "checksum manifest violations"
it "keeps future migration bodies schema-qualified and codd-safe" $ do
lintViolations
LintConfig
{ requiredQualifier = "keiro."
, exemptFiles = []
}
embeddedMigrationSources
`shouldHaveNoViolations` "migration body lint violations"
it "keeps timestamps unique across the combined Kiroku and Keiro ledger" $ do
duplicateTimestampViolations expectedLedgerNames
`shouldHaveNoViolations` "combined-ledger duplicate timestamp violations"
it "records every embedded Kiroku and Keiro migration in the codd v5 ledger" $ do
result <- withKeiroPg $ \db -> do
let connStr = Pg.connectionString db
coddSettings = testCoddSettings connStr "keiro-migrations/expected-schema"
runAllKeiroMigrationsNoCheck coddSettings (secondsToDiffTime 5)
schema <- detectLedgerSchema connStr
schema `shouldBe` "codd"
names <- ledgerNames connStr schema
names `shouldBe` map T.pack expectedLedgerNames
case result of
Left err -> expectationFailure ("Failed to start ephemeral PostgreSQL: " <> show err)
Right () -> pure ()
it "serializes concurrent combined applies with the shared advisory lock" $ do
result <- withKeiroPg $ \db -> do
let connStr = Pg.connectionString db
coddSettings = testCoddSettings connStr "keiro-migrations/expected-schema"
concurrently
(runAllKeiroMigrationsNoCheck coddSettings (secondsToDiffTime 5))
(runAllKeiroMigrationsNoCheck coddSettings (secondsToDiffTime 5))
schema <- detectLedgerSchema connStr
names <- ledgerNames connStr schema
names `shouldBe` map T.pack expectedLedgerNames
count <- ledgerRowCount connStr schema
count `shouldBe` fromIntegral (length expectedLedgerNames)
case result of
Left err -> expectationFailure ("Failed to start ephemeral PostgreSQL: " <> show err)
Right () -> pure ()
it "reports every embedded migration as pending on an empty database" $ do
result <- withKeiroPg $ \db -> do
let coddSettings = testCoddSettings (Pg.connectionString db) "keiro-migrations/expected-schema"
verifySchema coddSettings (secondsToDiffTime 5) `shouldReturn` VerifyPending expectedLedgerNames
status <- migrationStatus coddSettings (secondsToDiffTime 5)
statusApplied status `shouldBe` []
statusPending status `shouldBe` expectedLedgerNames
missingMigrations coddSettings (secondsToDiffTime 5) `shouldReturn` expectedLedgerNames
case result of
Left err -> expectationFailure ("Failed to start ephemeral PostgreSQL: " <> show err)
Right () -> pure ()
it "reports only Keiro migrations as pending after Kiroku-only apply" $ do
result <- withKeiroPg $ \db -> do
let coddSettings = testCoddSettings (Pg.connectionString db) "keiro-migrations/expected-schema"
runKirokuMigrationsNoCheck coddSettings (secondsToDiffTime 5)
verifySchema coddSettings (secondsToDiffTime 5) `shouldReturn` VerifyPending embeddedMigrationNames
status <- migrationStatus coddSettings (secondsToDiffTime 5)
map fst (statusApplied status) `shouldBe` kirokuEmbeddedMigrationNames
statusPending status `shouldBe` embeddedMigrationNames
missingMigrations coddSettings (secondsToDiffTime 5) `shouldReturn` embeddedMigrationNames
case result of
Left err -> expectationFailure ("Failed to start ephemeral PostgreSQL: " <> show err)
Right () -> pure ()
it "verifies the embedded Keiro expected schema after combined apply" $ do
result <- withKeiroPg $ \db -> do
let coddSettings = testCoddSettings (Pg.connectionString db) "keiro-migrations/expected-schema"
runAllKeiroMigrationsNoCheck coddSettings (secondsToDiffTime 5)
verifySchema coddSettings (secondsToDiffTime 5) `shouldReturn` VerifySucceeded
status <- migrationStatus coddSettings (secondsToDiffTime 5)
map fst (statusApplied status) `shouldBe` expectedLedgerNames
statusPending status `shouldBe` []
missingMigrations coddSettings (secondsToDiffTime 5) `shouldReturn` []
case result of
Left err -> expectationFailure ("Failed to start ephemeral PostgreSQL: " <> show err)
Right () -> pure ()
it "reports Keiro schema drift without applying migrations" $ do
result <- withKeiroPg $ \db -> do
let connStr = Pg.connectionString db
coddSettings = testCoddSettings connStr "keiro-migrations/expected-schema"
runAllKeiroMigrationsNoCheck coddSettings (secondsToDiffTime 5)
beforeCount <- ledgerRowCount connStr "codd"
runDb connStr "verify drift mutation" (Session.script "CREATE TABLE keiro.verify_drift (id int);")
verifySchema coddSettings (secondsToDiffTime 5) `shouldReturn` VerifyFailed
afterCount <- ledgerRowCount connStr "codd"
afterCount `shouldBe` beforeCount
case result of
Left err -> expectationFailure ("Failed to start ephemeral PostgreSQL: " <> show err)
Right () -> pure ()
it "realigns historical sentinel ledger rows before a repeat migrate" $ do
fixupPath <- findLedgerFixup
fixupScript <- TIO.readFile fixupPath
result <- withKeiroPg $ \db -> do
let connStr = Pg.connectionString db
coddSettings = testCoddSettings connStr "keiro-migrations/expected-schema"
runAllKeiroMigrationsNoCheck coddSettings (secondsToDiffTime 5)
schema <- detectLedgerSchema connStr
rewindKeiroLedgerToSentinelNames connStr schema
sentinelNames <- ledgerNames connStr schema
sentinelNames `shouldSatisfy` any (`elem` map oldLedgerName keiroLedgerRemaps)
runDb connStr "keiro ledger fixup script" (Session.script fixupScript)
fixedNames <- ledgerNames connStr schema
fixedNames `shouldBe` map T.pack expectedLedgerNames
beforeCount <- ledgerRowCount connStr schema
runAllKeiroMigrationsNoCheck coddSettings (secondsToDiffTime 5)
afterCount <- ledgerRowCount connStr schema
afterCount `shouldBe` beforeCount
case result of
Left err -> expectationFailure ("Failed to start ephemeral PostgreSQL: " <> show err)
Right () -> pure ()
{- | Prove the scaffolder (`Keiro.Migrations.New`) is the producer that satisfies
the reactive filename guard. The deterministic check proves the slug convention;
the temp-dir check proves the live writer creates a schema-qualified template.
-}
scaffolderSpec :: Spec
scaffolderSpec =
describe "migration scaffolder" $ do
it "stamps a real, non-sentinel UTC timestamp and a keiro-prefixed slug" $ do
let sampled = UTCTime (fromGregorian 2026 7 5) (secondsToDiffTime (19 * 3600 + 9 * 60 + 18))
name = migrationFileName sampled "Add widget index"
takeFileName name `shouldBe` name
isTimestampShaped (take timestampWidth name) `shouldBe` True
handAssignedTimestamp name `shouldBe` False
migrationSlug "Add widget index" `shouldBe` "keiro-add-widget-index"
it "writes a well-named file into a temp dir with a qualified template" $
withSystemTempDirectory "keiro-scaffolder" $ \dir -> do
path <- newMigrationFile dir "add widget index"
let base = takeFileName path
isTimestampShaped (take timestampWidth base) `shouldBe` True
length base `shouldSatisfy` (> timestampWidth)
body <- TIO.readFile path
(".sql" `isSuffixOf` path) `shouldBe` True
("keiro.keiro_example" `T.isInfixOf` body) `shouldBe` True
("search_path" `T.isInfixOf` body) `shouldBe` False
migrationUpgradeSpec :: Spec
migrationUpgradeSpec =
describe "keiro migration upgrade artifacts" $ do
it "remediates a 0.1.0.0-style kiroku-schema layout without losing rows" $ do
remediationPath <- findRemediationScript
remediationScript <- TIO.readFile remediationPath
expectedSchemaDir <- findExpectedSchemaDir
result <- withKeiroPg $ \db -> do
let connStr = Pg.connectionString db
coddSettings = testCoddSettings connStr expectedSchemaDir
runAllKeiroMigrationsNoCheck coddSettings (secondsToDiffTime 5)
schema <- detectLedgerSchema connStr
moveKeiroTablesBackToKiroku connStr
seedRemediationRows connStr
runDb connStr "keiro schema remediation script" (Session.script remediationScript)
assertTablesExist connStr "keiro" keiroTables
assertTablesAbsent connStr "kiroku" keiroTables
assertSnapshotRowSurvived connStr
assertTimerRowSurvived connStr
beforeCount <- ledgerRowCount connStr schema
runAllKeiroMigrationsNoCheck coddSettings (secondsToDiffTime 5)
afterCount <- ledgerRowCount connStr schema
afterCount `shouldBe` beforeCount
strictResult <- runAllKeiroMigrations coddSettings (secondsToDiffTime 5) StrictCheck
case strictResult of
SchemasMatch _ -> pure ()
SchemasNotVerified -> expectationFailure "StrictCheck did not verify remediated schemas"
SchemasDiffer _ -> expectationFailure "StrictCheck returned a schema mismatch after remediation"
runDb connStr "idempotent keiro schema remediation script" (Session.script remediationScript)
assertTablesExist connStr "keiro" keiroTables
assertTablesAbsent connStr "kiroku" keiroTables
assertSnapshotRowSurvived connStr
assertTimerRowSurvived connStr
case result of
Left err -> expectationFailure ("Failed to start ephemeral PostgreSQL: " <> show err)
Right () -> pure ()
-- | The migration @.sql@ files, wherever the suite is run from.
migrationFiles :: IO [FilePath]
migrationFiles = do
dir <- findMigrationsDir
filter (".sql" `isSuffixOf`) <$> listDirectory dir
findMigrationsDir :: IO FilePath
findMigrationsDir = do
let candidates = ["keiro-migrations/sql-migrations", "sql-migrations"]
existing <- filterM doesDirectoryExist candidates
case existing of
dir : _ -> pure dir
[] ->
expectationFailure "Could not find keiro-migrations/sql-migrations"
>> pure "keiro-migrations/sql-migrations"
findExpectedSchemaDir :: IO FilePath
findExpectedSchemaDir = do
let candidates =
[ "keiro-migrations/expected-schema"
, "expected-schema"
]
existing <- filterM doesDirectoryExist candidates
case existing of
dir : _ -> pure dir
[] ->
expectationFailure "Could not find keiro-migrations/expected-schema"
>> pure "keiro-migrations/expected-schema"
findLockfile :: IO FilePath
findLockfile = findExistingFile ["keiro-migrations/migrations.lock", "migrations.lock"]
findLedgerFixup :: IO FilePath
findLedgerFixup =
findExistingFile
[ "keiro-migrations/ledger-fixups/2026-07-05-realign-keiro-migration-timestamps.sql"
, "ledger-fixups/2026-07-05-realign-keiro-migration-timestamps.sql"
]
findRemediationScript :: IO FilePath
findRemediationScript =
findExistingFile
[ "keiro-migrations/remediation/2026-07-05-relocate-keiro-tables-to-keiro-schema.sql"
, "remediation/2026-07-05-relocate-keiro-tables-to-keiro-schema.sql"
]
findExistingFile :: [FilePath] -> IO FilePath
findExistingFile candidates = do
existing <- filterM doesFileExist candidates
case existing of
path : _ -> pure path
[] -> expectationFailure ("Could not find any of: " <> show candidates) >> pure fallback
where
fallback =
case candidates of
path : _ -> path
[] -> "."
expectedLedgerNames :: [FilePath]
expectedLedgerNames = sort (kirokuEmbeddedMigrationNames <> embeddedMigrationNames)
-- | Kiroku's own event-store tables, which remain in the kiroku schema.
kirokuTables :: [Text]
kirokuTables =
[ "events"
, "stream_events"
, "streams"
, "subscriptions"
]
-- | Keiro's framework tables, which live in the dedicated keiro schema.
keiroTables :: [Text]
keiroTables =
[ "keiro_awakeables"
, "keiro_inbox"
, "keiro_outbox"
, "keiro_projection_dedup"
, "keiro_read_models"
, "keiro_snapshots"
, "keiro_subscription_shards"
, "keiro_timers"
, "keiro_workflow_children"
, "keiro_workflow_steps"
, "keiro_workflows"
]
testCoddSettings :: Text -> FilePath -> CoddSettings
testCoddSettings connStr expectedSchemaDir =
CoddSettings
{ migsConnString = parseConnString connStr
, sqlMigrations = []
, onDiskReps = Left expectedSchemaDir
, namespacesToCheck = IncludeSchemas [SqlSchema "keiro"]
, extraRolesToCheck = []
, retryPolicy = singleTryPolicy
, txnIsolationLvl = DbDefault
, schemaAlgoOpts = SchemaAlgo False False False
}
parseConnString :: Text -> ConnectionString
parseConnString connStr =
case parseOnly (connStringParser <* endOfInput) connStr of
Left err -> error ("Could not parse ephemeral PostgreSQL connection string for codd: " <> err)
Right parsed -> parsed
shouldHaveNoViolations :: [Text] -> String -> Expectation
shouldHaveNoViolations [] _ = pure ()
shouldHaveNoViolations violations label =
expectationFailure (label <> ":\n" <> T.unpack (T.unlines violations))
runDb :: Text -> String -> Session.Session a -> IO a
runDb connStr label session = do
pool <- Pool.acquire poolConfig
result <- Pool.use pool session
Pool.release pool
case result of
Left err -> expectationFailure (label <> " failed: " <> show err) >> fail label
Right value -> pure value
where
poolConfig =
Pool.Config.settings
[ Pool.Config.staticConnectionSettings (Conn.connectionString connStr)
, Pool.Config.size 1
]
detectLedgerSchema :: Text -> IO Text
detectLedgerSchema connStr = do
(hasCodd, hasCoddSchema) <- runDb connStr "ledger schema detection" (Session.statement () ledgerSchemaStmt)
case (hasCodd, hasCoddSchema) of
(True, False) -> pure "codd"
(False, True) -> pure "codd_schema"
(False, False) -> expectationFailure "codd ledger table was not found" >> pure "codd"
(True, True) -> expectationFailure "both codd and codd_schema ledger tables exist" >> pure "codd"
ledgerSchemaStmt :: Statement () (Bool, Bool)
ledgerSchemaStmt =
preparable
"SELECT to_regclass('codd.sql_migrations') IS NOT NULL, to_regclass('codd_schema.sql_migrations') IS NOT NULL"
E.noParams
(D.singleRow ((,) <$> D.column (D.nonNullable D.bool) <*> D.column (D.nonNullable D.bool)))
ledgerNames :: Text -> Text -> IO [Text]
ledgerNames connStr "codd" = runDb connStr "codd ledger names" (Session.statement () ledgerNamesCoddStmt)
ledgerNames connStr "codd_schema" = runDb connStr "codd_schema ledger names" (Session.statement () ledgerNamesCoddSchemaStmt)
ledgerNames _ schema = expectationFailure ("unknown ledger schema " <> T.unpack schema) >> pure []
ledgerNamesCoddStmt :: Statement () [Text]
ledgerNamesCoddStmt =
preparable
"SELECT name::text FROM codd.sql_migrations ORDER BY name"
E.noParams
(D.rowList (D.column (D.nonNullable D.text)))
ledgerNamesCoddSchemaStmt :: Statement () [Text]
ledgerNamesCoddSchemaStmt =
preparable
"SELECT name::text FROM codd_schema.sql_migrations ORDER BY name"
E.noParams
(D.rowList (D.column (D.nonNullable D.text)))
ledgerRowCount :: Text -> Text -> IO Int32
ledgerRowCount connStr "codd" = runDb connStr "codd ledger row count" (Session.statement () ledgerCountCoddStmt)
ledgerRowCount connStr "codd_schema" = runDb connStr "codd_schema ledger row count" (Session.statement () ledgerCountCoddSchemaStmt)
ledgerRowCount _ schema = expectationFailure ("unknown ledger schema " <> T.unpack schema) >> pure 0
ledgerCountCoddStmt :: Statement () Int32
ledgerCountCoddStmt =
preparable
"SELECT count(*)::int FROM codd.sql_migrations"
E.noParams
(D.singleRow (D.column (D.nonNullable D.int4)))
ledgerCountCoddSchemaStmt :: Statement () Int32
ledgerCountCoddSchemaStmt =
preparable
"SELECT count(*)::int FROM codd_schema.sql_migrations"
E.noParams
(D.singleRow (D.column (D.nonNullable D.int4)))
data KeiroLedgerRemap = KeiroLedgerRemap
{ newLedgerName :: Text
, oldLedgerName :: Text
, oldLedgerTimestamp :: Text
}
deriving stock (Eq, Show)
keiroLedgerRemaps :: [KeiroLedgerRemap]
keiroLedgerRemaps =
[ KeiroLedgerRemap "2026-05-17-13-58-15-keiro-bootstrap.sql" "2026-05-17-00-00-00-keiro-bootstrap.sql" "2026-05-17 00:00:00+00"
, KeiroLedgerRemap "2026-05-19-12-55-02-keiro-outbox.sql" "2026-05-17-01-00-00-keiro-outbox.sql" "2026-05-17 01:00:00+00"
, KeiroLedgerRemap "2026-05-19-13-05-23-keiro-inbox.sql" "2026-05-17-02-00-00-keiro-inbox.sql" "2026-05-17 02:00:00+00"
, KeiroLedgerRemap "2026-06-03-05-14-28-keiro-timer-recovery.sql" "2026-05-17-03-00-00-keiro-timer-recovery.sql" "2026-05-17 03:00:00+00"
, KeiroLedgerRemap "2026-06-03-16-10-05-keiro-workflow-steps.sql" "2026-06-03-00-00-00-keiro-workflow-steps.sql" "2026-06-03 00:00:00+00"
, KeiroLedgerRemap "2026-06-03-18-19-41-keiro-awakeables.sql" "2026-06-03-01-00-00-keiro-awakeables.sql" "2026-06-03 01:00:00+00"
, KeiroLedgerRemap "2026-06-03-19-49-23-keiro-workflow-children.sql" "2026-06-03-02-00-00-keiro-workflow-children.sql" "2026-06-03 02:00:00+00"
, KeiroLedgerRemap "2026-06-04-02-12-28-keiro-workflow-generation.sql" "2026-06-05-00-00-00-keiro-workflow-generation.sql" "2026-06-05 00:00:00+00"
, KeiroLedgerRemap "2026-06-04-03-53-34-keiro-subscription-shards.sql" "2026-06-05-01-00-00-keiro-subscription-shards.sql" "2026-06-05 01:00:00+00"
, KeiroLedgerRemap "2026-06-15-15-07-25-keiro-workflows-instances.sql" "2026-06-11-00-00-04-keiro-workflows-instances.sql" "2026-06-11 00:00:04+00"
, KeiroLedgerRemap "2026-06-15-17-53-48-keiro-workflow-gc-index.sql" "2026-06-15-22-10-00-keiro-workflow-gc-index.sql" "2026-06-15 22:10:00+00"
, KeiroLedgerRemap "2026-06-15-18-01-33-keiro-workflows-wake-after.sql" "2026-06-15-22-20-00-keiro-workflows-wake-after.sql" "2026-06-15 22:20:00+00"
, KeiroLedgerRemap "2026-07-02-00-15-48-keiro-outbox-claim-order-index.sql" "2026-07-02-00-12-00-keiro-outbox-claim-order-index.sql" "2026-07-02 00:12:00+00"
, KeiroLedgerRemap "2026-07-02-00-58-54-keiro-inbox-drop-received-idx.sql" "2026-07-02-00-55-00-keiro-inbox-drop-received-idx.sql" "2026-07-02 00:55:00+00"
]
rewindKeiroLedgerToSentinelNames :: Text -> Text -> IO ()
rewindKeiroLedgerToSentinelNames connStr schema =
runDb connStr "keiro ledger rewind to sentinel names" (Session.script script)
where
qname = schema <> ".sql_migrations"
-- Kept in sync with ledger-fixups/2026-07-05-realign-keiro-migration-timestamps.sql.
script =
T.unlines
[ "UPDATE " <> qname <> " SET name = '" <> oldName <> "', migration_timestamp = '" <> oldTimestamp <> "' WHERE name = '" <> newName <> "';"
| KeiroLedgerRemap newName oldName oldTimestamp <- keiroLedgerRemaps
]
moveKeiroTablesBackToKiroku :: Text -> IO ()
moveKeiroTablesBackToKiroku connStr =
runDb connStr "move keiro tables back to kiroku schema" (Session.script script)
where
tableArray = T.intercalate ", " ["'" <> table <> "'" | table <- keiroTables]
script =
T.unlines
[ "DO $$"
, "DECLARE"
, " t text;"
, " tables text[] := ARRAY[" <> tableArray <> "];"
, "BEGIN"
, " FOREACH t IN ARRAY tables LOOP"
, " IF to_regclass('keiro.' || t) IS NOT NULL THEN"
, " EXECUTE format('ALTER TABLE keiro.%I SET SCHEMA kiroku', t);"
, " END IF;"
, " END LOOP;"
, "END"
, "$$;"
, "DROP SCHEMA IF EXISTS keiro;"
]
seedRemediationRows :: Text -> IO ()
seedRemediationRows connStr =
runDb connStr "seed 0.1.0.0-layout keiro rows" (Session.script script)
where
script =
"""
INSERT INTO kiroku.keiro_snapshots
(stream_id, stream_version, state, state_codec_version, regfile_shape_hash)
VALUES
(4242, 7, '{"ok": true}'::jsonb, 3, 'shape-abc');
INSERT INTO kiroku.keiro_timers
(timer_id, process_manager_name, correlation_id, fire_at, payload, status)
VALUES
('00000000-0000-4000-8000-000000000001', 'remediation-test', 'corr-1',
'2026-07-06 00:00:00+00', '{"wake": true}'::jsonb, 'scheduled');
"""
assertSnapshotRowSurvived :: Text -> IO ()
assertSnapshotRowSurvived connStr = do
present <- runDb connStr "snapshot survival query" (Session.statement () snapshotSurvivedStmt)
present `shouldBe` True
snapshotSurvivedStmt :: Statement () Bool
snapshotSurvivedStmt =
preparable
"""
SELECT EXISTS (
SELECT 1
FROM keiro.keiro_snapshots
WHERE stream_id = 4242
AND stream_version = 7
AND state = '{"ok": true}'::jsonb
AND state_codec_version = 3
AND regfile_shape_hash = 'shape-abc'
)
"""
E.noParams
(D.singleRow (D.column (D.nonNullable D.bool)))
assertTimerRowSurvived :: Text -> IO ()
assertTimerRowSurvived connStr = do
present <- runDb connStr "timer survival query" (Session.statement () timerSurvivedStmt)
present `shouldBe` True
timerSurvivedStmt :: Statement () Bool
timerSurvivedStmt =
preparable
"""
SELECT EXISTS (
SELECT 1
FROM keiro.keiro_timers
WHERE timer_id = '00000000-0000-4000-8000-000000000001'
AND process_manager_name = 'remediation-test'
AND correlation_id = 'corr-1'
AND payload = '{"wake": true}'::jsonb
AND status = 'scheduled'
)
"""
E.noParams
(D.singleRow (D.column (D.nonNullable D.bool)))
assertTablesExist :: Text -> Text -> [Text] -> IO ()
assertTablesExist connStr schema tables = do
actualTables <- runDb connStr "table verification query" (Session.statement schema schemaTablesStmt)
let missing = filter (`notElem` actualTables) tables
missing `shouldBe` []
assertTablesAbsent :: Text -> Text -> [Text] -> IO ()
assertTablesAbsent connStr schema tables = do
actualTables <- runDb connStr "table verification query" (Session.statement schema schemaTablesStmt)
let present = filter (`elem` actualTables) tables
present `shouldBe` []
assertColumnExists :: Text -> Text -> Text -> Text -> IO ()
assertColumnExists connStr schema table column = do
present <- runDb connStr "column verification query" (Session.statement (schema, table, column) columnExistsStmt)
present `shouldBe` True
columnExistsStmt :: Statement (Text, Text, Text) Bool
columnExistsStmt =
preparable
"""
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_schema = $1 AND table_name = $2 AND column_name = $3
)
"""
( contrazip3
(E.param (E.nonNullable E.text))
(E.param (E.nonNullable E.text))
(E.param (E.nonNullable E.text))
)
(D.singleRow (D.column (D.nonNullable D.bool)))
schemaTablesStmt :: Statement Text [Text]
schemaTablesStmt =
preparable
"""
SELECT table_name::text
FROM information_schema.tables
WHERE table_schema = $1
AND table_type = 'BASE TABLE'
ORDER BY table_name
"""
(E.param (E.nonNullable E.text))
(D.rowList (D.column (D.nonNullable D.text)))