pg-migrate-1.0.0.0: src/Database/PostgreSQL/Migrate/History.hs
-- | Source-agnostic history import. The importer validates evidence and target prefixes
-- under the normal runner lock, then atomically records applied target metadata and audit
-- evidence without executing migration actions.
module Database.PostgreSQL.Migrate.History
( EvidenceKey,
evidenceKey,
EvidenceStrength (..),
SourceTimestamp (..),
ImportEvidence,
ledgerOnlyEvidence,
sourceManifestVerifiedEvidence,
sourceLedgerChecksumVerifiedEvidence,
EvidenceRequirement (..),
PayloadRelation (..),
HistoryMapping,
historyMapping,
historyMappingPayloadRelation,
StateValidationError,
stateValidationError,
StateValidator,
stateValidator,
HistoryImport,
historyImport,
HistoryDefinitionError (..),
EquivalentHistoryPolicy (..),
ImportOptions,
defaultImportOptions,
withEquivalentHistory,
withImportRunOptions,
importEquivalentHistoryPolicy,
HistoryImportOutcome (..),
HistoryImportResult (..),
HistoryImportReport (..),
HistoryValidationError (..),
validateHistoryMappingTargets,
HistoryImportError (..),
importMigrationHistory,
)
where
import Data.Aeson qualified as Aeson
import Data.ByteString.Lazy qualified as LazyByteString
import Data.Map.Strict qualified as Map
import Data.Text qualified as Text
import Data.Time (UTCTime, getCurrentTime)
import Data.Version (showVersion)
import Database.PostgreSQL.Migrate.History.Types
import Database.PostgreSQL.Migrate.History.Validation
import Database.PostgreSQL.Migrate.Ledger
import Database.PostgreSQL.Migrate.Ledger.Migrations
import Database.PostgreSQL.Migrate.Ledger.Sql
import Database.PostgreSQL.Migrate.Ledger.Types
import Database.PostgreSQL.Migrate.Plan (planDescription)
import Database.PostgreSQL.Migrate.Runner (withRunLifecycle)
import Database.PostgreSQL.Migrate.Runner.Types
import Database.PostgreSQL.Migrate.Types
import Hasql.Connection qualified as Connection
import Hasql.Session qualified as Session
import Hasql.Transaction qualified as Transaction
import Hasql.Transaction.Sessions qualified as Transaction.Sessions
import Paths_pg_migrate qualified as Package
import PgMigrate.Prelude
data ClassifiedImport
= ImportPending !ResolvedHistoryMapping
| ImportExisting !ResolvedHistoryMapping
-- | Validate and atomically record imported target rows without executing actions.
importMigrationHistory ::
ImportOptions ->
ConnectionProvider ->
MigrationPlan ->
HistoryImport ->
IO (Either HistoryImportError HistoryImportReport)
importMigrationHistory options provider plan history = do
result <-
withRunLifecycle (importRunOptions options) provider $ \connection ->
Right <$> importLocked options connection plan history
pure $ case result of
Left migrationError -> Left (HistoryImportRunnerError migrationError)
Right imported -> imported
importLocked ::
ImportOptions ->
Connection.Connection ->
MigrationPlan ->
HistoryImport ->
IO (Either HistoryImportError HistoryImportReport)
importLocked options connection plan history = do
initialized <-
runSession
connection
(initializeOrUpgradeLedger activeLedgerConfig historyRunnerVersion)
case initialized of
Left migrationError -> pure (Left (HistoryImportRunnerError migrationError))
Right (Left ledgerError) -> pure (Left (HistoryImportRunnerError (LedgerInitializationFailed ledgerError)))
Right (Right ()) -> do
snapshotResult <- runSession connection (loadLedger activeLedgerConfig)
auditResult <- runSession connection (Session.statement () (loadHistoryImportsStatement activeLedgerConfig))
case (snapshotResult, auditResult) of
(Left migrationError, _) -> pure (Left (HistoryImportRunnerError migrationError))
(_, Left migrationError) -> pure (Left (HistoryImportRunnerError migrationError))
(Right snapshot, Right storedAudits) ->
importAgainstSnapshot options connection plan history snapshot storedAudits
where
activeLedgerConfig = runLedgerConfig (importRunOptions options)
importAgainstSnapshot ::
ImportOptions ->
Connection.Connection ->
MigrationPlan ->
HistoryImport ->
LedgerSnapshot ->
[StoredHistoryImport] ->
IO (Either HistoryImportError HistoryImportReport)
importAgainstSnapshot options connection plan history snapshot storedAudits =
case comparePlanWithLedger RejectUnknownMigrations (planDescription plan) (storedMigrations snapshot) of
verification@VerificationReport {issues = _ : _} ->
pure (Left (HistoryImportRunnerError (PlanVerificationFailed verification)))
VerificationReport {} -> do
validatedEvidence <- runValidators connection history
case validatedEvidence of
Left importError -> pure (Left importError)
Right availableEvidence ->
case resolveHistoryImport (equivalentHistoryPolicy options) plan availableEvidence history of
Left validationError -> pure (Left (HistoryImportValidationFailed validationError))
Right resolved -> do
let classified =
traverse
(classifyImport history snapshot storedAudits)
resolved
case classified of
Left importError -> pure (Left importError)
Right imports -> persistImports options connection history imports
runValidators ::
Connection.Connection ->
HistoryImport ->
IO (Either HistoryImportError (Map EvidenceKey ImportEvidence))
runValidators connection history = go (evidence history) (validators history)
where
go available [] = pure (Right available)
go available (validator : rest) = do
result <-
Connection.use
connection
( Transaction.Sessions.transactionNoRetry
Transaction.Sessions.ReadCommitted
Transaction.Sessions.Read
(runStateValidator validator)
)
case result of
Left sessionError -> pure (Left (HistoryImportRunnerError (DatabaseSessionFailed sessionError)))
Right (Left validationError) ->
pure (Left (HistoryStateValidationFailed (validatorEvidenceKey validator) validationError))
Right (Right details) ->
go
(Map.insert (validatorEvidenceKey validator) (stateVerifiedEvidence (validatorEvidenceKey validator) details) available)
rest
classifyImport ::
HistoryImport ->
LedgerSnapshot ->
[StoredHistoryImport] ->
ResolvedHistoryMapping ->
Either HistoryImportError ClassifiedImport
classifyImport history snapshot storedAudits resolved =
case (Map.lookup identifier migrationsById, Map.lookup identifier auditsById) of
(Nothing, Nothing) -> Right (ImportPending resolved)
(Just storedMigration, Just storedAudit)
| migrationMatches resolved storedMigration && auditMatches history resolved storedAudit ->
Right (ImportExisting resolved)
_ -> Left (HistoryImportConflict identifier)
where
identifier = resolvedTarget resolved
migrationsById = Map.fromList [(storedMigrationId row, row) | row <- storedMigrations snapshot]
auditsById = Map.fromList [(storedHistoryMigrationId row, row) | row <- storedAudits]
migrationMatches :: ResolvedHistoryMapping -> StoredMigration -> Bool
migrationMatches
resolved
StoredMigration
{ position = storedPosition,
checksum = storedChecksum,
kind = storedKind,
transactionMode = storedTransactionMode,
status = storedStatus
} =
storedPosition == fromIntegral (resolvedPosition resolved)
&& storedChecksum == resolvedChecksum resolved
&& storedKind == resolvedKind resolved
&& storedTransactionMode == resolvedTransactionMode resolved
&& storedStatus == Applied
auditMatches :: HistoryImport -> ResolvedHistoryMapping -> StoredHistoryImport -> Bool
auditMatches history resolved stored =
storedHistorySource stored == source history
&& storedHistoryEvidence stored == resolvedAuditEvidence resolved
&& storedHistoryReason stored == reason history
persistImports ::
ImportOptions ->
Connection.Connection ->
HistoryImport ->
NonEmpty ClassifiedImport ->
IO (Either HistoryImportError HistoryImportReport)
persistImports options connection history imports = do
importedAt <- getCurrentTime
let pending = [resolved | ImportPending resolved <- toList imports]
ledgerRows = historyLedgerRow importedAt history <$> pending
activeLedgerConfig = runLedgerConfig (importRunOptions options)
written <-
Connection.use
connection
( Transaction.Sessions.transactionNoRetry
Transaction.Sessions.ReadCommitted
Transaction.Sessions.Write
( for_ ledgerRows $ \row -> do
Transaction.statement row (insertImportedMigrationStatement activeLedgerConfig)
Transaction.statement row (insertHistoryImportAuditStatement activeLedgerConfig)
)
)
pure $ case written of
Left sessionError -> Left (HistoryImportRunnerError (DatabaseSessionFailed sessionError))
Right () ->
Right
( HistoryImportReport
(toResult <$> imports)
)
where
toResult = \case
ImportPending resolved -> HistoryImportResult (resolvedTarget resolved) Imported
ImportExisting resolved -> HistoryImportResult (resolvedTarget resolved) AlreadyImported
historyLedgerRow ::
UTCTime ->
HistoryImport ->
ResolvedHistoryMapping ->
HistoryLedgerRow
historyLedgerRow historyLedgerImportedAt history resolved =
HistoryLedgerRow
{ historyLedgerMigrationId = resolvedTarget resolved,
historyLedgerPosition = resolvedPosition resolved,
historyLedgerChecksum = resolvedChecksum resolved,
historyLedgerKind = resolvedKind resolved,
historyLedgerTransactionMode = resolvedTransactionMode resolved,
historyLedgerImportedAt,
historyLedgerSource = source history,
historyLedgerEvidence = LazyByteString.toStrict (Aeson.encode (resolvedAuditEvidence resolved)),
historyLedgerReason = reason history,
historyLedgerRunnerVersion = historyRunnerVersion
}
runSession ::
Connection.Connection ->
Session.Session value ->
IO (Either MigrationError value)
runSession connection session = first DatabaseSessionFailed <$> Connection.use connection session
historyRunnerVersion :: Text
historyRunnerVersion = Text.pack (showVersion Package.version)