packages feed

keiro-migrations-0.2.0.0: test-legacy/Main.hs

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