packages feed

kiroku-store 0.12.0.0 → 0.13.0.0

raw patch · 29 files changed

+3024/−11 lines, 29 filesdep +cryptondep +ramdep ~aesondep ~basedep ~generic-lensPVP ok

version bump matches the API change (PVP)

Dependencies added: crypton, ram

Dependency ranges changed: aeson, base, generic-lens, hasql, hasql-pool, lens, text, vector

API changes (from Hackage documentation)

+ Kiroku.Store: KirokuEventCompactionAlreadyApplied :: !CompactionId -> !CompactionDigest -> KirokuEvent
+ Kiroku.Store: KirokuEventCompactionApplied :: !CompactionId -> !CompactionDigest -> !Int64 -> !Int64 -> KirokuEvent
+ Kiroku.Store: KirokuEventCompactionPreviewed :: !CompactionDigest -> !Int64 -> KirokuEvent
+ Kiroku.Store: KirokuEventCompactionRefused :: !CompactionDigest -> !CompactionRefusal -> !Int -> KirokuEvent
+ Kiroku.Store.Compaction: applyCompaction :: forall (es :: [Effect]). Store :> es => CompactionManifest -> Eff es (Either (NonEmpty CompactionRefusal) CompactionApplyResult)
+ Kiroku.Store.Compaction: applyCompactionTx :: CompactionManifest -> Transaction (Either (NonEmpty CompactionRefusal) CompactionApplyResult)
+ Kiroku.Store.Compaction: applyCompactionTxWith :: Transaction () -> CompactionManifest -> Transaction (Either (NonEmpty CompactionRefusal) CompactionApplyResult)
+ Kiroku.Store.Compaction: compactionLedger :: forall (es :: [Effect]). Store :> es => CompactionLedgerQuery -> Eff es (Vector CompactionRecord)
+ Kiroku.Store.Compaction: compactionLedgerTx :: CompactionLedgerQuery -> Transaction (Vector CompactionRecord)
+ Kiroku.Store.Compaction: previewCompaction :: forall (es :: [Effect]). Store :> es => CompactionManifest -> Eff es (Either (NonEmpty CompactionRefusal) CompactionPreview)
+ Kiroku.Store.Compaction: previewCompactionTx :: CompactionManifest -> Transaction (Either (NonEmpty CompactionRefusal) CompactionPreview)
+ Kiroku.Store.Compaction.SQL: CompactionRecordInsert :: !CompactionDigest -> !CompactionDigest -> !StoreIdentity -> !CompactionOperation -> !DeadLetterPolicy -> !CausationPolicy -> !Int64 -> !Int64 -> !Int64 -> !Int64 -> !Int64 -> !Int64 -> !GlobalPosition -> !GlobalPosition -> !Vector StreamHeadWitness -> CompactionRecordInsert
+ Kiroku.Store.Compaction.SQL: [affectedStreams] :: CompactionRecordInsert -> !Vector StreamHeadWitness
+ Kiroku.Store.Compaction.SQL: [causationDependents] :: CompactionRecordInsert -> !Int64
+ Kiroku.Store.Compaction.SQL: [causationPolicy] :: CompactionRecordInsert -> !CausationPolicy
+ Kiroku.Store.Compaction.SQL: [deadLetterPolicy] :: CompactionRecordInsert -> !DeadLetterPolicy
+ Kiroku.Store.Compaction.SQL: [deadLettersRemoved] :: CompactionRecordInsert -> !Int64
+ Kiroku.Store.Compaction.SQL: [globalMemberships] :: CompactionRecordInsert -> !Int64
+ Kiroku.Store.Compaction.SQL: [highestGlobalPosition] :: CompactionRecordInsert -> !GlobalPosition
+ Kiroku.Store.Compaction.SQL: [homeMemberships] :: CompactionRecordInsert -> !Int64
+ Kiroku.Store.Compaction.SQL: [linkMemberships] :: CompactionRecordInsert -> !Int64
+ Kiroku.Store.Compaction.SQL: [lowestGlobalPosition] :: CompactionRecordInsert -> !GlobalPosition
+ Kiroku.Store.Compaction.SQL: [manifestDigest] :: CompactionRecordInsert -> !CompactionDigest
+ Kiroku.Store.Compaction.SQL: [operation] :: CompactionRecordInsert -> !CompactionOperation
+ Kiroku.Store.Compaction.SQL: [reportDigest] :: CompactionRecordInsert -> !CompactionDigest
+ Kiroku.Store.Compaction.SQL: [selectedEvents] :: CompactionRecordInsert -> !Int64
+ Kiroku.Store.Compaction.SQL: [storeId] :: CompactionRecordInsert -> !StoreIdentity
+ Kiroku.Store.Compaction.SQL: causationDependentCountsStmt :: Statement (Vector UUID) (Vector (UUID, Int64))
+ Kiroku.Store.Compaction.SQL: compactionLedgerStmt :: Statement Int32 (Vector CompactionRecord)
+ Kiroku.Store.Compaction.SQL: compactionRecordRow :: Row CompactionRecord
+ Kiroku.Store.Compaction.SQL: data CompactionRecordInsert
+ Kiroku.Store.Compaction.SQL: deadLetterCountsStmt :: Statement (Vector UUID) (Vector (UUID, Int64))
+ Kiroku.Store.Compaction.SQL: deleteJunctionRowsStmt :: Statement (Vector UUID, Vector Int64) (Vector (Int64, Int64))
+ Kiroku.Store.Compaction.SQL: deleteSelectedDeadLettersStmt :: Statement (Vector UUID) Int64
+ Kiroku.Store.Compaction.SQL: deleteSelectedEventsStmt :: Statement (Vector UUID) Int64
+ Kiroku.Store.Compaction.SQL: eventMembershipsStmt :: Statement (Vector UUID) (Vector (UUID, Int64, Text, Int64, Int64, Int64))
+ Kiroku.Store.Compaction.SQL: insertCompactionRecordStmt :: Statement CompactionRecordInsert (UUID, UTCTime, Text)
+ Kiroku.Store.Compaction.SQL: instance GHC.Classes.Eq Kiroku.Store.Compaction.SQL.CompactionRecordInsert
+ Kiroku.Store.Compaction.SQL: instance GHC.Internal.Generics.Generic Kiroku.Store.Compaction.SQL.CompactionRecordInsert
+ Kiroku.Store.Compaction.SQL: instance GHC.Internal.Show.Show Kiroku.Store.Compaction.SQL.CompactionRecordInsert
+ Kiroku.Store.Compaction.SQL: ledgerColumns :: Text
+ Kiroku.Store.Compaction.SQL: ledgerRecordByDigestStmt :: Statement ByteString (Maybe CompactionRecord)
+ Kiroku.Store.Compaction.SQL: lockCompactionStreamsStmt :: Statement (Vector Text) (Vector (Text, Int64, Int64, Maybe UTCTime))
+ Kiroku.Store.Compaction.SQL: resolveStreamsStmt :: Statement (Vector Text) (Vector (Text, Int64, Int64, Bool))
+ Kiroku.Store.Compaction.SQL: survivingSelectedEventsStmt :: Statement (Vector UUID) Int64
+ Kiroku.Store.Compaction.Types: AllowDanglingCausation :: CausationPolicy
+ Kiroku.Store.Compaction.Types: CompactionAcknowledgedLinkMissing :: !EventId -> !LinkWitness -> CompactionRefusal
+ Kiroku.Store.Compaction.Types: CompactionAlreadyApplied :: !CompactionRecord -> CompactionApplyResult
+ Kiroku.Store.Compaction.Types: CompactionAppliedNow :: !CompactionRecord -> CompactionApplyResult
+ Kiroku.Store.Compaction.Types: CompactionCausationDependentsPresent :: !EventId -> !Int64 -> CompactionRefusal
+ Kiroku.Store.Compaction.Types: CompactionDeadLettersPresent :: !EventId -> !Int64 -> CompactionRefusal
+ Kiroku.Store.Compaction.Types: CompactionHistoryRetentionActive :: !HistoryRetentionConflict -> CompactionRefusal
+ Kiroku.Store.Compaction.Types: CompactionId :: UUID -> CompactionId
+ Kiroku.Store.Compaction.Types: CompactionLedgerConflict :: !CompactionId -> !Int64 -> CompactionRefusal
+ Kiroku.Store.Compaction.Types: CompactionLedgerLimitOutOfRange :: !Int32 -> CompactionLedgerError
+ Kiroku.Store.Compaction.Types: CompactionLedgerQuery :: !CompactionLedgerLimit -> CompactionLedgerQuery
+ Kiroku.Store.Compaction.Types: CompactionManifestDigestMismatch :: !CompactionDigest -> !CompactionDigest -> CompactionManifestError
+ Kiroku.Store.Compaction.Types: CompactionManifestDuplicateEvent :: !EventId -> CompactionManifestError
+ Kiroku.Store.Compaction.Types: CompactionManifestDuplicateLink :: !EventId -> !LinkWitness -> CompactionManifestError
+ Kiroku.Store.Compaction.Types: CompactionManifestDuplicateOriginVersion :: !StreamName -> !StreamVersion -> CompactionManifestError
+ Kiroku.Store.Compaction.Types: CompactionManifestDuplicatePosition :: !GlobalPosition -> CompactionManifestError
+ Kiroku.Store.Compaction.Types: CompactionManifestDuplicateStreamHead :: !StreamName -> CompactionManifestError
+ Kiroku.Store.Compaction.Types: CompactionManifestEmpty :: CompactionManifestError
+ Kiroku.Store.Compaction.Types: CompactionManifestInput :: !StoreIdentity -> !CompactionOperation -> !DeadLetterPolicy -> !CausationPolicy -> ![StreamHeadWitness] -> ![CompactionSelection] -> !Maybe CompactionDigest -> CompactionManifestInput
+ Kiroku.Store.Compaction.Types: CompactionManifestInvalidStreamName :: !StreamName -> !Int -> CompactionManifestError
+ Kiroku.Store.Compaction.Types: CompactionManifestLinkIntoOrigin :: !EventId -> CompactionManifestError
+ Kiroku.Store.Compaction.Types: CompactionManifestMissingStreamHead :: !StreamName -> CompactionManifestError
+ Kiroku.Store.Compaction.Types: CompactionManifestNonPositivePosition :: !EventId -> CompactionManifestError
+ Kiroku.Store.Compaction.Types: CompactionManifestNonPositiveVersion :: !EventId -> CompactionManifestError
+ Kiroku.Store.Compaction.Types: CompactionManifestOperationEmpty :: CompactionManifestError
+ Kiroku.Store.Compaction.Types: CompactionManifestOperationTooLong :: !Int -> CompactionManifestError
+ Kiroku.Store.Compaction.Types: CompactionManifestReservedStream :: !StreamName -> CompactionManifestError
+ Kiroku.Store.Compaction.Types: CompactionManifestTooLarge :: !Int -> CompactionManifestError
+ Kiroku.Store.Compaction.Types: CompactionManifestUnusedStreamHead :: !StreamName -> CompactionManifestError
+ Kiroku.Store.Compaction.Types: CompactionPreviewAlreadyApplied :: !CompactionRecord -> CompactionPreview
+ Kiroku.Store.Compaction.Types: CompactionPreviewReady :: !CompactionReport -> CompactionPreview
+ Kiroku.Store.Compaction.Types: CompactionRecord :: !CompactionId -> !CompactionReport -> !UTCTime -> !Text -> CompactionRecord
+ Kiroku.Store.Compaction.Types: CompactionReport :: !CompactionDigest -> !StoreIdentity -> !CompactionOperation -> !DeadLetterPolicy -> !CausationPolicy -> !Int64 -> !Int64 -> !Int64 -> !Int64 -> !Int64 -> !Int64 -> !GlobalPosition -> !GlobalPosition -> !Vector StreamHeadWitness -> !CompactionDigest -> CompactionReport
+ Kiroku.Store.Compaction.Types: CompactionSelectedEventMissing :: !EventId -> CompactionRefusal
+ Kiroku.Store.Compaction.Types: CompactionSelection :: !EventId -> !StreamName -> !StreamVersion -> !GlobalPosition -> !Vector LinkWitness -> CompactionSelection
+ Kiroku.Store.Compaction.Types: CompactionStoreIdentityMismatch :: !StoreIdentity -> !StoreIdentity -> CompactionRefusal
+ Kiroku.Store.Compaction.Types: CompactionStreamHeadDrift :: !StreamName -> !StreamVersion -> !StreamVersion -> CompactionRefusal
+ Kiroku.Store.Compaction.Types: CompactionStreamMissing :: !StreamName -> CompactionRefusal
+ Kiroku.Store.Compaction.Types: CompactionStreamSoftDeleted :: !StreamName -> CompactionRefusal
+ Kiroku.Store.Compaction.Types: CompactionUnexpectedLink :: !EventId -> !LinkWitness -> CompactionRefusal
+ Kiroku.Store.Compaction.Types: CompactionWitnessMismatch :: !EventId -> !WitnessMismatch -> CompactionRefusal
+ Kiroku.Store.Compaction.Types: LinkWitness :: !StreamName -> !StreamVersion -> LinkWitness
+ Kiroku.Store.Compaction.Types: RefuseCausationDependents :: CausationPolicy
+ Kiroku.Store.Compaction.Types: RefuseDeadLetters :: DeadLetterPolicy
+ Kiroku.Store.Compaction.Types: RemoveDeadLetters :: DeadLetterPolicy
+ Kiroku.Store.Compaction.Types: StreamHeadWitness :: !StreamName -> !StreamVersion -> StreamHeadWitness
+ Kiroku.Store.Compaction.Types: WitnessMismatch :: !StreamName -> !StreamVersion -> !Maybe GlobalPosition -> WitnessMismatch
+ Kiroku.Store.Compaction.Types: [acknowledgedLinks] :: CompactionSelection -> !Vector LinkWitness
+ Kiroku.Store.Compaction.Types: [actualGlobalPosition] :: WitnessMismatch -> !Maybe GlobalPosition
+ Kiroku.Store.Compaction.Types: [actualHead] :: CompactionRefusal -> !StreamVersion
+ Kiroku.Store.Compaction.Types: [actualIdentity] :: CompactionRefusal -> !StoreIdentity
+ Kiroku.Store.Compaction.Types: [actualOriginStream] :: WitnessMismatch -> !StreamName
+ Kiroku.Store.Compaction.Types: [actualOriginVersion] :: WitnessMismatch -> !StreamVersion
+ Kiroku.Store.Compaction.Types: [affectedStreams] :: CompactionReport -> !Vector StreamHeadWitness
+ Kiroku.Store.Compaction.Types: [appliedAt] :: CompactionRecord -> !UTCTime
+ Kiroku.Store.Compaction.Types: [appliedBy] :: CompactionRecord -> !Text
+ Kiroku.Store.Compaction.Types: [causationDependents] :: CompactionReport -> !Int64
+ Kiroku.Store.Compaction.Types: [causationPolicy] :: CompactionReport -> !CausationPolicy
+ Kiroku.Store.Compaction.Types: [compactionId] :: CompactionRecord -> !CompactionId
+ Kiroku.Store.Compaction.Types: [computed] :: CompactionManifestError -> !CompactionDigest
+ Kiroku.Store.Compaction.Types: [deadLetterPolicy] :: CompactionReport -> !DeadLetterPolicy
+ Kiroku.Store.Compaction.Types: [deadLettersRemoved] :: CompactionReport -> !Int64
+ Kiroku.Store.Compaction.Types: [eventId] :: CompactionSelection -> !EventId
+ Kiroku.Store.Compaction.Types: [expectedDigest] :: CompactionManifestInput -> !Maybe CompactionDigest
+ Kiroku.Store.Compaction.Types: [expectedHead] :: CompactionRefusal -> !StreamVersion
+ Kiroku.Store.Compaction.Types: [expectedIdentity] :: CompactionRefusal -> !StoreIdentity
+ Kiroku.Store.Compaction.Types: [expected] :: CompactionManifestError -> !CompactionDigest
+ Kiroku.Store.Compaction.Types: [globalMemberships] :: CompactionReport -> !Int64
+ Kiroku.Store.Compaction.Types: [globalPosition] :: CompactionSelection -> !GlobalPosition
+ Kiroku.Store.Compaction.Types: [headVersion] :: StreamHeadWitness -> !StreamVersion
+ Kiroku.Store.Compaction.Types: [highestGlobalPosition] :: CompactionReport -> !GlobalPosition
+ Kiroku.Store.Compaction.Types: [homeMemberships] :: CompactionReport -> !Int64
+ Kiroku.Store.Compaction.Types: [limit] :: CompactionLedgerQuery -> !CompactionLedgerLimit
+ Kiroku.Store.Compaction.Types: [linkMemberships] :: CompactionReport -> !Int64
+ Kiroku.Store.Compaction.Types: [lowestGlobalPosition] :: CompactionReport -> !GlobalPosition
+ Kiroku.Store.Compaction.Types: [manifestDigest] :: CompactionReport -> !CompactionDigest
+ Kiroku.Store.Compaction.Types: [operation] :: CompactionReport -> !CompactionOperation
+ Kiroku.Store.Compaction.Types: [originStream] :: CompactionSelection -> !StreamName
+ Kiroku.Store.Compaction.Types: [originVersion] :: CompactionSelection -> !StreamVersion
+ Kiroku.Store.Compaction.Types: [reportDigest] :: CompactionReport -> !CompactionDigest
+ Kiroku.Store.Compaction.Types: [report] :: CompactionRecord -> !CompactionReport
+ Kiroku.Store.Compaction.Types: [selectedEvents] :: CompactionReport -> !Int64
+ Kiroku.Store.Compaction.Types: [selections] :: CompactionManifestInput -> ![CompactionSelection]
+ Kiroku.Store.Compaction.Types: [storeIdentity] :: CompactionReport -> !StoreIdentity
+ Kiroku.Store.Compaction.Types: [streamHeads] :: CompactionManifestInput -> ![StreamHeadWitness]
+ Kiroku.Store.Compaction.Types: [streamVersion] :: LinkWitness -> !StreamVersion
+ Kiroku.Store.Compaction.Types: [stream] :: CompactionRefusal -> !StreamName
+ Kiroku.Store.Compaction.Types: [survivingEvents] :: CompactionRefusal -> !Int64
+ Kiroku.Store.Compaction.Types: canonicalCompactionManifestBytes :: CompactionManifest -> ByteString
+ Kiroku.Store.Compaction.Types: canonicalCompactionReportBytes :: CompactionReport -> ByteString
+ Kiroku.Store.Compaction.Types: compactionDigestBytes :: CompactionDigest -> ByteString
+ Kiroku.Store.Compaction.Types: compactionDigestHex :: CompactionDigest -> Text
+ Kiroku.Store.Compaction.Types: compactionLedgerLimitValue :: CompactionLedgerLimit -> Int32
+ Kiroku.Store.Compaction.Types: compactionManifestDigest :: CompactionManifest -> CompactionDigest
+ Kiroku.Store.Compaction.Types: compactionOperationText :: CompactionOperation -> Text
+ Kiroku.Store.Compaction.Types: compactionReportDigest :: CompactionReport -> CompactionDigest
+ Kiroku.Store.Compaction.Types: data CausationPolicy
+ Kiroku.Store.Compaction.Types: data CompactionApplyResult
+ Kiroku.Store.Compaction.Types: data CompactionDigest
+ Kiroku.Store.Compaction.Types: data CompactionLedgerError
+ Kiroku.Store.Compaction.Types: data CompactionLedgerLimit
+ Kiroku.Store.Compaction.Types: data CompactionLedgerQuery
+ Kiroku.Store.Compaction.Types: data CompactionManifest
+ Kiroku.Store.Compaction.Types: data CompactionManifestError
+ Kiroku.Store.Compaction.Types: data CompactionManifestInput
+ Kiroku.Store.Compaction.Types: data CompactionOperation
+ Kiroku.Store.Compaction.Types: data CompactionPreview
+ Kiroku.Store.Compaction.Types: data CompactionRecord
+ Kiroku.Store.Compaction.Types: data CompactionRefusal
+ Kiroku.Store.Compaction.Types: data CompactionReport
+ Kiroku.Store.Compaction.Types: data CompactionSelection
+ Kiroku.Store.Compaction.Types: data DeadLetterPolicy
+ Kiroku.Store.Compaction.Types: data LinkWitness
+ Kiroku.Store.Compaction.Types: data StreamHeadWitness
+ Kiroku.Store.Compaction.Types: data WitnessMismatch
+ Kiroku.Store.Compaction.Types: instance Data.Aeson.Types.FromJSON.FromJSON Kiroku.Store.Compaction.Types.CausationPolicy
+ Kiroku.Store.Compaction.Types: instance Data.Aeson.Types.FromJSON.FromJSON Kiroku.Store.Compaction.Types.CompactionDigest
+ Kiroku.Store.Compaction.Types: instance Data.Aeson.Types.FromJSON.FromJSON Kiroku.Store.Compaction.Types.CompactionId
+ Kiroku.Store.Compaction.Types: instance Data.Aeson.Types.FromJSON.FromJSON Kiroku.Store.Compaction.Types.CompactionManifest
+ Kiroku.Store.Compaction.Types: instance Data.Aeson.Types.FromJSON.FromJSON Kiroku.Store.Compaction.Types.CompactionOperation
+ Kiroku.Store.Compaction.Types: instance Data.Aeson.Types.FromJSON.FromJSON Kiroku.Store.Compaction.Types.CompactionRecord
+ Kiroku.Store.Compaction.Types: instance Data.Aeson.Types.FromJSON.FromJSON Kiroku.Store.Compaction.Types.CompactionRefusal
+ Kiroku.Store.Compaction.Types: instance Data.Aeson.Types.FromJSON.FromJSON Kiroku.Store.Compaction.Types.CompactionReport
+ Kiroku.Store.Compaction.Types: instance Data.Aeson.Types.FromJSON.FromJSON Kiroku.Store.Compaction.Types.CompactionSelection
+ Kiroku.Store.Compaction.Types: instance Data.Aeson.Types.FromJSON.FromJSON Kiroku.Store.Compaction.Types.DeadLetterPolicy
+ Kiroku.Store.Compaction.Types: instance Data.Aeson.Types.FromJSON.FromJSON Kiroku.Store.Compaction.Types.LinkWitness
+ Kiroku.Store.Compaction.Types: instance Data.Aeson.Types.FromJSON.FromJSON Kiroku.Store.Compaction.Types.StreamHeadWitness
+ Kiroku.Store.Compaction.Types: instance Data.Aeson.Types.FromJSON.FromJSON Kiroku.Store.Compaction.Types.WitnessMismatch
+ Kiroku.Store.Compaction.Types: instance Data.Aeson.Types.ToJSON.ToJSON Kiroku.Store.Compaction.Types.CausationPolicy
+ Kiroku.Store.Compaction.Types: instance Data.Aeson.Types.ToJSON.ToJSON Kiroku.Store.Compaction.Types.CompactionDigest
+ Kiroku.Store.Compaction.Types: instance Data.Aeson.Types.ToJSON.ToJSON Kiroku.Store.Compaction.Types.CompactionId
+ Kiroku.Store.Compaction.Types: instance Data.Aeson.Types.ToJSON.ToJSON Kiroku.Store.Compaction.Types.CompactionManifest
+ Kiroku.Store.Compaction.Types: instance Data.Aeson.Types.ToJSON.ToJSON Kiroku.Store.Compaction.Types.CompactionOperation
+ Kiroku.Store.Compaction.Types: instance Data.Aeson.Types.ToJSON.ToJSON Kiroku.Store.Compaction.Types.CompactionRecord
+ Kiroku.Store.Compaction.Types: instance Data.Aeson.Types.ToJSON.ToJSON Kiroku.Store.Compaction.Types.CompactionRefusal
+ Kiroku.Store.Compaction.Types: instance Data.Aeson.Types.ToJSON.ToJSON Kiroku.Store.Compaction.Types.CompactionReport
+ Kiroku.Store.Compaction.Types: instance Data.Aeson.Types.ToJSON.ToJSON Kiroku.Store.Compaction.Types.CompactionSelection
+ Kiroku.Store.Compaction.Types: instance Data.Aeson.Types.ToJSON.ToJSON Kiroku.Store.Compaction.Types.DeadLetterPolicy
+ Kiroku.Store.Compaction.Types: instance Data.Aeson.Types.ToJSON.ToJSON Kiroku.Store.Compaction.Types.LinkWitness
+ Kiroku.Store.Compaction.Types: instance Data.Aeson.Types.ToJSON.ToJSON Kiroku.Store.Compaction.Types.StreamHeadWitness
+ Kiroku.Store.Compaction.Types: instance Data.Aeson.Types.ToJSON.ToJSON Kiroku.Store.Compaction.Types.WitnessMismatch
+ Kiroku.Store.Compaction.Types: instance GHC.Classes.Eq Kiroku.Store.Compaction.Types.CausationPolicy
+ Kiroku.Store.Compaction.Types: instance GHC.Classes.Eq Kiroku.Store.Compaction.Types.CompactionApplyResult
+ Kiroku.Store.Compaction.Types: instance GHC.Classes.Eq Kiroku.Store.Compaction.Types.CompactionDigest
+ Kiroku.Store.Compaction.Types: instance GHC.Classes.Eq Kiroku.Store.Compaction.Types.CompactionId
+ Kiroku.Store.Compaction.Types: instance GHC.Classes.Eq Kiroku.Store.Compaction.Types.CompactionLedgerError
+ Kiroku.Store.Compaction.Types: instance GHC.Classes.Eq Kiroku.Store.Compaction.Types.CompactionLedgerLimit
+ Kiroku.Store.Compaction.Types: instance GHC.Classes.Eq Kiroku.Store.Compaction.Types.CompactionLedgerQuery
+ Kiroku.Store.Compaction.Types: instance GHC.Classes.Eq Kiroku.Store.Compaction.Types.CompactionManifest
+ Kiroku.Store.Compaction.Types: instance GHC.Classes.Eq Kiroku.Store.Compaction.Types.CompactionManifestError
+ Kiroku.Store.Compaction.Types: instance GHC.Classes.Eq Kiroku.Store.Compaction.Types.CompactionManifestInput
+ Kiroku.Store.Compaction.Types: instance GHC.Classes.Eq Kiroku.Store.Compaction.Types.CompactionOperation
+ Kiroku.Store.Compaction.Types: instance GHC.Classes.Eq Kiroku.Store.Compaction.Types.CompactionPreview
+ Kiroku.Store.Compaction.Types: instance GHC.Classes.Eq Kiroku.Store.Compaction.Types.CompactionRecord
+ Kiroku.Store.Compaction.Types: instance GHC.Classes.Eq Kiroku.Store.Compaction.Types.CompactionRefusal
+ Kiroku.Store.Compaction.Types: instance GHC.Classes.Eq Kiroku.Store.Compaction.Types.CompactionReport
+ Kiroku.Store.Compaction.Types: instance GHC.Classes.Eq Kiroku.Store.Compaction.Types.CompactionSelection
+ Kiroku.Store.Compaction.Types: instance GHC.Classes.Eq Kiroku.Store.Compaction.Types.DeadLetterPolicy
+ Kiroku.Store.Compaction.Types: instance GHC.Classes.Eq Kiroku.Store.Compaction.Types.LinkWitness
+ Kiroku.Store.Compaction.Types: instance GHC.Classes.Eq Kiroku.Store.Compaction.Types.StreamHeadWitness
+ Kiroku.Store.Compaction.Types: instance GHC.Classes.Eq Kiroku.Store.Compaction.Types.WitnessMismatch
+ Kiroku.Store.Compaction.Types: instance GHC.Classes.Ord Kiroku.Store.Compaction.Types.CausationPolicy
+ Kiroku.Store.Compaction.Types: instance GHC.Classes.Ord Kiroku.Store.Compaction.Types.CompactionDigest
+ Kiroku.Store.Compaction.Types: instance GHC.Classes.Ord Kiroku.Store.Compaction.Types.CompactionId
+ Kiroku.Store.Compaction.Types: instance GHC.Classes.Ord Kiroku.Store.Compaction.Types.CompactionLedgerLimit
+ Kiroku.Store.Compaction.Types: instance GHC.Classes.Ord Kiroku.Store.Compaction.Types.CompactionOperation
+ Kiroku.Store.Compaction.Types: instance GHC.Classes.Ord Kiroku.Store.Compaction.Types.DeadLetterPolicy
+ Kiroku.Store.Compaction.Types: instance GHC.Internal.Enum.Bounded Kiroku.Store.Compaction.Types.CausationPolicy
+ Kiroku.Store.Compaction.Types: instance GHC.Internal.Enum.Bounded Kiroku.Store.Compaction.Types.DeadLetterPolicy
+ Kiroku.Store.Compaction.Types: instance GHC.Internal.Enum.Enum Kiroku.Store.Compaction.Types.CausationPolicy
+ Kiroku.Store.Compaction.Types: instance GHC.Internal.Enum.Enum Kiroku.Store.Compaction.Types.DeadLetterPolicy
+ Kiroku.Store.Compaction.Types: instance GHC.Internal.Generics.Generic Kiroku.Store.Compaction.Types.CausationPolicy
+ Kiroku.Store.Compaction.Types: instance GHC.Internal.Generics.Generic Kiroku.Store.Compaction.Types.CompactionApplyResult
+ Kiroku.Store.Compaction.Types: instance GHC.Internal.Generics.Generic Kiroku.Store.Compaction.Types.CompactionDigest
+ Kiroku.Store.Compaction.Types: instance GHC.Internal.Generics.Generic Kiroku.Store.Compaction.Types.CompactionId
+ Kiroku.Store.Compaction.Types: instance GHC.Internal.Generics.Generic Kiroku.Store.Compaction.Types.CompactionLedgerError
+ Kiroku.Store.Compaction.Types: instance GHC.Internal.Generics.Generic Kiroku.Store.Compaction.Types.CompactionLedgerLimit
+ Kiroku.Store.Compaction.Types: instance GHC.Internal.Generics.Generic Kiroku.Store.Compaction.Types.CompactionLedgerQuery
+ Kiroku.Store.Compaction.Types: instance GHC.Internal.Generics.Generic Kiroku.Store.Compaction.Types.CompactionManifest
+ Kiroku.Store.Compaction.Types: instance GHC.Internal.Generics.Generic Kiroku.Store.Compaction.Types.CompactionManifestError
+ Kiroku.Store.Compaction.Types: instance GHC.Internal.Generics.Generic Kiroku.Store.Compaction.Types.CompactionManifestInput
+ Kiroku.Store.Compaction.Types: instance GHC.Internal.Generics.Generic Kiroku.Store.Compaction.Types.CompactionOperation
+ Kiroku.Store.Compaction.Types: instance GHC.Internal.Generics.Generic Kiroku.Store.Compaction.Types.CompactionPreview
+ Kiroku.Store.Compaction.Types: instance GHC.Internal.Generics.Generic Kiroku.Store.Compaction.Types.CompactionRecord
+ Kiroku.Store.Compaction.Types: instance GHC.Internal.Generics.Generic Kiroku.Store.Compaction.Types.CompactionRefusal
+ Kiroku.Store.Compaction.Types: instance GHC.Internal.Generics.Generic Kiroku.Store.Compaction.Types.CompactionReport
+ Kiroku.Store.Compaction.Types: instance GHC.Internal.Generics.Generic Kiroku.Store.Compaction.Types.CompactionSelection
+ Kiroku.Store.Compaction.Types: instance GHC.Internal.Generics.Generic Kiroku.Store.Compaction.Types.DeadLetterPolicy
+ Kiroku.Store.Compaction.Types: instance GHC.Internal.Generics.Generic Kiroku.Store.Compaction.Types.LinkWitness
+ Kiroku.Store.Compaction.Types: instance GHC.Internal.Generics.Generic Kiroku.Store.Compaction.Types.StreamHeadWitness
+ Kiroku.Store.Compaction.Types: instance GHC.Internal.Generics.Generic Kiroku.Store.Compaction.Types.WitnessMismatch
+ Kiroku.Store.Compaction.Types: instance GHC.Internal.Show.Show Kiroku.Store.Compaction.Types.CausationPolicy
+ Kiroku.Store.Compaction.Types: instance GHC.Internal.Show.Show Kiroku.Store.Compaction.Types.CompactionApplyResult
+ Kiroku.Store.Compaction.Types: instance GHC.Internal.Show.Show Kiroku.Store.Compaction.Types.CompactionDigest
+ Kiroku.Store.Compaction.Types: instance GHC.Internal.Show.Show Kiroku.Store.Compaction.Types.CompactionId
+ Kiroku.Store.Compaction.Types: instance GHC.Internal.Show.Show Kiroku.Store.Compaction.Types.CompactionLedgerError
+ Kiroku.Store.Compaction.Types: instance GHC.Internal.Show.Show Kiroku.Store.Compaction.Types.CompactionLedgerLimit
+ Kiroku.Store.Compaction.Types: instance GHC.Internal.Show.Show Kiroku.Store.Compaction.Types.CompactionLedgerQuery
+ Kiroku.Store.Compaction.Types: instance GHC.Internal.Show.Show Kiroku.Store.Compaction.Types.CompactionManifest
+ Kiroku.Store.Compaction.Types: instance GHC.Internal.Show.Show Kiroku.Store.Compaction.Types.CompactionManifestError
+ Kiroku.Store.Compaction.Types: instance GHC.Internal.Show.Show Kiroku.Store.Compaction.Types.CompactionManifestInput
+ Kiroku.Store.Compaction.Types: instance GHC.Internal.Show.Show Kiroku.Store.Compaction.Types.CompactionOperation
+ Kiroku.Store.Compaction.Types: instance GHC.Internal.Show.Show Kiroku.Store.Compaction.Types.CompactionPreview
+ Kiroku.Store.Compaction.Types: instance GHC.Internal.Show.Show Kiroku.Store.Compaction.Types.CompactionRecord
+ Kiroku.Store.Compaction.Types: instance GHC.Internal.Show.Show Kiroku.Store.Compaction.Types.CompactionRefusal
+ Kiroku.Store.Compaction.Types: instance GHC.Internal.Show.Show Kiroku.Store.Compaction.Types.CompactionReport
+ Kiroku.Store.Compaction.Types: instance GHC.Internal.Show.Show Kiroku.Store.Compaction.Types.CompactionSelection
+ Kiroku.Store.Compaction.Types: instance GHC.Internal.Show.Show Kiroku.Store.Compaction.Types.DeadLetterPolicy
+ Kiroku.Store.Compaction.Types: instance GHC.Internal.Show.Show Kiroku.Store.Compaction.Types.LinkWitness
+ Kiroku.Store.Compaction.Types: instance GHC.Internal.Show.Show Kiroku.Store.Compaction.Types.StreamHeadWitness
+ Kiroku.Store.Compaction.Types: instance GHC.Internal.Show.Show Kiroku.Store.Compaction.Types.WitnessMismatch
+ Kiroku.Store.Compaction.Types: manifestCausationPolicy :: CompactionManifest -> CausationPolicy
+ Kiroku.Store.Compaction.Types: manifestDeadLetterPolicy :: CompactionManifest -> DeadLetterPolicy
+ Kiroku.Store.Compaction.Types: manifestOperation :: CompactionManifest -> CompactionOperation
+ Kiroku.Store.Compaction.Types: manifestSelections :: CompactionManifest -> Vector CompactionSelection
+ Kiroku.Store.Compaction.Types: manifestStoreIdentity :: CompactionManifest -> StoreIdentity
+ Kiroku.Store.Compaction.Types: manifestStreamHeads :: CompactionManifest -> Vector StreamHeadWitness
+ Kiroku.Store.Compaction.Types: manifestStreamNames :: CompactionManifest -> Vector Text
+ Kiroku.Store.Compaction.Types: maxCompactionSelections :: Int
+ Kiroku.Store.Compaction.Types: mkCompactionDigest :: ByteString -> Maybe CompactionDigest
+ Kiroku.Store.Compaction.Types: mkCompactionLedgerLimit :: Int32 -> Either CompactionLedgerError CompactionLedgerLimit
+ Kiroku.Store.Compaction.Types: mkCompactionManifest :: CompactionManifestInput -> Either CompactionManifestError CompactionManifest
+ Kiroku.Store.Compaction.Types: mkCompactionOperation :: Text -> Either CompactionManifestError CompactionOperation
+ Kiroku.Store.Compaction.Types: newtype CompactionId
+ Kiroku.Store.Compaction.Types: parseCompactionDigestHex :: Text -> Maybe CompactionDigest
+ Kiroku.Store.Compaction.Types: sealCompactionReport :: CompactionReport -> CompactionReport
+ Kiroku.Store.Effect: [ApplyCompaction] :: forall (a :: Type -> Type). CompactionManifest -> Store a (Either (NonEmpty CompactionRefusal) CompactionApplyResult)
+ Kiroku.Store.Effect: [GetCompactionLedger] :: forall (a :: Type -> Type). CompactionLedgerQuery -> Store a (Vector CompactionRecord)
+ Kiroku.Store.Effect: [GetStoreIdentity] :: forall (a :: Type -> Type). Store a StoreIdentity
+ Kiroku.Store.Effect: [LookupEventReferences] :: forall (a :: Type -> Type). [EventId] -> Store a (Map EventId EventReferenceInventory)
+ Kiroku.Store.Effect: [PreviewCompaction] :: forall (a :: Type -> Type). CompactionManifest -> Store a (Either (NonEmpty CompactionRefusal) CompactionPreview)
+ Kiroku.Store.Effect: assembleEventReferences :: Vector (UUID, Int64, Text, Int64, Int64, Int64) -> Vector (UUID, Int64) -> Vector (UUID, Int64) -> Map EventId EventReferenceInventory
+ Kiroku.Store.Effect: chunkUuids :: Int -> Vector UUID -> [Vector UUID]
+ Kiroku.Store.Effect: maxEventReferenceBatch :: Int
+ Kiroku.Store.Error: CompactionConcurrentMutation :: !CompactionDigest -> !Text -> StoreError
+ Kiroku.Store.Observability: KirokuEventCompactionAlreadyApplied :: !CompactionId -> !CompactionDigest -> KirokuEvent
+ Kiroku.Store.Observability: KirokuEventCompactionApplied :: !CompactionId -> !CompactionDigest -> !Int64 -> !Int64 -> KirokuEvent
+ Kiroku.Store.Observability: KirokuEventCompactionPreviewed :: !CompactionDigest -> !Int64 -> KirokuEvent
+ Kiroku.Store.Observability: KirokuEventCompactionRefused :: !CompactionDigest -> !CompactionRefusal -> !Int -> KirokuEvent
+ Kiroku.Store.Read: lookupEventReferences :: forall (es :: [Effect]). (HasCallStack, Store :> es) => [EventId] -> Eff es (Map EventId EventReferenceInventory)
+ Kiroku.Store.Read: storeIdentity :: forall (es :: [Effect]). (HasCallStack, Store :> es) => Eff es StoreIdentity
+ Kiroku.Store.SQL: causationDependentCountsStmt :: Statement (Vector UUID) (Vector (UUID, Int64))
+ Kiroku.Store.SQL: deadLetterCountsStmt :: Statement (Vector UUID) (Vector (UUID, Int64))
+ Kiroku.Store.SQL: eventMembershipsStmt :: Statement (Vector UUID) (Vector (UUID, Int64, Text, Int64, Int64, Int64))
+ Kiroku.Store.SQL: storeIdentityStmt :: Statement () StoreIdentity
+ Kiroku.Store.Transaction: lookupEventReferencesTx :: [EventId] -> Transaction (Map EventId EventReferenceInventory)
+ Kiroku.Store.Transaction: storeIdentityTx :: Transaction StoreIdentity
+ Kiroku.Store.Types: EventReferenceInventory :: !Vector EventMembership -> !Int64 -> !Int64 -> EventReferenceInventory
+ Kiroku.Store.Types: GlobalMembership :: !GlobalPosition -> EventMembership
+ Kiroku.Store.Types: HomeMembership :: !StreamName -> !StreamId -> !StreamVersion -> EventMembership
+ Kiroku.Store.Types: LinkMembership :: !StreamName -> !StreamId -> !StreamVersion -> EventMembership
+ Kiroku.Store.Types: StoreIdentity :: UUID -> StoreIdentity
+ Kiroku.Store.Types: [causationDependentCount] :: EventReferenceInventory -> !Int64
+ Kiroku.Store.Types: [deadLetterCount] :: EventReferenceInventory -> !Int64
+ Kiroku.Store.Types: [memberships] :: EventReferenceInventory -> !Vector EventMembership
+ Kiroku.Store.Types: data EventMembership
+ Kiroku.Store.Types: data EventReferenceInventory
+ Kiroku.Store.Types: instance Data.Aeson.Types.FromJSON.FromJSON Kiroku.Store.Types.StoreIdentity
+ Kiroku.Store.Types: instance Data.Aeson.Types.ToJSON.ToJSON Kiroku.Store.Types.StoreIdentity
+ Kiroku.Store.Types: instance GHC.Classes.Eq Kiroku.Store.Types.EventMembership
+ Kiroku.Store.Types: instance GHC.Classes.Eq Kiroku.Store.Types.EventReferenceInventory
+ Kiroku.Store.Types: instance GHC.Classes.Eq Kiroku.Store.Types.StoreIdentity
+ Kiroku.Store.Types: instance GHC.Classes.Ord Kiroku.Store.Types.StoreIdentity
+ Kiroku.Store.Types: instance GHC.Internal.Generics.Generic Kiroku.Store.Types.EventMembership
+ Kiroku.Store.Types: instance GHC.Internal.Generics.Generic Kiroku.Store.Types.EventReferenceInventory
+ Kiroku.Store.Types: instance GHC.Internal.Generics.Generic Kiroku.Store.Types.StoreIdentity
+ Kiroku.Store.Types: instance GHC.Internal.Show.Show Kiroku.Store.Types.EventMembership
+ Kiroku.Store.Types: instance GHC.Internal.Show.Show Kiroku.Store.Types.EventReferenceInventory
+ Kiroku.Store.Types: instance GHC.Internal.Show.Show Kiroku.Store.Types.StoreIdentity
+ Kiroku.Store.Types: newtype StoreIdentity

Files

CHANGELOG.md view
@@ -1,5 +1,49 @@ # Changelog +## 0.13.0.0 — 2026-10-28++### Breaking Changes++- The closed `Store` effect gains `GetStoreIdentity`, `LookupEventReferences`,+  `PreviewCompaction`, `ApplyCompaction` and `GetCompactionLedger`; exhaustive+  custom interpreters must add arms.+- `StoreError` gains retryable `CompactionConcurrentMutation`. An affected apply+  transaction rolled back; preview again before retrying. Direct transaction+  composers can also receive existing `UnexpectedServerError` SQLSTATE `23503`+  for a concurrent foreign-key reference and must treat it identically.+- Exhaustive `KirokuEvent` observers must handle `KirokuEventCompactionPreviewed`,+  `KirokuEventCompactionRefused`, `KirokuEventCompactionApplied` and+  `KirokuEventCompactionAlreadyApplied`.++### New Features++- Add `Kiroku.Store.Compaction`, `.Types` and `.SQL`: immutable digest-sealed+  manifests, explicit reference policies, typed refusals, canonical SHA-256+  encodings, deterministic reports and durable ledger records. JSON decoding+  verifies manifest and report digests.+- Add read-only `previewCompaction` / `previewCompactionTx`. The effect uses one+  pooled connection and one repeatable-read snapshot without row locks or writes.+- Add atomic `applyCompaction` / `applyCompactionTx`: revalidate exact witnesses+  under the retention coordinator then ascending stream-ID locks, refuse active+  replay-history leases, and delete only selected events and acknowledged links.+  Retained envelopes, versions, positions and stream heads stay unchanged; ordinary+  append/read SQL and triggers gain no work. Causation refusal is validation-time+  best-effort. Reference/count races roll back the whole transaction.+- Add `compactionLedger` / `compactionLedgerTx`. The append-only record commits+  with deletion; an identical reapply returns that original record. Keep the+  reviewed manifest and archived envelopes because the ledger omits event IDs.+- Add `StoreIdentity`, `storeIdentity` and `storeIdentityTx`. Restored clones share+  their source identity. These APIs and compaction require migration `0017` from+  `kiroku-store-migrations` 0.7.3.0; older schemas report a missing relation.+- Add `lookupEventReferences` / `lookupEventReferencesTx`: home/global/link+  memberships, dead-letter counts and causation dependents outside the requested+  set. Unknown IDs are omitted, duplicates deduplicated and empty input uses no+  connection. Membership and dead-letter reads batch at 10,000 IDs on one checkout;+  causation exclusions cover the complete request.+- Document the reviewed workflow, lease margins, 10k initial operational chunks,+  audit and separate PostgreSQL space reclamation in the+  [operator guide](https://github.com/shinzui/kiroku/blob/master/docs/user/compaction.md).+ ## 0.12.0.0 — 2026-10-26  ### Breaking Changes
+ bench/CompactionCost.hs view
@@ -0,0 +1,77 @@+{-# LANGUAGE OverloadedRecordDot #-}++-- A bounded maintenance-path sizing probe, not a statistical regression gate.+module Main (main) where++import Control.Lens ((^.))+import Control.Monad (forM_, unless)+import Data.Aeson (object, (.=))+import Data.Generics.Labels ()+import Data.Text qualified as T+import Data.Vector qualified as V+import GHC.Clock (getMonotonicTimeNSec)+import Hasql.Pool qualified as Pool+import Hasql.Session qualified as Session+import Kiroku.Store hiding (id)+import Kiroku.Test.Postgres (withMigratedTestDatabase, withSharedMigratedPostgres)+import Text.Printf (printf)++main :: IO ()+main = withSharedMigratedPostgres $ do+  putStrLn "selections,link_memberships,dead_letters,apply_wall_seconds,coordinator_hold_upper_bound_seconds,microseconds_per_selection,report_digest"+  forM_ [10000, 50000, 100000 :: Int] $ \size ->+    withMigratedTestDatabase $ \conn -> withStore (defaultConnectionSettings conn) $ \store -> do+      let origin = StreamName "cost-origin"+          target = StreamName "cost-links"+      forM_ [0, 2500 .. size - 1] $ \offset -> do+        let events =+              [ EventData+                  Nothing+                  (EventType "CompactionCost")+                  (object ["ordinal" .= n, "payload" .= T.replicate 256 "x"])+                  Nothing+                  Nothing+                  Nothing+              | n <- [offset + 1 .. min size (offset + 2500)]+              ]+        _ <- require =<< runStoreIO store (appendToStream origin AnyVersion events)+        pure ()+      events <- require =<< runStoreIO store (readAllForward (GlobalPosition 0) (fromIntegral size))+      let linked = V.filter (\e -> let GlobalPosition p = e.globalPosition in p `mod` 10 == 0) events+      _ <- require =<< runStoreIO store (linkToStream target (map (\e -> e.eventId) (V.toList linked)))+      require+        =<< Pool.use+          (store ^. #pool)+          ( Session.script+              "INSERT INTO dead_letters(subscription_name,consumer_group_member,global_position,event_id,reason,reason_summary,attempt_count) SELECT 'cost',0,stream_version,event_id,'{}'::jsonb,'cost fixture',1 FROM stream_events WHERE stream_id=0 AND stream_version % 10=0"+          )+      identity <- require =<< runStoreIO store storeIdentity+      op <- either (fail . show) pure (mkCompactionOperation ("cost-" <> T.pack (show size)))+      let selections =+            [ CompactionSelection+                e.eventId+                origin+                e.originalVersion+                e.globalPosition+                (let GlobalPosition pos = e.globalPosition in if pos `mod` 10 == 0 then V.singleton (LinkWitness target (StreamVersion (pos `div` 10))) else V.empty)+            | e <- V.toList events+            ]+          heads = [StreamHeadWitness origin (StreamVersion (fromIntegral size)), StreamHeadWitness target (StreamVersion (fromIntegral (size `div` 10)))]+      manifest <- either (fail . show) pure (mkCompactionManifest (CompactionManifestInput identity op RemoveDeadLetters RefuseCausationDependents heads selections Nothing))+      start <- getMonotonicTimeNSec+      result <- require =<< runStoreIO store (applyCompaction manifest)+      end <- getMonotonicTimeNSec+      record <- case result of+        Right (CompactionAppliedNow value) -> pure value+        other -> fail (show other)+      let seconds = fromIntegral (end - start) / 1e9 :: Double+          report = record.report+      unless (report.selectedEvents == fromIntegral size && report.linkMemberships == fromIntegral (size `div` 10) && report.deadLettersRemoved == fromIntegral (size `div` 10)) (fail "count mismatch")+      ledger <- require =<< runStoreIO store (compactionLedger (CompactionLedgerQuery (either (error . show) id (mkCompactionLedgerLimit 10))))+      unless (ledger == V.singleton record && report.reportDigest == compactionReportDigest report) (fail "ledger mismatch")+      repeated <- require =<< runStoreIO store (applyCompaction manifest)+      unless (repeated == Right (CompactionAlreadyApplied record)) (fail "reapply mismatch")+      printf "%d,%d,%d,%.6f,%.6f,%.3f,%s\n" size (size `div` 10) (size `div` 10) seconds seconds (seconds * 1e6 / fromIntegral size) (T.unpack (compactionDigestHex report.reportDigest))++require :: (Show e) => Either e a -> IO a+require = either (fail . show) pure
kiroku-store.cabal view
@@ -1,6 +1,6 @@ cabal-version:   3.0 name:            kiroku-store-version:         0.12.0.0+version:         0.13.0.0 synopsis:        High-performance PostgreSQL event store description:   Kiroku is a PostgreSQL-backed event store for Haskell applications. It@@ -38,6 +38,9 @@     Kiroku.Store     Kiroku.Store.Append     Kiroku.Store.Causation+    Kiroku.Store.Compaction+    Kiroku.Store.Compaction.SQL+    Kiroku.Store.Compaction.Types     Kiroku.Store.Connection     Kiroku.Store.Effect     Kiroku.Store.Effect.Resource@@ -63,6 +66,7 @@     Kiroku.Store.Types    other-modules:+    Kiroku.Store.Compaction.Internal     Kiroku.Store.HistoryRetention.Internal     Kiroku.Store.HistoryRetention.SQL     Kiroku.Store.Subscription.Checkpoint.SQL@@ -76,6 +80,7 @@     , bytestring            >=0.11  && <0.13     , containers            >=0.6   && <0.8     , contravariant-extras  >=0.3   && <0.4+    , crypton               >=1.1.5 && <2.3     , effectful-core        >=2.6.1 && <2.7  || >=2.7.1.1 && <2.8     , generic-lens          >=2.2   && <2.4     , hasql                 >=1.10  && <1.11@@ -85,6 +90,7 @@     , lens                  >=5.2   && <5.4     , mmzk-typeid           >=0.6   && <0.8     , mtl                   >=2.3   && <2.4+    , ram                   >=0.22  && <0.23     , stm                   >=2.5   && <2.6     , streamly-core         >=0.3   && <0.4     , text                  >=2.0   && <2.2@@ -109,12 +115,20 @@     Test.Category     Test.CategoryIdleNoSpin     Test.Causation+    Test.CompactionApply+    Test.CompactionApplyMock+    Test.CompactionHelpers+    Test.CompactionManifest+    Test.CompactionPreview+    Test.CompactionPreviewMock     Test.Concurrency     Test.ConsumerGroup     Test.ConsumerGroupEffect     Test.ConsumerGroupResize     Test.ConsumerGroupSql     Test.DeadLetterQueryPlans+    Test.EventReferences+    Test.EventReferencesMock     Test.EventTypeFilter     Test.FailureInjection     Test.HandlerStall@@ -133,6 +147,8 @@     Test.ReadStream     Test.ScriptSessionErrors     Test.StartupFailureSurfacing+    Test.StoreIdentity+    Test.StoreIdentityMock     Test.StreamBridgeTermination     Test.StreamHead     Test.StreamHeadIsolation@@ -361,3 +377,21 @@     , text                 >=2.0   && <2.2     , time                 >=1.12  && <1.15     , vector               >=0.13  && <0.14++benchmark kiroku-compaction-cost+  import:         common+  type:           exitcode-stdio-1.0+  main-is:        CompactionCost.hs+  hs-source-dirs: bench+  ghc-options:    -threaded -rtsopts "-with-rtsopts=-N2 -A32m"+  build-depends:+    , aeson+    , base+    , generic-lens+    , hasql+    , hasql-pool+    , kiroku-store+    , kiroku-test-support+    , lens+    , text+    , vector
src/Kiroku/Store.hs view
@@ -8,6 +8,7 @@ -- explicitly to use the effectful version. module Kiroku.Store   ( module Kiroku.Store.Types,+    module Kiroku.Store.Compaction,     module Kiroku.Store.Connection,     module Kiroku.Store.Effect,     module Kiroku.Store.Effect.Resource,@@ -52,6 +53,7 @@ import Hasql.Pool.Observation (ConnectionReadyForUseReason (..), ConnectionStatus (..), ConnectionTerminationReason (..), Observation (..)) import Kiroku.Store.Append import Kiroku.Store.Causation+import Kiroku.Store.Compaction import Kiroku.Store.Connection import Kiroku.Store.Effect import Kiroku.Store.Effect.Resource
+ src/Kiroku/Store/Compaction.hs view
@@ -0,0 +1,74 @@+-- | Explicit selective physical compaction. Preview is read-only, takes no row+-- locks, and reports discrepancies under one snapshot. Apply revalidates under+-- the retention coordinator and ordered stream locks before deleting anything.+-- Unlike logical truncate-before, compaction physically removes selected events;+-- unlike whole-stream hard delete, it keeps streams live and never renumbers+-- retained history. PostgreSQL vacuum/rewrite work controls space reclamation;+-- apply does not shrink relation files. See @docs/user/compaction.md@.+module Kiroku.Store.Compaction+  ( module Kiroku.Store.Compaction.Types,+    previewCompaction,+    previewCompactionTx,+    applyCompaction,+    applyCompactionTx,+    compactionLedger,+    compactionLedgerTx,++    -- * Failure-injection seam+    applyCompactionTxWith,+  )+where++import Data.List.NonEmpty (NonEmpty)+import Data.Vector (Vector)+import Effectful (Eff, (:>))+import Effectful.Dispatch.Dynamic (send)+import Hasql.Transaction qualified as Tx+import Kiroku.Store.Compaction.Internal qualified as Internal+import Kiroku.Store.Compaction.Types+import Kiroku.Store.Effect (Store (..))++-- | Read-only preview on one pooled connection and one repeatable-read snapshot.+-- Reports active leases without waiting for them. Causation refusal is a+-- validation-time check: causation IDs have no foreign key and dependents may+-- be appended to unlisted streams after validation.+previewCompaction :: (Store :> es) => CompactionManifest -> Eff es (Either (NonEmpty CompactionRefusal) CompactionPreview)+previewCompaction = send . PreviewCompaction++-- | Preview inside the caller's transaction. Takes no row locks and writes+-- nothing. The caller chooses isolation; use repeatable read for one snapshot.+previewCompactionTx :: CompactionManifest -> Tx.Transaction (Either (NonEmpty CompactionRefusal) CompactionPreview)+previewCompactionTx = Internal.previewCompactionTx++-- | Apply one manifest atomically. Takes the retention coordinator first,+-- refuses active leases, recognizes recorded digests before locking streams,+-- then locks affected streams by ascending internal ID and revalidates every+-- witness before deletion. Retained rows and stream heads are unchanged; no+-- NOTIFY fires. A repeated recorded manifest returns its original ledger record.+-- Concurrent references abort the whole transaction: re-preview and re-apply.+applyCompaction :: (Store :> es) => CompactionManifest -> Eff es (Either (NonEmpty CompactionRefusal) CompactionApplyResult)+applyCompaction = send . ApplyCompaction++-- | Apply inside the caller's write transaction. Lease work must precede stream+-- guards to preserve the coordinator-before-stream lock order. Count races+-- raise 'Kiroku.Store.Error.CompactionConcurrentMutation'; a concurrent foreign+-- key reference may surface as 'Kiroku.Store.Error.UnexpectedServerError' with+-- SQLSTATE @23503@ for direct composers. Both mean rollback and retry after a+-- fresh preview. Causation refusal is validation-time best-effort.+applyCompactionTx :: CompactionManifest -> Tx.Transaction (Either (NonEmpty CompactionRefusal) CompactionApplyResult)+applyCompactionTx = Internal.applyCompactionTx++-- | Newest durable audit records first, with a validated limit of 1–1000.+-- Records preserve stored digests verbatim for audit; the ledger omits event IDs.+compactionLedger :: (Store :> es) => CompactionLedgerQuery -> Eff es (Vector CompactionRecord)+compactionLedger = send . GetCompactionLedger++-- | Ledger inventory inside the caller's transaction.+compactionLedgerTx :: CompactionLedgerQuery -> Tx.Transaction (Vector CompactionRecord)+compactionLedgerTx = Internal.compactionLedgerTx++-- | Failure-injection barrier after validation and before the first DELETE.+-- Production 'applyCompactionTx' uses @pure ()@ and adds no statement. A barrier+-- must remain SQL-only when the caller enables automatic transaction retries.+applyCompactionTxWith :: Tx.Transaction () -> CompactionManifest -> Tx.Transaction (Either (NonEmpty CompactionRefusal) CompactionApplyResult)+applyCompactionTxWith = Internal.applyCompactionTxWith
+ src/Kiroku/Store/Compaction/Internal.hs view
@@ -0,0 +1,264 @@+{-# LANGUAGE OverloadedRecordDot #-}+{-# LANGUAGE NoFieldSelectors #-}++module Kiroku.Store.Compaction.Internal+  ( ValidatedCompaction (..),+    ValidationOutcome (..),+    LedgerOutcome (..),+    ledgerOutcomeTx,+    validateCompactionTx,+    buildReport,+    previewCompactionTx,+    applyCompactionTx,+    applyCompactionTxWith,+    compactionLedgerTx,+  )+where++import Control.Monad (unless, void)+import Data.Int (Int64)+import Data.List (find)+import Data.List.NonEmpty (NonEmpty (..))+import Data.List.NonEmpty qualified as NE+import Data.Map.Strict qualified as Map+import Data.Set qualified as Set+import Data.Text.Encoding qualified as TE+import Data.UUID (UUID)+import Data.Vector (Vector)+import Data.Vector qualified as V+import GHC.Generics (Generic)+import Hasql.Transaction qualified as Tx+import Kiroku.Store.Compaction.SQL qualified as SQL+import Kiroku.Store.Compaction.Types+import Kiroku.Store.HistoryRetention.Internal qualified as Retention+import Kiroku.Store.SQL qualified as StoreSQL+import Kiroku.Store.Types++data ValidatedCompaction = ValidatedCompaction+  { report :: !CompactionReport,+    selectedEventIds :: !(Vector UUID),+    junctionRows :: !(Vector (UUID, Int64))+  }+  deriving stock (Eq, Show, Generic)++data ValidationOutcome+  = ValidationRefused (NonEmpty CompactionRefusal)+  | ValidationAlreadyApplied CompactionRecord+  | ValidationReady ValidatedCompaction+  deriving stock (Eq, Show)++data LedgerOutcome = LedgerUnrecorded | LedgerAlreadyApplied CompactionRecord | LedgerConflict CompactionId Int64+  deriving stock (Eq, Show)++selectedIds :: CompactionManifest -> Vector UUID+selectedIds = V.map (\s -> let EventId uid = s.eventId in uid) . manifestSelections++ledgerOutcomeTx :: CompactionManifest -> Tx.Transaction LedgerOutcome+ledgerOutcomeTx manifest = do+  found <- Tx.statement (compactionDigestBytes (compactionManifestDigest manifest)) SQL.ledgerRecordByDigestStmt+  case found of+    Nothing -> pure LedgerUnrecorded+    Just record -> do+      survivors <- Tx.statement (selectedIds manifest) SQL.survivingSelectedEventsStmt+      pure (if survivors == 0 then LedgerAlreadyApplied record else LedgerConflict record.compactionId survivors)++-- No import of Store.Transaction: it imports Effect, which consumes this module.+validateCompactionTx :: CompactionManifest -> Tx.Transaction ValidationOutcome+validateCompactionTx manifest = do+  identity <- Tx.statement () StoreSQL.storeIdentityStmt+  if identity /= manifestStoreIdentity manifest+    then pure (ValidationRefused (CompactionStoreIdentityMismatch (manifestStoreIdentity manifest) identity :| []))+    else do+      conflict <- Retention.activeHistoryRetentionConflictTx+      let leaseRefusals = maybe [] (pure . CompactionHistoryRetentionActive) conflict+      ledger <- ledgerOutcomeTx manifest+      case ledger of+        LedgerAlreadyApplied record -> pure $ case NE.nonEmpty leaseRefusals of+          Just refusals -> ValidationRefused refusals+          Nothing -> ValidationAlreadyApplied record+        LedgerConflict cid count -> pure (ValidationRefused (CompactionLedgerConflict cid count :| []))+        LedgerUnrecorded -> validateWitnessesTx manifest leaseRefusals++validateWitnessesTx :: CompactionManifest -> [CompactionRefusal] -> Tx.Transaction ValidationOutcome+validateWitnessesTx manifest leaseRefusals = do+  streams <- Tx.statement (manifestStreamNames manifest) SQL.resolveStreamsStmt+  let resolved = Map.fromList [(StreamName name, (sid, ver, deleted)) | (name, sid, ver, deleted) <- V.toList streams]+      allSelections = V.toList (manifestSelections manifest)+      resolvable = filter (\s -> Map.member s.originStream resolved) allSelections+      ids = V.fromList [uid | s <- resolvable, let EventId uid = s.eventId]+      streamRefusals = concatMap (checkStream resolved) (V.toList (manifestStreamHeads manifest))+  rows <- if V.null ids then pure V.empty else Tx.statement ids SQL.eventMembershipsStmt+  let grouped = Map.map reverse (Map.fromListWith (<>) [(EventId uid, [(sid, StreamName name, ver, origin, originVer)]) | (uid, sid, name, ver, origin, originVer) <- V.toList rows])+      knownNames =+        Map.fromList [(sid, StreamName name) | (name, sid, _, _) <- V.toList streams]+          <> Map.fromList [(sid, StreamName name) | (_, sid, name, _, _, _) <- V.toList rows]+      missingOriginIds = Set.toList (Set.fromList [origin | (_, _, _, _, origin, _) <- V.toList rows, Map.notMember origin knownNames])+  extraNames <- if null missingOriginIds then pure V.empty else Tx.statement missingOriginIds StoreSQL.lookupStreamNamesStmt+  -- The causation exclusion must cover the whole manifest, even when another+  -- selected event has an invalid witness. Report policies only for verified rows.+  deadLetters <- Tx.statement (selectedIds manifest) SQL.deadLetterCountsStmt+  dependents <- Tx.statement (selectedIds manifest) SQL.causationDependentCountsStmt+  let names = knownNames <> Map.fromList [(sid, StreamName name) | (sid, name) <- V.toList extraNames]+      deadMap = Map.fromList (V.toList deadLetters)+      dependentMap = Map.fromList (V.toList dependents)+      results = map (checkSelection resolved grouped names deadMap dependentMap) resolvable+      refusals = leaseRefusals <> streamRefusals <> concatMap fst results+      pairs = concatMap snd results+      links = sum [fromIntegral (V.length s.acknowledgedLinks) | s <- allSelections]+      deadCount = if manifestDeadLetterPolicy manifest == RemoveDeadLetters then sum (Map.elems deadMap) else 0+      causeCount = if manifestCausationPolicy manifest == AllowDanglingCausation then sum (Map.elems dependentMap) else 0+  pure $ case NE.nonEmpty refusals of+    Just found -> ValidationRefused found+    Nothing -> ValidationReady (ValidatedCompaction (buildReport manifest links deadCount causeCount) (selectedIds manifest) (V.fromList pairs))+  where+    checkStream resolved h = case Map.lookup h.stream resolved of+      Nothing -> [CompactionStreamMissing h.stream]+      Just (_, ver, deleted) ->+        [CompactionStreamSoftDeleted h.stream | deleted]+          <> [CompactionStreamHeadDrift h.stream h.headVersion (StreamVersion ver) | StreamVersion ver /= h.headVersion]+    checkSelection resolved grouped names deadMap dependentMap s =+      case Map.findWithDefault [] s.eventId grouped of+        [] -> ([CompactionSelectedEventMissing s.eventId], [])+        liveRows@((_, _, _, actualOrigin, actualVersion) : _) ->+          let EventId uid = s.eventId+              originId = maybe (-1) (\(sid, _, _) -> sid) (Map.lookup s.originStream resolved)+              actualName = Map.findWithDefault s.originStream actualOrigin names+              global = fmap (\(_, _, v, _, _) -> GlobalPosition v) (find (\(sid, _, _, _, _) -> sid == 0) liveRows)+              home = find (\(sid, _, _, _, _) -> sid == originId) liveRows+              matchesHome = case home of+                Just (_, _, ver, origin, originVer) -> origin == originId && StreamVersion ver == s.originVersion && StreamVersion originVer == s.originVersion+                Nothing -> False+              originsMatch = all (\(_, _, _, origin, ver) -> origin == originId && StreamVersion ver == s.originVersion) liveRows+              witnesses =+                [ CompactionWitnessMismatch s.eventId (WitnessMismatch actualName (StreamVersion actualVersion) global)+                | not (matchesHome && originsMatch && global == Just s.globalPosition)+                ]+              actualLinks = [(LinkWitness name (StreamVersion ver), sid) | (sid, name, ver, origin, _) <- liveRows, sid /= 0, sid /= origin]+              expectedLinks = V.toList s.acknowledgedLinks+              key (LinkWitness name ver) = (name, ver)+              actualKeys = Set.fromList (map (key . fst) actualLinks)+              expectedKeys = Set.fromList (map key expectedLinks)+              linkRefusals =+                [CompactionUnexpectedLink s.eventId link | (link, _) <- actualLinks, Set.notMember (key link) expectedKeys]+                  <> [CompactionAcknowledgedLinkMissing s.eventId link | link <- expectedLinks, Map.member link.stream resolved, Set.notMember (key link) actualKeys]+              verified = null witnesses && null linkRefusals+              dead = Map.findWithDefault 0 uid deadMap+              cause = Map.findWithDefault 0 uid dependentMap+              referenceRefusals =+                if not verified+                  then []+                  else+                    [CompactionDeadLettersPresent s.eventId dead | dead > 0, manifestDeadLetterPolicy manifest == RefuseDeadLetters]+                      <> [CompactionCausationDependentsPresent s.eventId cause | cause > 0, manifestCausationPolicy manifest == RefuseCausationDependents]+              pairs = if verified then (uid, originId) : (uid, 0) : [(uid, sid) | (_, sid) <- actualLinks] else []+           in (witnesses <> linkRefusals <> referenceRefusals, pairs)++buildReport :: CompactionManifest -> Int64 -> Int64 -> Int64 -> CompactionReport+buildReport manifest links dead cause =+  sealCompactionReport $+    CompactionReport+      digest+      (manifestStoreIdentity manifest)+      (manifestOperation manifest)+      (manifestDeadLetterPolicy manifest)+      (manifestCausationPolicy manifest)+      count+      count+      count+      links+      dead+      cause+      (V.head selections).globalPosition+      (V.last selections).globalPosition+      (manifestStreamHeads manifest)+      digest+  where+    selections = manifestSelections manifest+    count = fromIntegral (V.length selections)+    digest = compactionManifestDigest manifest++previewCompactionTx :: CompactionManifest -> Tx.Transaction (Either (NonEmpty CompactionRefusal) CompactionPreview)+previewCompactionTx manifest =+  validateCompactionTx manifest >>= \case+    ValidationRefused refusals -> pure (Left refusals)+    ValidationAlreadyApplied record -> pure (Right (CompactionPreviewAlreadyApplied record))+    ValidationReady validated -> pure (Right (CompactionPreviewReady validated.report))++applyCompactionTx :: CompactionManifest -> Tx.Transaction (Either (NonEmpty CompactionRefusal) CompactionApplyResult)+applyCompactionTx = applyCompactionTxWith (pure ())++applyCompactionTxWith :: Tx.Transaction () -> CompactionManifest -> Tx.Transaction (Either (NonEmpty CompactionRefusal) CompactionApplyResult)+applyCompactionTxWith barrier manifest = do+  identity <- Tx.statement () StoreSQL.storeIdentityStmt+  if identity /= manifestStoreIdentity manifest+    then pure (Left (CompactionStoreIdentityMismatch (manifestStoreIdentity manifest) identity :| []))+    else do+      Tx.sql "SET LOCAL kiroku.enable_hard_deletes = 'on'"+      Retention.lockHistoryRetentionCoordinatorTx+      conflict <- Retention.activeHistoryRetentionConflictTx+      case conflict of+        Just active -> pure (Left (CompactionHistoryRetentionActive active :| []))+        Nothing ->+          ledgerOutcomeTx manifest >>= \case+            LedgerAlreadyApplied record -> pure (Right (CompactionAlreadyApplied record))+            LedgerConflict cid count -> pure (Left (CompactionLedgerConflict cid count :| []))+            LedgerUnrecorded -> do+              void (Tx.statement (manifestStreamNames manifest) SQL.lockCompactionStreamsStmt)+              validateCompactionTx manifest >>= \case+                ValidationRefused refusals -> pure (Left refusals)+                ValidationAlreadyApplied record -> pure (Right (CompactionAlreadyApplied record))+                ValidationReady validated -> do+                  barrier+                  Right . CompactionAppliedNow <$> deleteAndRecord validated+  where+    deleteAndRecord validated = do+      let r = validated.report+          (eventKeys, streamKeys) = V.unzip validated.junctionRows+      removed <- Tx.statement (eventKeys, streamKeys) SQL.deleteJunctionRowsStmt+      guardCount "junction count" (fromIntegral (V.length validated.junctionRows)) (fromIntegral (V.length removed))+      let home = V.length (V.filter (\(sid, origin) -> sid /= 0 && sid == origin) removed)+          global = V.length (V.filter (\(sid, _) -> sid == 0) removed)+          links = V.length removed - home - global+      guardCount "home membership count" r.homeMemberships (fromIntegral home)+      guardCount "global membership count" r.globalMemberships (fromIntegral global)+      guardCount "link membership count" r.linkMemberships (fromIntegral links)+      if manifestDeadLetterPolicy manifest == RemoveDeadLetters+        then Tx.statement validated.selectedEventIds SQL.deleteSelectedDeadLettersStmt >>= guardCount "dead-letter count" r.deadLettersRemoved+        else pure ()+      Tx.statement validated.selectedEventIds SQL.deleteSelectedEventsStmt >>= guardCount "events count" r.selectedEvents+      let inserted =+            SQL.CompactionRecordInsert+              r.manifestDigest+              r.reportDigest+              r.storeIdentity+              r.operation+              r.deadLetterPolicy+              r.causationPolicy+              r.selectedEvents+              r.homeMemberships+              r.globalMemberships+              r.linkMemberships+              r.deadLettersRemoved+              r.causationDependents+              r.lowestGlobalPosition+              r.highestGlobalPosition+              r.affectedStreams+      (cid, applied, actor) <- Tx.statement inserted SQL.insertCompactionRecordStmt+      pure (CompactionRecord (CompactionId cid) r applied actor)+    -- Only fixed guard labels and a validated lowercase hex digest enter SQL.+    -- PostgreSQL aborts the transaction itself; returning Left here would commit+    -- earlier deletes in a composer that kept running after the result.+    guardCount label expected actual =+      unless (expected == actual) $+        Tx.sql+          ( TE.encodeUtf8+              ( "DO $$ BEGIN RAISE EXCEPTION USING ERRCODE='KRCMP', MESSAGE='"+                  <> label+                  <> "', DETAIL='"+                  <> compactionDigestHex (compactionManifestDigest manifest)+                  <> "'; END $$"+              )+          )++compactionLedgerTx :: CompactionLedgerQuery -> Tx.Transaction (Vector CompactionRecord)+compactionLedgerTx query = Tx.statement (compactionLedgerLimitValue query.limit) SQL.compactionLedgerStmt
+ src/Kiroku/Store/Compaction/SQL.hs view
@@ -0,0 +1,185 @@+{-# LANGUAGE ApplicativeDo #-}+{-# LANGUAGE MultilineStrings #-}+{-# LANGUAGE OverloadedRecordDot #-}+{-# LANGUAGE NoFieldSelectors #-}++-- | Maintenance statements, exposed for deterministic query-plan checks.+module Kiroku.Store.Compaction.SQL+  ( resolveStreamsStmt,+    survivingSelectedEventsStmt,+    ledgerRecordByDigestStmt,+    compactionRecordRow,+    ledgerColumns,+    lockCompactionStreamsStmt,+    deleteJunctionRowsStmt,+    deleteSelectedDeadLettersStmt,+    deleteSelectedEventsStmt,+    CompactionRecordInsert (..),+    insertCompactionRecordStmt,+    compactionLedgerStmt,+    eventMembershipsStmt,+    deadLetterCountsStmt,+    causationDependentCountsStmt,+  )+where++import Contravariant.Extras (contrazip2)+import Data.Aeson qualified as Aeson+import Data.ByteString (ByteString)+import Data.Functor.Contravariant ((>$<))+import Data.Int (Int32, Int64)+import Data.Text (Text)+import Data.Text qualified as T+import Data.Time (UTCTime)+import Data.UUID (UUID)+import Data.Vector (Vector)+import GHC.Generics (Generic)+import Hasql.Decoders qualified as D+import Hasql.Encoders qualified as E+import Hasql.Statement (Statement, preparable)+import Kiroku.Store.Compaction.Types+import Kiroku.Store.SQL (causationDependentCountsStmt, deadLetterCountsStmt, eventMembershipsStmt)+import Kiroku.Store.Types (GlobalPosition (..), StoreIdentity (..))++resolveStreamsStmt :: Statement (Vector Text) (Vector (Text, Int64, Int64, Bool))+resolveStreamsStmt =+  preparable+    """+    SELECT s.stream_name, s.stream_id, s.stream_version, s.deleted_at IS NOT NULL+    FROM unnest($1::text[]) AS requested(stream_name)+    JOIN streams s USING (stream_name)+    """+    (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text))))+    (D.rowVector ((,,,) <$> col D.text <*> col D.int8 <*> col D.int8 <*> col D.bool))++survivingSelectedEventsStmt :: Statement (Vector UUID) Int64+survivingSelectedEventsStmt =+  preparable+    "SELECT count(*) FROM events WHERE event_id = ANY($1::uuid[])"+    (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.uuid))))+    (D.singleRow (col D.int8))++ledgerColumns :: Text+ledgerColumns = "compaction_id, manifest_digest, report_digest, store_id, operation, dead_letter_policy, causation_policy, selected_events, home_memberships, global_memberships, link_memberships, dead_letters_removed, causation_dependents, lowest_global_position, highest_global_position, affected_streams, applied_at, applied_by"++ledgerRecordByDigestStmt :: Statement ByteString (Maybe CompactionRecord)+ledgerRecordByDigestStmt =+  preparable+    ("SELECT " <> ledgerColumns <> " FROM event_compactions WHERE manifest_digest = $1")+    (E.param (E.nonNullable E.bytea))+    (D.rowMaybe compactionRecordRow)++-- | Decode the stored report digest verbatim. Audits compare it with the+-- recomputed digest; reading an audit record must not silently reseal it.+compactionRecordRow :: D.Row CompactionRecord+compactionRecordRow = do+  cid <- CompactionId <$> col D.uuid+  md <- col digestValue+  rd <- col digestValue+  identity <- StoreIdentity <$> col D.uuid+  operation <- col (D.refine (either (Left . T.pack . show) Right . mkCompactionOperation) D.text)+  dead <- col (D.refine (\case "refuse" -> Right RefuseDeadLetters; "remove" -> Right RemoveDeadLetters; _ -> Left "invalid dead-letter policy") D.text)+  cause <- col (D.refine (\case "refuse" -> Right RefuseCausationDependents; "allow" -> Right AllowDanglingCausation; _ -> Left "invalid causation policy") D.text)+  selected <- col D.int8+  home <- col D.int8+  global <- col D.int8+  links <- col D.int8+  deadLetters <- col D.int8+  dependents <- col D.int8+  lo <- GlobalPosition <$> col D.int8+  hi <- GlobalPosition <$> col D.int8+  streams <- col (D.refine parseHeads D.jsonb)+  applied <- col D.timestamptz+  actor <- col D.text+  pure (CompactionRecord cid (CompactionReport md identity operation dead cause selected home global links deadLetters dependents lo hi streams rd) applied actor)+  where+    digestValue = D.refine (maybe (Left "invalid SHA-256 byte length") Right . mkCompactionDigest) D.bytea+    parseHeads value = case Aeson.fromJSON value of+      Aeson.Error err -> Left (T.pack err)+      Aeson.Success heads -> Right heads++col :: D.Value a -> D.Row a+col = D.column . D.nonNullable++lockCompactionStreamsStmt :: Statement (Vector Text) (Vector (Text, Int64, Int64, Maybe UTCTime))+lockCompactionStreamsStmt =+  preparable+    "SELECT stream_name,stream_id,stream_version,deleted_at FROM streams WHERE stream_name = ANY($1::text[]) ORDER BY stream_id FOR UPDATE"+    (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.text))))+    (D.rowVector ((,,,) <$> col D.text <*> col D.int8 <*> col D.int8 <*> D.column (D.nullable D.timestamptz)))++deleteJunctionRowsStmt :: Statement (Vector UUID, Vector Int64) (Vector (Int64, Int64))+deleteJunctionRowsStmt =+  preparable+    "DELETE FROM stream_events se USING unnest($1::uuid[], $2::bigint[]) AS k(event_id,stream_id) WHERE se.event_id=k.event_id AND se.stream_id=k.stream_id RETURNING se.stream_id,se.original_stream_id"+    (contrazip2 (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.uuid)))) (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.int8)))))+    (D.rowVector ((,) <$> col D.int8 <*> col D.int8))++deleteSelectedDeadLettersStmt :: Statement (Vector UUID) Int64+deleteSelectedDeadLettersStmt =+  preparable+    "WITH removed AS (DELETE FROM dead_letters WHERE event_id=ANY($1::uuid[]) RETURNING 1) SELECT count(*) FROM removed"+    (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.uuid))))+    (D.singleRow (col D.int8))++deleteSelectedEventsStmt :: Statement (Vector UUID) Int64+deleteSelectedEventsStmt =+  preparable+    "WITH removed AS (DELETE FROM events WHERE event_id=ANY($1::uuid[]) RETURNING 1) SELECT count(*) FROM removed"+    (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.uuid))))+    (D.singleRow (col D.int8))++compactionLedgerStmt :: Statement Int32 (Vector CompactionRecord)+compactionLedgerStmt =+  preparable+    ("SELECT " <> ledgerColumns <> " FROM event_compactions ORDER BY applied_at DESC,compaction_id LIMIT $1")+    (E.param (E.nonNullable E.int4))+    (D.rowVector compactionRecordRow)++data CompactionRecordInsert = CompactionRecordInsert+  { manifestDigest :: !CompactionDigest,+    reportDigest :: !CompactionDigest,+    storeId :: !StoreIdentity,+    operation :: !CompactionOperation,+    deadLetterPolicy :: !DeadLetterPolicy,+    causationPolicy :: !CausationPolicy,+    selectedEvents :: !Int64,+    homeMemberships :: !Int64,+    globalMemberships :: !Int64,+    linkMemberships :: !Int64,+    deadLettersRemoved :: !Int64,+    causationDependents :: !Int64,+    lowestGlobalPosition :: !GlobalPosition,+    highestGlobalPosition :: !GlobalPosition,+    affectedStreams :: !(Vector StreamHeadWitness)+  }+  deriving stock (Eq, Show, Generic)++insertCompactionRecordStmt :: Statement CompactionRecordInsert (UUID, UTCTime, Text)+insertCompactionRecordStmt =+  preparable+    """+    INSERT INTO event_compactions (manifest_digest,report_digest,store_id,operation,+      dead_letter_policy,causation_policy,selected_events,home_memberships,+      global_memberships,link_memberships,dead_letters_removed,causation_dependents,+      lowest_global_position,highest_global_position,affected_streams)+    VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15)+    RETURNING compaction_id,applied_at,applied_by+    """+    ( ((\r -> compactionDigestBytes r.manifestDigest) >$< E.param (E.nonNullable E.bytea))+        <> ((\r -> compactionDigestBytes r.reportDigest) >$< E.param (E.nonNullable E.bytea))+        <> ((\r -> let StoreIdentity uid = r.storeId in uid) >$< E.param (E.nonNullable E.uuid))+        <> ((\r -> compactionOperationText r.operation) >$< E.param (E.nonNullable E.text))+        <> ((\r -> if r.deadLetterPolicy == RefuseDeadLetters then "refuse" else "remove") >$< E.param (E.nonNullable E.text))+        <> ((\r -> if r.causationPolicy == RefuseCausationDependents then "refuse" else "allow") >$< E.param (E.nonNullable E.text))+        <> ((\r -> r.selectedEvents) >$< E.param (E.nonNullable E.int8))+        <> ((\r -> r.homeMemberships) >$< E.param (E.nonNullable E.int8))+        <> ((\r -> r.globalMemberships) >$< E.param (E.nonNullable E.int8))+        <> ((\r -> r.linkMemberships) >$< E.param (E.nonNullable E.int8))+        <> ((\r -> r.deadLettersRemoved) >$< E.param (E.nonNullable E.int8))+        <> ((\r -> r.causationDependents) >$< E.param (E.nonNullable E.int8))+        <> ((\r -> let GlobalPosition n = r.lowestGlobalPosition in n) >$< E.param (E.nonNullable E.int8))+        <> ((\r -> let GlobalPosition n = r.highestGlobalPosition in n) >$< E.param (E.nonNullable E.int8))+        <> ((\r -> Aeson.toJSON r.affectedStreams) >$< E.param (E.nonNullable E.jsonb))+    )+    (D.singleRow ((,,) <$> col D.uuid <*> col D.timestamptz <*> col D.text))
+ src/Kiroku/Store/Compaction/Types.hs view
@@ -0,0 +1,672 @@+{-# LANGUAGE OverloadedRecordDot #-}+{-# LANGUAGE RecordWildCards #-}+{-# LANGUAGE NoFieldSelectors #-}++-- | Digest-sealed, explicitly witnessed selective compaction inputs and outcomes.+-- The consumer proves redundancy and archives envelopes. These types describe+-- physical deletion of exact events while preserving stream heads and retained+-- versions; logical truncate-before and whole-stream hard delete are separate+-- lifecycle operations. File-space reclamation is PostgreSQL maintenance.+-- Canonical bytes are a versioned cross-language contract, independent of JSON.+module Kiroku.Store.Compaction.Types+  ( CompactionOperation,+    CompactionDigest,+    CompactionId (..),+    LinkWitness (..),+    StreamHeadWitness (..),+    CompactionSelection (..),+    DeadLetterPolicy (..),+    CausationPolicy (..),+    CompactionManifestInput (..),+    CompactionManifestError (..),+    CompactionRefusal (..),+    WitnessMismatch (..),+    CompactionReport (..),+    CompactionRecord (..),+    CompactionPreview (..),+    CompactionApplyResult (..),+    CompactionLedgerLimit,+    CompactionLedgerQuery (..),+    CompactionManifest,+    CompactionLedgerError (..),+    mkCompactionManifest,+    compactionManifestDigest,+    canonicalCompactionManifestBytes,+    maxCompactionSelections,+    mkCompactionOperation,+    compactionOperationText,+    mkCompactionDigest,+    compactionDigestBytes,+    compactionDigestHex,+    parseCompactionDigestHex,+    mkCompactionLedgerLimit,+    compactionLedgerLimitValue,+    sealCompactionReport,+    compactionReportDigest,+    canonicalCompactionReportBytes,+    manifestStoreIdentity,+    manifestOperation,+    manifestDeadLetterPolicy,+    manifestCausationPolicy,+    manifestStreamHeads,+    manifestSelections,+    manifestStreamNames,+  )+where++import Control.Monad (unless, when)+import Crypto.Hash (Digest, SHA256, hash)+import Data.Aeson (FromJSON (..), ToJSON (..), Value (String), object, withObject, withText, (.:), (.:?), (.=))+import Data.Aeson.Types (Parser)+import Data.ByteArray (convert)+import Data.ByteString (ByteString)+import Data.ByteString qualified as BS+import Data.ByteString.Builder+import Data.ByteString.Lazy qualified as LBS+import Data.Char (digitToInt, intToDigit)+import Data.Int (Int32, Int64)+import Data.List (sortOn)+import Data.Set qualified as Set+import Data.Text (Text)+import Data.Text qualified as T+import Data.Text.Encoding qualified as TE+import Data.Time (UTCTime)+import Data.UUID (UUID)+import Data.UUID qualified as UUID+import Data.Vector (Vector)+import Data.Vector qualified as V+import GHC.Generics (Generic)+import Kiroku.Store.HistoryRetention.Types (HistoryRetentionConflict (..))+import Kiroku.Store.Types++newtype CompactionOperation = CompactionOperation Text -- validated 1..512 UTF-8 bytes+  deriving stock (Eq, Ord, Show, Generic)++newtype CompactionDigest = CompactionDigest ByteString -- exactly 32 bytes (SHA-256)+  deriving stock (Eq, Ord, Show, Generic)++newtype CompactionId = CompactionId UUID+  deriving stock (Eq, Ord, Show, Generic)++data LinkWitness = LinkWitness {stream :: !StreamName, streamVersion :: !StreamVersion}+  deriving stock (Eq, Show, Generic)++data StreamHeadWitness = StreamHeadWitness {stream :: !StreamName, headVersion :: !StreamVersion}+  deriving stock (Eq, Show, Generic)++data CompactionSelection = CompactionSelection+  { eventId :: !EventId,+    originStream :: !StreamName,+    originVersion :: !StreamVersion,+    globalPosition :: !GlobalPosition,+    acknowledgedLinks :: !(Vector LinkWitness) -- sorted by (stream, streamVersion), unique+  }+  deriving stock (Eq, Show, Generic)++data DeadLetterPolicy = RefuseDeadLetters | RemoveDeadLetters+  deriving stock (Eq, Ord, Show, Enum, Bounded, Generic)++data CausationPolicy = RefuseCausationDependents | AllowDanglingCausation+  deriving stock (Eq, Ord, Show, Enum, Bounded, Generic)++data CompactionManifestInput = CompactionManifestInput+  { storeIdentity :: !StoreIdentity,+    operation :: !CompactionOperation,+    deadLetterPolicy :: !DeadLetterPolicy,+    causationPolicy :: !CausationPolicy,+    streamHeads :: ![StreamHeadWitness],+    selections :: ![CompactionSelection],+    expectedDigest :: !(Maybe CompactionDigest)+  }+  deriving stock (Eq, Show, Generic)++data CompactionManifestError+  = CompactionManifestEmpty+  | CompactionManifestTooLarge !Int+  | CompactionManifestDuplicateEvent !EventId+  | CompactionManifestReservedStream !StreamName+  | CompactionManifestInvalidStreamName !StreamName !Int+  | CompactionManifestNonPositiveVersion !EventId+  | CompactionManifestNonPositivePosition !EventId+  | CompactionManifestDuplicatePosition !GlobalPosition+  | CompactionManifestDuplicateOriginVersion !StreamName !StreamVersion+  | CompactionManifestDuplicateLink !EventId !LinkWitness+  | CompactionManifestLinkIntoOrigin !EventId+  | CompactionManifestMissingStreamHead !StreamName+  | CompactionManifestUnusedStreamHead !StreamName+  | CompactionManifestDuplicateStreamHead !StreamName+  | CompactionManifestOperationEmpty+  | CompactionManifestOperationTooLong !Int+  | CompactionManifestDigestMismatch {expected :: !CompactionDigest, computed :: !CompactionDigest}+  deriving stock (Eq, Show, Generic)++data CompactionRefusal+  = CompactionStoreIdentityMismatch {expectedIdentity :: !StoreIdentity, actualIdentity :: !StoreIdentity}+  | CompactionHistoryRetentionActive !HistoryRetentionConflict+  | CompactionStreamMissing !StreamName+  | CompactionStreamSoftDeleted !StreamName+  | CompactionStreamHeadDrift {stream :: !StreamName, expectedHead :: !StreamVersion, actualHead :: !StreamVersion}+  | CompactionSelectedEventMissing !EventId+  | CompactionWitnessMismatch !EventId !WitnessMismatch+  | CompactionUnexpectedLink !EventId !LinkWitness+  | CompactionAcknowledgedLinkMissing !EventId !LinkWitness+  | CompactionDeadLettersPresent !EventId !Int64+  | CompactionCausationDependentsPresent !EventId !Int64+  | CompactionLedgerConflict {compactionId :: !CompactionId, survivingEvents :: !Int64}+  deriving stock (Eq, Show, Generic)++data WitnessMismatch = WitnessMismatch+  { actualOriginStream :: !StreamName,+    actualOriginVersion :: !StreamVersion,+    actualGlobalPosition :: !(Maybe GlobalPosition) -- Nothing when the $all row is gone+  }+  deriving stock (Eq, Show, Generic)++data CompactionReport = CompactionReport+  { manifestDigest :: !CompactionDigest,+    storeIdentity :: !StoreIdentity,+    operation :: !CompactionOperation,+    deadLetterPolicy :: !DeadLetterPolicy,+    causationPolicy :: !CausationPolicy,+    selectedEvents :: !Int64,+    homeMemberships :: !Int64,+    globalMemberships :: !Int64,+    linkMemberships :: !Int64,+    deadLettersRemoved :: !Int64,+    causationDependents :: !Int64,+    lowestGlobalPosition :: !GlobalPosition,+    highestGlobalPosition :: !GlobalPosition,+    affectedStreams :: !(Vector StreamHeadWitness), -- sorted by stream name+    reportDigest :: !CompactionDigest+  }+  deriving stock (Eq, Show, Generic)++data CompactionRecord = CompactionRecord+  { compactionId :: !CompactionId,+    report :: !CompactionReport,+    appliedAt :: !UTCTime,+    appliedBy :: !Text+  }+  deriving stock (Eq, Show, Generic)++data CompactionPreview+  = CompactionPreviewReady !CompactionReport+  | CompactionPreviewAlreadyApplied !CompactionRecord+  deriving stock (Eq, Show, Generic)++data CompactionApplyResult+  = CompactionAppliedNow !CompactionRecord+  | CompactionAlreadyApplied !CompactionRecord+  deriving stock (Eq, Show, Generic)++newtype CompactionLedgerLimit = CompactionLedgerLimit Int32 -- validated 1..1000+  deriving stock (Eq, Ord, Show, Generic)++data CompactionLedgerQuery = CompactionLedgerQuery {limit :: !CompactionLedgerLimit}+  deriving stock (Eq, Show, Generic)++data CompactionLedgerError = CompactionLedgerLimitOutOfRange !Int32+  deriving stock (Eq, Show, Generic)++data CompactionManifest = CompactionManifest+  { identityValue :: !StoreIdentity,+    operationValue :: !CompactionOperation,+    deadPolicyValue :: !DeadLetterPolicy,+    causationPolicyValue :: !CausationPolicy,+    headValues :: !(Vector StreamHeadWitness),+    selectionValues :: !(Vector CompactionSelection)+  }+  deriving stock (Eq, Show, Generic)++manifestStoreIdentity :: CompactionManifest -> StoreIdentity+manifestStoreIdentity m = m.identityValue++manifestOperation :: CompactionManifest -> CompactionOperation+manifestOperation m = m.operationValue++manifestDeadLetterPolicy :: CompactionManifest -> DeadLetterPolicy+manifestDeadLetterPolicy m = m.deadPolicyValue++manifestCausationPolicy :: CompactionManifest -> CausationPolicy+manifestCausationPolicy m = m.causationPolicyValue++manifestStreamHeads :: CompactionManifest -> Vector StreamHeadWitness+manifestStreamHeads m = m.headValues++manifestSelections :: CompactionManifest -> Vector CompactionSelection+manifestSelections m = m.selectionValues++manifestStreamNames :: CompactionManifest -> Vector Text+manifestStreamNames m = V.map (\h -> let StreamName name = h.stream in name) m.headValues++maxCompactionSelections :: Int+maxCompactionSelections = 100000++mkCompactionOperation :: Text -> Either CompactionManifestError CompactionOperation+mkCompactionOperation value+  | bytes == 0 = Left CompactionManifestOperationEmpty+  | bytes > 512 = Left (CompactionManifestOperationTooLong bytes)+  | otherwise = Right (CompactionOperation value)+  where+    bytes = BS.length (TE.encodeUtf8 value)++compactionOperationText :: CompactionOperation -> Text+compactionOperationText (CompactionOperation t) = t++mkCompactionDigest :: ByteString -> Maybe CompactionDigest+mkCompactionDigest b+  | BS.length b == 32 = Just (CompactionDigest b)+  | otherwise = Nothing++compactionDigestBytes :: CompactionDigest -> ByteString+compactionDigestBytes (CompactionDigest b) = b++compactionDigestHex :: CompactionDigest -> Text+compactionDigestHex (CompactionDigest b) = T.pack (concatMap hex (BS.unpack b))+  where+    hex n = [intToDigit (fromIntegral n `div` 16), intToDigit (fromIntegral n `mod` 16)]++parseCompactionDigestHex :: Text -> Maybe CompactionDigest+parseCompactionDigestHex t+  | T.length t /= 64 || not (T.all (\c -> c `elem` ("0123456789abcdefABCDEF" :: String)) t) = Nothing+  | otherwise = mkCompactionDigest (BS.pack (pairs (T.unpack t)))+  where+    pairs (a : b : rest) = fromIntegral (16 * digitToInt a + digitToInt b) : pairs rest+    pairs _ = []++mkCompactionLedgerLimit :: Int32 -> Either CompactionLedgerError CompactionLedgerLimit+mkCompactionLedgerLimit n+  | n >= 1 && n <= 1000 = Right (CompactionLedgerLimit n)+  | otherwise = Left (CompactionLedgerLimitOutOfRange n)++compactionLedgerLimitValue :: CompactionLedgerLimit -> Int32+compactionLedgerLimitValue (CompactionLedgerLimit n) = n++-- Kept below Store.Error in the import graph: Error consumes the digest type.+-- These are the same reserved-name and UTF-8 length rules as ordinary writes.+validateName :: StreamName -> Either CompactionManifestError ()+validateName name@(StreamName text)+  | text == "$all" = Left (CompactionManifestReservedStream name)+  | bytes > 512 = Left (CompactionManifestInvalidStreamName name bytes)+  | otherwise = Right ()+  where+    bytes = BS.length (TE.encodeUtf8 text)++uniqueBy :: (Ord b) => (a -> b) -> (a -> CompactionManifestError) -> [a] -> Either CompactionManifestError ()+uniqueBy key err = go Set.empty+  where+    go _ [] = Right ()+    go seen (x : xs)+      | Set.member (key x) seen = Left (err x)+      | otherwise = go (Set.insert (key x) seen) xs++mkCompactionManifest :: CompactionManifestInput -> Either CompactionManifestError CompactionManifest+mkCompactionManifest input = do+  let sels = input.selections+      heads = input.streamHeads+      CompactionOperation op = input.operation+  _ <- mkCompactionOperation op+  when (null sels) (Left CompactionManifestEmpty)+  when (length sels > maxCompactionSelections) (Left (CompactionManifestTooLarge (length sels)))+  mapM_ validateSelection sels+  uniqueBy (\s -> s.eventId) (\s -> CompactionManifestDuplicateEvent s.eventId) sels+  uniqueBy (\s -> s.globalPosition) (\s -> CompactionManifestDuplicatePosition s.globalPosition) sels+  uniqueBy (\s -> (s.originStream, s.originVersion)) (\s -> CompactionManifestDuplicateOriginVersion s.originStream s.originVersion) sels+  mapM_ (\h -> validateName h.stream) heads+  uniqueBy (\h -> h.stream) (\h -> CompactionManifestDuplicateStreamHead h.stream) heads+  let touched = Set.fromList (concatMap (\s -> s.originStream : map (\l -> l.stream) (V.toList s.acknowledgedLinks)) sels)+      declared = Set.fromList (map (\h -> h.stream) heads)+  case Set.lookupMin (touched Set.\\ declared) of+    Just name -> Left (CompactionManifestMissingStreamHead name)+    Nothing -> pure ()+  case Set.lookupMin (declared Set.\\ touched) of+    Just name -> Left (CompactionManifestUnusedStreamHead name)+    Nothing -> pure ()+  let canonicalSelection s = s {acknowledgedLinks = V.fromList (sortOn linkKey (V.toList s.acknowledgedLinks))}+      m =+        CompactionManifest+          input.storeIdentity+          input.operation+          input.deadLetterPolicy+          input.causationPolicy+          (V.fromList (sortOn (\h -> nameBytes h.stream) heads))+          (V.fromList (map canonicalSelection (sortOn (\s -> s.globalPosition) sels)))+      computed = compactionManifestDigest m+  case input.expectedDigest of+    Just expected | expected /= computed -> Left (CompactionManifestDigestMismatch expected computed)+    _ -> Right m+  where+    nameBytes (StreamName t) = TE.encodeUtf8 t+    linkKey l = (nameBytes l.stream, l.streamVersion)+    validateSelection s = do+      validateName s.originStream+      let StreamVersion ver = s.originVersion+          GlobalPosition pos = s.globalPosition+      when (ver < 1) (Left (CompactionManifestNonPositiveVersion s.eventId))+      when (pos < 1) (Left (CompactionManifestNonPositivePosition s.eventId))+      mapM_ (validateLink s) s.acknowledgedLinks+      uniqueBy linkKey (CompactionManifestDuplicateLink s.eventId) (V.toList s.acknowledgedLinks)+    validateLink s l = do+      validateName l.stream+      let StreamVersion ver = l.streamVersion+      when (ver < 1) (Left (CompactionManifestNonPositiveVersion s.eventId))+      when (l.stream == s.originStream) (Left (CompactionManifestLinkIntoOrigin s.eventId))++sha256 :: ByteString -> CompactionDigest+sha256 bytes = CompactionDigest (convert (hash bytes :: Digest SHA256))++lengthPrefixed :: Text -> Builder+lengthPrefixed t = let b = TE.encodeUtf8 t in word32BE (fromIntegral (BS.length b)) <> byteString b++uuidBytes :: UUID -> Builder+uuidBytes = lazyByteString . UUID.toByteString++counted :: Vector a -> (a -> Builder) -> Builder+counted values encode = word32BE (fromIntegral (V.length values)) <> foldMap encode values++headBytes :: StreamHeadWitness -> Builder+headBytes (StreamHeadWitness (StreamName name) (StreamVersion ver)) = lengthPrefixed name <> int64BE ver++linkBytes :: LinkWitness -> Builder+linkBytes (LinkWitness (StreamName name) (StreamVersion ver)) = lengthPrefixed name <> int64BE ver++policyBytes :: DeadLetterPolicy -> CausationPolicy -> Builder+policyBytes dead cause = word8 (if dead == RefuseDeadLetters then 0 else 1) <> word8 (if cause == RefuseCausationDependents then 0 else 1)++-- | Version-1 digest protocol, independent of JSON: ASCII format tag plus LF,+-- raw 16-byte store UUID, length-prefixed UTF-8 operation, two policy bytes+-- (0 refuses, 1 allows/removes), counted heads, then counted selections. Text+-- lengths and vector counts are unsigned big-endian Word32; versions and+-- positions are signed big-endian Int64. Heads sort by UTF-8 name; selections+-- by global position; links by UTF-8 name then version. A head/link is name+-- then version. A selection is raw UUID, origin name, origin version, global+-- position and counted links. See @docs/user/compaction.md@ for the test vector.+canonicalCompactionManifestBytes :: CompactionManifest -> ByteString+canonicalCompactionManifestBytes m =+  LBS.toStrict . toLazyByteString $+    byteString "kiroku-compaction-manifest/1\n"+      <> uuidBytes uid+      <> lengthPrefixed (compactionOperationText m.operationValue)+      <> policyBytes m.deadPolicyValue m.causationPolicyValue+      <> counted m.headValues headBytes+      <> counted m.selectionValues selectionBytes+  where+    StoreIdentity uid = m.identityValue+    selectionBytes s =+      let EventId eid = s.eventId+          StreamName name = s.originStream+          StreamVersion ver = s.originVersion+          GlobalPosition pos = s.globalPosition+       in uuidBytes eid <> lengthPrefixed name <> int64BE ver <> int64BE pos <> counted s.acknowledgedLinks linkBytes++compactionManifestDigest :: CompactionManifest -> CompactionDigest+compactionManifestDigest = sha256 . canonicalCompactionManifestBytes++-- | Version-1 report protocol: format tag plus LF, raw manifest digest, store+-- UUID, operation and policies, then eight Int64 values: selected, home, global,+-- link, removed dead-letter and causation counts, lowest and highest position.+-- Counted affected heads follow, using the manifest encoding. The report's own+-- digest is excluded. Stored ledger digests remain verbatim audit evidence.+canonicalCompactionReportBytes :: CompactionReport -> ByteString+canonicalCompactionReportBytes r =+  LBS.toStrict . toLazyByteString $+    byteString "kiroku-compaction-report/1\n"+      <> byteString (compactionDigestBytes r.manifestDigest)+      <> uuidBytes uid+      <> lengthPrefixed (compactionOperationText r.operation)+      <> policyBytes r.deadLetterPolicy r.causationPolicy+      <> foldMap+        int64BE+        [ r.selectedEvents,+          r.homeMemberships,+          r.globalMemberships,+          r.linkMemberships,+          r.deadLettersRemoved,+          r.causationDependents,+          lo,+          hi+        ]+      <> counted r.affectedStreams headBytes+  where+    StoreIdentity uid = r.storeIdentity+    GlobalPosition lo = r.lowestGlobalPosition+    GlobalPosition hi = r.highestGlobalPosition++compactionReportDigest :: CompactionReport -> CompactionDigest+compactionReportDigest = sha256 . canonicalCompactionReportBytes++sealCompactionReport :: CompactionReport -> CompactionReport+sealCompactionReport r = r {reportDigest = compactionReportDigest r}++instance ToJSON CompactionId where+  toJSON (CompactionId uid) = toJSON uid++instance FromJSON CompactionId where+  parseJSON value = CompactionId <$> parseJSON value++instance ToJSON CompactionDigest where+  toJSON = toJSON . compactionDigestHex++instance FromJSON CompactionDigest where+  parseJSON = withText "CompactionDigest" $ \t -> maybe (fail "expected a 64-character SHA-256 hex digest") pure (parseCompactionDigestHex t)++instance ToJSON CompactionOperation where+  toJSON = toJSON . compactionOperationText++instance FromJSON CompactionOperation where+  parseJSON = withText "CompactionOperation" $ either (fail . show) pure . mkCompactionOperation++instance ToJSON DeadLetterPolicy where+  toJSON p = String (if p == RefuseDeadLetters then "refuse" else "remove")++instance FromJSON DeadLetterPolicy where+  parseJSON = withText "DeadLetterPolicy" $ \case+    "refuse" -> pure RefuseDeadLetters+    "remove" -> pure RemoveDeadLetters+    _ -> fail "dead_letter_policy must be refuse or remove"++instance ToJSON CausationPolicy where+  toJSON p = String (if p == RefuseCausationDependents then "refuse" else "allow")++instance FromJSON CausationPolicy where+  parseJSON = withText "CausationPolicy" $ \case+    "refuse" -> pure RefuseCausationDependents+    "allow" -> pure AllowDanglingCausation+    _ -> fail "causation_policy must be refuse or allow"++streamText :: StreamName -> Text+streamText (StreamName name) = name++versionInt :: StreamVersion -> Int64+versionInt (StreamVersion ver) = ver++positionInt :: GlobalPosition -> Int64+positionInt (GlobalPosition pos) = pos++eventUuid :: EventId -> UUID+eventUuid (EventId uid) = uid++instance ToJSON LinkWitness where+  toJSON r =+    object+      [ "stream" .= (streamText r.stream),+        "stream_version" .= (versionInt r.streamVersion)+      ]++instance FromJSON LinkWitness where+  parseJSON = withObject "LinkWitness" $ \o ->+    LinkWitness+      <$> (StreamName <$> o .: "stream")+      <*> (StreamVersion <$> o .: "stream_version")++instance ToJSON StreamHeadWitness where+  toJSON r =+    object+      [ "stream" .= (streamText r.stream),+        "head_version" .= (versionInt r.headVersion)+      ]++instance FromJSON StreamHeadWitness where+  parseJSON = withObject "StreamHeadWitness" $ \o ->+    StreamHeadWitness+      <$> (StreamName <$> o .: "stream")+      <*> (StreamVersion <$> o .: "head_version")++instance ToJSON CompactionSelection where+  toJSON r =+    object+      [ "event_id" .= (eventUuid r.eventId),+        "origin_stream" .= (streamText r.originStream),+        "origin_version" .= (versionInt r.originVersion),+        "global_position" .= (positionInt r.globalPosition),+        "acknowledged_links" .= (r.acknowledgedLinks)+      ]++instance FromJSON CompactionSelection where+  parseJSON = withObject "CompactionSelection" $ \o ->+    CompactionSelection+      <$> (EventId <$> o .: "event_id")+      <*> (StreamName <$> o .: "origin_stream")+      <*> (StreamVersion <$> o .: "origin_version")+      <*> (GlobalPosition <$> o .: "global_position")+      <*> o .: "acknowledged_links"++instance ToJSON WitnessMismatch where+  toJSON r =+    object+      [ "actual_origin_stream" .= (streamText r.actualOriginStream),+        "actual_origin_version" .= (versionInt r.actualOriginVersion),+        "actual_global_position" .= (fmap positionInt r.actualGlobalPosition)+      ]++instance FromJSON WitnessMismatch where+  parseJSON = withObject "WitnessMismatch" $ \o ->+    WitnessMismatch+      <$> (StreamName <$> o .: "actual_origin_stream")+      <*> (StreamVersion <$> o .: "actual_origin_version")+      <*> (fmap GlobalPosition <$> o .: "actual_global_position")++instance ToJSON CompactionReport where+  toJSON r =+    object+      [ "manifest_digest" .= (r.manifestDigest),+        "store_identity" .= (r.storeIdentity),+        "operation" .= (r.operation),+        "dead_letter_policy" .= (r.deadLetterPolicy),+        "causation_policy" .= (r.causationPolicy),+        "selected_events" .= (r.selectedEvents),+        "home_memberships" .= (r.homeMemberships),+        "global_memberships" .= (r.globalMemberships),+        "link_memberships" .= (r.linkMemberships),+        "dead_letters_removed" .= (r.deadLettersRemoved),+        "causation_dependents" .= (r.causationDependents),+        "lowest_global_position" .= (positionInt r.lowestGlobalPosition),+        "highest_global_position" .= (positionInt r.highestGlobalPosition),+        "affected_streams" .= (r.affectedStreams),+        "report_digest" .= (r.reportDigest)+      ]++instance FromJSON CompactionReport where+  parseJSON = withObject "CompactionReport" $ \o -> do+    r <-+      CompactionReport+        <$> o .: "manifest_digest"+        <*> o .: "store_identity"+        <*> o .: "operation"+        <*> o .: "dead_letter_policy"+        <*> o .: "causation_policy"+        <*> o .: "selected_events"+        <*> o .: "home_memberships"+        <*> o .: "global_memberships"+        <*> o .: "link_memberships"+        <*> o .: "dead_letters_removed"+        <*> o .: "causation_dependents"+        <*> (GlobalPosition <$> o .: "lowest_global_position")+        <*> (GlobalPosition <$> o .: "highest_global_position")+        <*> o .: "affected_streams"+        <*> o .: "report_digest"+    unless (compactionReportDigest r == r.reportDigest) (fail "compaction report digest mismatch")+    pure r++instance ToJSON CompactionRecord where+  toJSON r =+    object+      [ "compaction_id" .= (r.compactionId),+        "report" .= (r.report),+        "applied_at" .= (r.appliedAt),+        "applied_by" .= (r.appliedBy)+      ]++instance FromJSON CompactionRecord where+  parseJSON = withObject "CompactionRecord" $ \o ->+    CompactionRecord+      <$> o .: "compaction_id"+      <*> o .: "report"+      <*> o .: "applied_at"+      <*> o .: "applied_by"++instance ToJSON CompactionManifest where+  toJSON m =+    object+      [ "format" .= ("kiroku-compaction-manifest/1" :: Text),+        "store_identity" .= manifestStoreIdentity m,+        "operation" .= manifestOperation m,+        "dead_letter_policy" .= manifestDeadLetterPolicy m,+        "causation_policy" .= manifestCausationPolicy m,+        "stream_heads" .= manifestStreamHeads m,+        "selections" .= manifestSelections m,+        "digest" .= compactionManifestDigest m+      ]++instance FromJSON CompactionManifest where+  parseJSON = withObject "CompactionManifest" $ \o -> do+    format <- o .: "format"+    unless (format == ("kiroku-compaction-manifest/1" :: Text)) (fail "unsupported compaction manifest format")+    digest <- o .:? "digest" >>= maybe (fail "compaction manifest requires a digest") pure+    input <-+      CompactionManifestInput+        <$> o .: "store_identity"+        <*> o .: "operation"+        <*> o .: "dead_letter_policy"+        <*> o .: "causation_policy"+        <*> o .: "stream_heads"+        <*> o .: "selections"+        <*> pure (Just digest)+    either (fail . show) pure (mkCompactionManifest input)++instance ToJSON CompactionRefusal where+  toJSON refusal = object $ case refusal of+    CompactionStoreIdentityMismatch expected actual -> tag "store_identity_mismatch" <> ["expected" .= expected, "actual" .= actual]+    CompactionHistoryRetentionActive (HistoryRetentionConflict count expiry) -> tag "history_retention_active" <> ["active_lease_count" .= count, "earliest_expiry" .= expiry]+    CompactionStreamMissing stream -> tag "stream_missing" <> ["stream" .= streamText stream]+    CompactionStreamSoftDeleted stream -> tag "stream_soft_deleted" <> ["stream" .= streamText stream]+    CompactionStreamHeadDrift stream expected actual -> tag "stream_head_drift" <> ["stream" .= streamText stream, "expected" .= versionInt expected, "actual" .= versionInt actual]+    CompactionSelectedEventMissing eid -> tag "selected_event_missing" <> ["event_id" .= eventUuid eid]+    CompactionWitnessMismatch eid witness -> tag "witness_mismatch" <> ["event_id" .= eventUuid eid, "witness" .= witness]+    CompactionUnexpectedLink eid link -> tag "unexpected_link" <> ["event_id" .= eventUuid eid, "link" .= link]+    CompactionAcknowledgedLinkMissing eid link -> tag "acknowledged_link_missing" <> ["event_id" .= eventUuid eid, "link" .= link]+    CompactionDeadLettersPresent eid count -> tag "dead_letters_present" <> ["event_id" .= eventUuid eid, "count" .= count]+    CompactionCausationDependentsPresent eid count -> tag "causation_dependents_present" <> ["event_id" .= eventUuid eid, "count" .= count]+    CompactionLedgerConflict cid count -> tag "ledger_conflict" <> ["compaction_id" .= cid, "surviving_events" .= count]+    where+      tag name = ["refusal" .= (name :: Text)]++instance FromJSON CompactionRefusal where+  parseJSON = withObject "CompactionRefusal" $ \o -> do+    kind <- o .: "refusal" :: Parser Text+    let stream = StreamName <$> o .: "stream"+        eid = EventId <$> o .: "event_id"+    case kind of+      "store_identity_mismatch" -> CompactionStoreIdentityMismatch <$> o .: "expected" <*> o .: "actual"+      "history_retention_active" -> CompactionHistoryRetentionActive <$> (HistoryRetentionConflict <$> o .: "active_lease_count" <*> o .: "earliest_expiry")+      "stream_missing" -> CompactionStreamMissing <$> stream+      "stream_soft_deleted" -> CompactionStreamSoftDeleted <$> stream+      "stream_head_drift" -> CompactionStreamHeadDrift <$> stream <*> (StreamVersion <$> o .: "expected") <*> (StreamVersion <$> o .: "actual")+      "selected_event_missing" -> CompactionSelectedEventMissing <$> eid+      "witness_mismatch" -> CompactionWitnessMismatch <$> eid <*> o .: "witness"+      "unexpected_link" -> CompactionUnexpectedLink <$> eid <*> o .: "link"+      "acknowledged_link_missing" -> CompactionAcknowledgedLinkMissing <$> eid <*> o .: "link"+      "dead_letters_present" -> CompactionDeadLettersPresent <$> eid <*> o .: "count"+      "causation_dependents_present" -> CompactionCausationDependentsPresent <$> eid <*> o .: "count"+      "ledger_conflict" -> CompactionLedgerConflict <$> o .: "compaction_id" <*> o .: "surviving_events"+      _ -> fail "unknown compaction refusal"
src/Kiroku/Store/Effect.hs view
@@ -17,6 +17,9 @@     prepareEvents,     buildAppendParams,     appendDispatchTx,+    assembleEventReferences,+    chunkUuids,+    maxEventReferenceBatch,   ) where @@ -25,8 +28,10 @@ import Control.Monad.IO.Class (MonadIO, liftIO) import Data.Aeson (Value) import Data.Generics.Labels ()-import Data.Int (Int32)+import Data.Int (Int32, Int64) import Data.List (find)+import Data.List.NonEmpty (NonEmpty)+import Data.List.NonEmpty qualified as NonEmpty import Data.Map.Strict (Map) import Data.Map.Strict qualified as Map import Data.Maybe (isNothing)@@ -38,7 +43,7 @@ import Data.Vector qualified as V import Effectful (Dispatch (..), DispatchOf, Eff, Effect, IOE, runEff, (:>)) import Effectful.Dispatch.Dynamic (interpret_)-import Effectful.Error.Static (Error, runErrorNoCallStack, throwError)+import Effectful.Error.Static (Error, catchError, runErrorNoCallStack, throwError) import GHC.Generics (Generic) import Hasql.Decoders qualified as D import Hasql.Encoders qualified as E@@ -49,6 +54,8 @@ import Hasql.Statement (Statement, preparable) import Hasql.Transaction qualified as Tx import Hasql.Transaction.Sessions qualified as TxSessions+import Kiroku.Store.Compaction.Internal qualified as Compaction+import Kiroku.Store.Compaction.Types import Kiroku.Store.Connection (KirokuStore (..)) import Kiroku.Store.Effect.Resource (KirokuStoreResource, getKirokuStore) import Kiroku.Store.Error (StoreError (..), attributeMultiStreamError, emptyResultError, isTransientSerializationError, mapGenericUsageError, mapLinkUsageError, mapTransactionUsageError, mapUsageError, validateStreamName)@@ -89,6 +96,12 @@   --   --     Surfaced as 'Kiroku.Store.Read.visibleGlobalHeadPosition'.   GetVisibleGlobalHeadPosition :: Store m GlobalPosition+  -- | Read the immutable store identity installed by migration 0017.+  GetStoreIdentity :: Store m StoreIdentity+  -- | Read-only manifest preview on one repeatable-read snapshot, without row locks.+  PreviewCompaction :: CompactionManifest -> Store m (Either (NonEmpty CompactionRefusal) CompactionPreview)+  ApplyCompaction :: CompactionManifest -> Store m (Either (NonEmpty CompactionRefusal) CompactionApplyResult)+  GetCompactionLedger :: CompactionLedgerQuery -> Store m (Vector CompactionRecord)   GetStream :: StreamName -> Store m (Maybe StreamInfo)   -- | One-statement metadata and newest surviving originated head observation.   --     Outer 'Nothing' means absent; inner 'Nothing' means no originated events,@@ -120,6 +133,8 @@   --     the distinct ids from a batch and resolve them once, rather than per event.   --     Surfaced as 'Kiroku.Store.Read.lookupStreamNames' (and the singular   --     'Kiroku.Store.Read.lookupStreamName').+  -- | Inventory of memberships and references outside the requested event set.+  LookupEventReferences :: [EventId] -> Store m (Map EventId EventReferenceInventory)   LookupStreamNames :: [StreamId] -> Store m (Map StreamId StreamName)   -- | Byte-ordered names; optional exact category, literal prefix and exclusive cursor.   --     Includes soft-deleted streams, excludes only the reserved stream row.@@ -262,6 +277,40 @@       usePool (store ^. #pool) $         Session.statement (cursor, limit) SQL.readAllBackwardStmt     decodeReadEvents (store ^. #storeSettings) evs+  ApplyCompaction manifest -> do+    result <-+      catchError+        (runTxOnPool (store ^. #pool) TxSessions.transaction (Compaction.applyCompactionTx manifest))+        ( \_ err -> case err of+            UnexpectedServerError "23503" message -> throwError (CompactionConcurrentMutation (compactionManifestDigest manifest) message)+            _ -> throwError err+        )+    let digest = compactionManifestDigest manifest+    liftIO $ case result of+      Right (CompactionAppliedNow record) -> do+        let report = record ^. #report+            memberships = report ^. #homeMemberships + report ^. #globalMemberships + report ^. #linkMemberships+        emitOrDrop (store ^. #eventHandler) (KirokuEventCompactionApplied (record ^. #compactionId) digest (report ^. #selectedEvents) memberships)+      Right (CompactionAlreadyApplied record) ->+        emitOrDrop (store ^. #eventHandler) (KirokuEventCompactionAlreadyApplied (record ^. #compactionId) digest)+      Left refusals ->+        emitOrDrop (store ^. #eventHandler) (KirokuEventCompactionRefused digest (NonEmpty.head refusals) (NonEmpty.length refusals))+    pure result+  GetCompactionLedger query ->+    runTxOnPool (store ^. #pool) TxSessions.transaction (Compaction.compactionLedgerTx query)+  PreviewCompaction manifest -> do+    result <- runReadOnlyTxOnPool (store ^. #pool) (Compaction.previewCompactionTx manifest)+    let digest = compactionManifestDigest manifest+    liftIO $ case result of+      Right (CompactionPreviewReady report) ->+        emitOrDrop (store ^. #eventHandler) (KirokuEventCompactionPreviewed digest (report ^. #selectedEvents))+      Right (CompactionPreviewAlreadyApplied record) ->+        emitOrDrop (store ^. #eventHandler) (KirokuEventCompactionPreviewed digest (record ^. #report . #selectedEvents))+      Left refusals ->+        emitOrDrop (store ^. #eventHandler) (KirokuEventCompactionRefused digest (NonEmpty.head refusals) (NonEmpty.length refusals))+    pure result+  GetStoreIdentity ->+    usePool (store ^. #pool) $ Session.statement () SQL.storeIdentityStmt   GetVisibleGlobalHeadPosition ->     usePool (store ^. #pool) $       Session.statement () SQL.visibleGlobalHeadPositionStmt@@ -278,6 +327,23 @@   EventExistsInStream (StreamName name) (EventId eid) ->     usePool (store ^. #pool) $       Session.statement (name, eid) SQL.eventExistsInStreamStmt+  LookupEventReferences [] ->+    pure Map.empty+  LookupEventReferences eventIds -> do+    let uuids = V.fromList (Map.keys (Map.fromList [(uid, ()) | EventId uid <- eventIds]))+        batches = chunkUuids maxEventReferenceBatch uuids+    usePool (store ^. #pool) $ do+      perBatch <-+        traverse+          ( \batch ->+              (,)+                <$> Session.statement batch SQL.eventMembershipsStmt+                <*> Session.statement batch SQL.deadLetterCountsStmt+          )+          batches+      dependents <- Session.statement uuids SQL.causationDependentCountsStmt+      let (memberships, deadLetters) = unzip perBatch+      pure (assembleEventReferences (V.concat memberships) (V.concat deadLetters) dependents)   LookupStreamNames [] ->     pure Map.empty   LookupStreamNames sids ->@@ -694,3 +760,57 @@ decodeReadEvents settings events = case decodeHook settings of   Nothing -> pure events   Just hook -> V.mapM (\event -> liftIO (hook event) >>= either (throwError . EventDecodeFailed) pure) events++-- | Ids per execution of the membership and dead-letter statements. The+-- causation-dependent statement is exempt: its array is also its exclusion set,+-- so it always runs once over the full request.+maxEventReferenceBatch :: Int+maxEventReferenceBatch = 10000++-- | Split a request into batches of at most 'maxEventReferenceBatch' ids.+chunkUuids :: Int -> Vector UUID -> [Vector UUID]+chunkUuids size values+  | V.null values = []+  | otherwise =+      let (batch, rest) = V.splitAt (max 1 size) values+       in batch : chunkUuids size rest++-- | Fold the three inventory statements' rows into one map. Memberships keep+-- statement order (ascending stream id, so the global row's entry comes first);+-- each row becomes 'GlobalMembership' (@stream_id = 0@, carrying the global+-- position), 'HomeMembership' (@stream_id = original_stream_id@), or+-- 'LinkMembership'.+assembleEventReferences ::+  Vector (UUID, Int64, Text, Int64, Int64, Int64) ->+  Vector (UUID, Int64) ->+  Vector (UUID, Int64) ->+  Map EventId EventReferenceInventory+assembleEventReferences memberships deadLetters dependents =+  Map.mapWithKey attach grouped+  where+    grouped =+      Map.fromListWith+        (<>)+        [ (EventId eid, [membership sid name version origin])+        | (eid, sid, name, version, origin, _originVersion) <- V.toList memberships+        ]+    membership sid name version origin+      | sid == 0 = GlobalMembership (GlobalPosition version)+      | sid == origin = HomeMembership (StreamName name) (StreamId sid) (StreamVersion version)+      | otherwise = LinkMembership (StreamName name) (StreamId sid) (StreamVersion version)+    deadLetterMap = Map.fromList [(EventId eid, n) | (eid, n) <- V.toList deadLetters]+    dependentMap = Map.fromList [(EventId eid, n) | (eid, n) <- V.toList dependents]+    attach eid ms =+      EventReferenceInventory+        { memberships = V.fromList (reverse ms),+          deadLetterCount = Map.findWithDefault 0 eid deadLetterMap,+          causationDependentCount = Map.findWithDefault 0 eid dependentMap+        }++-- | One stable snapshot for maintenance reads, without writes or row locks.+runReadOnlyTxOnPool :: (IOE :> es, Error StoreError :> es) => Pool -> Tx.Transaction a -> Eff es a+runReadOnlyTxOnPool pool tx = do+  result <- liftIO $ Pool.use pool $ TxSessions.transaction TxSessions.RepeatableRead TxSessions.Read tx+  case result of+    Left usageErr -> throwError (mapTransactionUsageError usageErr)+    Right value -> pure value
src/Kiroku/Store/Error.hs view
@@ -33,6 +33,7 @@ import GHC.Generics (Generic) import Hasql.Errors qualified as Errors import Hasql.Pool (UsageError (..))+import Kiroku.Store.Compaction.Types (CompactionDigest, parseCompactionDigestHex) import Kiroku.Store.HistoryRetention.Types (HistoryRetentionConflict) import Kiroku.Store.Settings (DecodeFailure) import Kiroku.Store.Types@@ -149,6 +150,10 @@     --       human-readable message. This is *not* generally retryable —     --       investigate.     UnexpectedServerError !Text !Text+  | -- | Retryable concurrent reference/count mutation during compaction. The+    -- transaction committed nothing; re-preview and re-apply. Carries the+    -- manifest digest and the guard or foreign-key failure that aborted it.+    CompactionConcurrentMutation !CompactionDigest !Text   | -- | Catch-all for everything not matched by a more specific     --       constructor above. Retained for backward compatibility with     --       consumers that already pattern-match on it; new code should@@ -215,6 +220,9 @@ mapTransactionUsageError :: UsageError -> StoreError mapTransactionUsageError usageErr =   case extractServerError usageErr of+    Just (Errors.ServerError "KRCMP" message (Just detail) _ _)+      | Just digest <- parseCompactionDigestHex detail ->+          CompactionConcurrentMutation digest message     Just (Errors.ServerError "23505" message detail _ _)       | uniqueConstraintName message detail == Just "events_pkey" ->           DuplicateEvent (EventId <$> (detail >>= extractUuidFromDetail))
src/Kiroku/Store/Observability.hs view
@@ -53,10 +53,11 @@  import Control.Exception (SomeAsyncException, SomeException, asyncExceptionFromException, catch, throwIO) import Data.Foldable (for_)-import Data.Int (Int32)+import Data.Int (Int32, Int64) import Data.Time (NominalDiffTime) import Data.Time.Clock (UTCTime) import Hasql.Pool (UsageError)+import Kiroku.Store.Compaction.Types (CompactionDigest, CompactionId, CompactionRefusal) import Kiroku.Store.HistoryRetention.Types (HistoryRetentionConflict, HistoryRetentionLeaseId, HistoryRetentionLeaseOwner, HistoryRetentionPruneResult) import Kiroku.Store.Settings (DecodeFailure) import Kiroku.Store.Subscription.Fsm (DeadLetterReason (..), SubscriptionStopReason (..))@@ -226,6 +227,14 @@     KirokuEventHistoryRetentionLeasesPruned !HistoryRetentionPruneResult   | -- | Supported hard delete was refused before changing history.     KirokuEventHardDeleteHistoryRetentionConflict !StreamName !HistoryRetentionConflict+  | -- | Preview completed: manifest digest and selection count; no payload data.+    KirokuEventCompactionPreviewed !CompactionDigest !Int64+  | -- | Expected refusal: digest, first refusal and total refusal count.+    KirokuEventCompactionRefused !CompactionDigest !CompactionRefusal !Int+  | -- | Committed apply: ledger ID, manifest digest, events and memberships removed.+    KirokuEventCompactionApplied !CompactionId !CompactionDigest !Int64 !Int64+  | -- | Recorded manifest recognized; no row changed.+    KirokuEventCompactionAlreadyApplied !CompactionId !CompactionDigest   deriving stock (Show)  -- | Invoke the optional observability handler, dropping any synchronous exception
src/Kiroku/Store/Read.hs view
@@ -5,6 +5,7 @@     readAllForward,     readAllBackward,     visibleGlobalHeadPosition,+    storeIdentity,     readCategory,     getStream,     getStreamWithHead,@@ -15,6 +16,7 @@     eventExistsInStream,     lookupStreamName,     lookupStreamNames,+    lookupEventReferences,   ) where @@ -289,3 +291,32 @@ -- | An event as seen in the global log, using the configured typed decode hook. getEvent :: (HasCallStack, Store :> es) => EventId -> Eff es (Maybe RecordedEvent) getEvent eid = send (GetEvent eid)++-- | Read the immutable store identity in one statement. Restored clones share+-- their source identity; requires migration 0017.+storeIdentity :: (HasCallStack, Store :> es) => Eff es StoreIdentity+storeIdentity = send GetStoreIdentity++-- | Everything that refers to each of the given events. For every id that+-- exists, the result holds its 'EventReferenceInventory': memberships (the+-- @$all@ 'GlobalMembership' first, then home and link entries in ascending+-- stream-id order), the count of @dead_letters@ rows referencing it, and the+-- count of other events whose @causationId@ is it (events in the request are+-- not counted as each other's dependents). Ids that name no event are absent.+--+-- Soft-deleted streams are reported like live ones: the rows physically exist.+-- Use 'getStream' when stream state matters.+--+-- Passing @[]@ returns an empty map without a database round trip. Non-empty+-- input of any size runs on one pooled connection: the membership and+-- dead-letter statements execute in batches of at most 10000 ids, and the+-- causation statement executes once over the full request (its input is also its+-- exclusion set). This is an operator and maintenance read, not a hot path; it+-- is the inventory a caller consults before asking Kiroku to remove events (see+-- "Kiroku.Store.Compaction"). Inventory is advisory under concurrent mutation;+-- preview validates one repeatable-read snapshot and apply revalidates under locks.+lookupEventReferences ::+  (HasCallStack, Store :> es) =>+  [EventId] ->+  Eff es (Map EventId EventReferenceInventory)+lookupEventReferences eventIds = send (LookupEventReferences eventIds)
src/Kiroku/Store/SQL.hs view
@@ -35,7 +35,13 @@     getEventStmt,     currentGlobalPositionStmt,     visibleGlobalHeadPositionStmt,+    storeIdentityStmt, +    -- * Event reference inventory statements+    eventMembershipsStmt,+    deadLetterCountsStmt,+    causationDependentCountsStmt,+     -- * Consumer-group read statements     readCategoryForwardConsumerGroupStmt,     readCategoryConsumerGroupEncoder,@@ -1653,3 +1659,77 @@     "SELECT e.event_id,e.event_type,se.stream_version,se.stream_version AS global_position, se.original_stream_id,se.original_stream_version,e.data,e.metadata,e.causation_id,e.correlation_id,e.created_at FROM events e JOIN stream_events se ON se.event_id=e.event_id AND se.stream_id=0 WHERE e.event_id=$1"     (E.param (E.nonNullable E.uuid))     (D.rowMaybe recordedEventRow)++-- | The singleton identity installed by migration 0017.+storeIdentityStmt :: Statement () StoreIdentity+storeIdentityStmt =+  preparable+    "SELECT store_id FROM store_identity WHERE singleton"+    E.noParams+    (D.singleRow (StoreIdentity <$> D.column (D.nonNullable D.uuid)))++-- | Every junction row for the given event ids, with the stream name resolved.+-- Served by @stream_events_pkey@ on @(event_id, stream_id)@; the join to+-- @streams@ is a primary-key lookup per row. Rows are returned in ascending+-- @(event_id, stream_id)@ order so callers can group without sorting.+eventMembershipsStmt :: Statement (Vector UUID) (Vector (UUID, Int64, Text, Int64, Int64, Int64))+eventMembershipsStmt =+  preparable+    """+    SELECT se.event_id, se.stream_id, s.stream_name,+           se.stream_version, se.original_stream_id, se.original_stream_version+    FROM stream_events se+    JOIN streams s ON s.stream_id = se.stream_id+    WHERE se.event_id = ANY($1::uuid[])+    ORDER BY se.event_id, se.stream_id+    """+    (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.uuid))))+    ( D.rowVector+        ( (,,,,,)+            <$> D.column (D.nonNullable D.uuid)+            <*> D.column (D.nonNullable D.int8)+            <*> D.column (D.nonNullable D.text)+            <*> D.column (D.nonNullable D.int8)+            <*> D.column (D.nonNullable D.int8)+            <*> D.column (D.nonNullable D.int8)+        )+    )++-- | Dead-letter rows per referenced event, across all subscriptions and+-- members. Served by @ix_dead_letters_event_id@. Events with no dead letters are+-- absent from the result.+deadLetterCountsStmt :: Statement (Vector UUID) (Vector (UUID, Int64))+deadLetterCountsStmt =+  preparable+    """+    SELECT dl.event_id, count(*)+    FROM dead_letters dl+    WHERE dl.event_id = ANY($1::uuid[])+    GROUP BY dl.event_id+    """+    (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.uuid))))+    (D.rowVector ((,) <$> D.column (D.nonNullable D.uuid) <*> D.column (D.nonNullable D.int8)))++-- | Per referenced event, the number of other events whose @causation_id@ is+-- that event, excluding events that are themselves in the input set. Served by+-- the partial index @ix_events_causation_id@. The exclusion is an anti-join over+-- @unnest($1)@ — never @NOT (event_id = ANY($1))@, which degrades to a linear+-- scan of the array per candidate row at large input sizes. The input array is+-- simultaneously the lookup set and the exclusion set, so callers always execute+-- this statement once over the full request (batching it would silently change+-- "outside the input set" into "outside the batch"). Events with no dependents+-- are absent from the result.+causationDependentCountsStmt :: Statement (Vector UUID) (Vector (UUID, Int64))+causationDependentCountsStmt =+  preparable+    """+    SELECT e.causation_id, count(*)+    FROM events e+    LEFT JOIN unnest($1::uuid[]) AS excluded(event_id)+      ON excluded.event_id = e.event_id+    WHERE e.causation_id = ANY($1::uuid[])+      AND excluded.event_id IS NULL+    GROUP BY e.causation_id+    """+    (E.param (E.nonNullable (E.foldableArray (E.nonNullable E.uuid))))+    (D.rowVector ((,) <$> D.column (D.nonNullable D.uuid) <*> D.column (D.nonNullable D.int8)))
src/Kiroku/Store/Transaction.hs view
@@ -42,6 +42,8 @@   ( -- * Bare escape hatch     runTransaction,     runTransactionNoRetry,+    storeIdentityTx,+    lookupEventReferencesTx,      -- * Tx-flavored append building block     appendToStreamTx,@@ -67,15 +69,19 @@ import Control.Monad.IO.Class (MonadIO, liftIO) import Data.Functor (($>)) import Data.Generics.Labels ()+import Data.Map.Strict (Map)+import Data.Map.Strict qualified as Map import Data.Time.Clock (UTCTime, getCurrentTime)+import Data.Vector qualified as V import Effectful (Eff, IOE, (:>)) import Effectful.Dispatch.Dynamic (send) import GHC.Stack (HasCallStack) import Hasql.Transaction qualified as Tx import Kiroku.Store.Connection (KirokuStore)-import Kiroku.Store.Effect (PreparedEvent, Store (..), appendDispatchTx, buildAppendParams, prepareEvents)+import Kiroku.Store.Effect (PreparedEvent, Store (..), appendDispatchTx, assembleEventReferences, buildAppendParams, chunkUuids, maxEventReferenceBatch, prepareEvents) import Kiroku.Store.Effect.Resource (KirokuStoreResource, getKirokuStore) import Kiroku.Store.Error (AppendConflict (..), StoreError (..), appendConflictToStoreError, emptyResultConflict, validateStreamName)+import Kiroku.Store.SQL qualified as SQL import Kiroku.Store.Settings qualified as Settings import Kiroku.Store.Types @@ -361,3 +367,29 @@               Right ar ->                 Right <$> k ar       send (ctor body)++-- | Read the immutable store identity inside the caller's transaction.+storeIdentityTx :: Tx.Transaction StoreIdentity+storeIdentityTx = Tx.statement () SQL.storeIdentityStmt++-- | 'Kiroku.Store.Read.lookupEventReferences' inside the caller's transaction,+-- with caller-chosen isolation. Use repeatable read when inventory statements+-- and decisions must share one snapshot. Empty input runs no statement. Batching mirrors the interpreter: membership and+-- dead-letter statements at most 10000 ids per execution, the causation+-- statement once over the full request.+lookupEventReferencesTx :: [EventId] -> Tx.Transaction (Map EventId EventReferenceInventory)+lookupEventReferencesTx [] = pure Map.empty+lookupEventReferencesTx eventIds = do+  let uuids = V.fromList (Map.keys (Map.fromList [(uid, ()) | EventId uid <- eventIds]))+      batches = chunkUuids maxEventReferenceBatch uuids+  perBatch <-+    traverse+      ( \batch ->+          (,)+            <$> Tx.statement batch SQL.eventMembershipsStmt+            <*> Tx.statement batch SQL.deadLetterCountsStmt+      )+      batches+  dependents <- Tx.statement uuids SQL.causationDependentCountsStmt+  let (memberships, deadLetters) = unzip perBatch+  pure (assembleEventReferences (V.concat memberships) (V.concat deadLetters) dependents)
src/Kiroku/Store/Types.hs view
@@ -5,6 +5,7 @@     EventType (..),     StreamVersion (..),     GlobalPosition (..),+    StoreIdentity (..),     ExpectedVersion (..),     EventData (..),     RecordedEvent (..),@@ -15,6 +16,8 @@     categoryName,     streamNameInCategory,     EventFilter (..),+    EventMembership (..),+    EventReferenceInventory (..),     BrowsePageSize,     BrowsePageSizeError (..),     mkBrowsePageSize,@@ -22,12 +25,13 @@   ) where -import Data.Aeson (Value)+import Data.Aeson (FromJSON (..), ToJSON (..), Value) import Data.Int (Int32, Int64) import Data.Text (Text) import Data.Text qualified as Text import Data.Time (UTCTime) import Data.UUID (UUID)+import Data.Vector (Vector) import GHC.Generics (Generic)  -- Stream identification@@ -323,3 +327,40 @@  browsePageSizeValue :: BrowsePageSize -> Int32 browsePageSizeValue (BrowsePageSize n) = n++-- | Immutable, migration-installed store identity. Restored clones share the+-- source identity so the same maintenance manifest can be rehearsed on a clone.+newtype StoreIdentity = StoreIdentity UUID+  deriving stock (Eq, Ord, Show, Generic)++-- | How an event is visible in one stream. Every event has exactly one+-- 'HomeMembership' (the stream it was appended to, with the version it holds+-- there) and one 'GlobalMembership' (the @$all@ log, carrying the event's global+-- position); each 'Kiroku.Store.Link.linkToStream' adds one 'LinkMembership'+-- naming the target stream and the version the event holds in it. Each+-- constructor carries exactly the identity its membership class has, so a global+-- position is never smuggled through a 'StreamVersion' field.+data EventMembership+  = HomeMembership !StreamName !StreamId !StreamVersion+  | GlobalMembership !GlobalPosition+  | LinkMembership !StreamName !StreamId !StreamVersion+  deriving stock (Eq, Show, Generic)++-- | Everything in the store that refers to one event: its memberships — the+-- 'GlobalMembership' first (its junction row is stream id 0), then home and link+-- entries in ascending stream-id order — the number of @dead_letters@ rows that+-- reference it (across all subscriptions), and the number of /other/ events+-- whose @causation_id@ is this event. Produced by+-- 'Kiroku.Store.Read.lookupEventReferences'.+data EventReferenceInventory = EventReferenceInventory+  { memberships :: !(Vector EventMembership),+    deadLetterCount :: !Int64,+    causationDependentCount :: !Int64+  }+  deriving stock (Eq, Show, Generic)++instance ToJSON StoreIdentity where+  toJSON (StoreIdentity uid) = toJSON uid++instance FromJSON StoreIdentity where+  parseJSON value = StoreIdentity <$> parseJSON value
test/Main.hs view
@@ -32,11 +32,18 @@ import Test.Category qualified as Category import Test.CategoryIdleNoSpin qualified as CategoryIdleNoSpin import Test.Causation qualified as Causation+import Test.CompactionApply qualified as CompactionApply+import Test.CompactionApplyMock qualified as CompactionApplyMock+import Test.CompactionManifest qualified as CompactionManifest+import Test.CompactionPreview qualified as CompactionPreview+import Test.CompactionPreviewMock qualified as CompactionPreviewMock import Test.Concurrency qualified as Concurrency import Test.ConsumerGroup qualified as ConsumerGroup import Test.ConsumerGroupEffect qualified as ConsumerGroupEffect import Test.ConsumerGroupResize qualified as ConsumerGroupResize import Test.ConsumerGroupSql qualified as ConsumerGroupSql+import Test.EventReferences qualified as EventReferences+import Test.EventReferencesMock qualified as EventReferencesMock import Test.EventTypeFilter qualified as EventTypeFilter import Test.FailureInjection qualified as FailureInjection import Test.HandlerStall qualified as HandlerStall@@ -56,6 +63,8 @@ import Test.ReadStream qualified as ReadStream import Test.ScriptSessionErrors qualified as ScriptSessionErrors import Test.StartupFailureSurfacing qualified as StartupFailureSurfacing+import Test.StoreIdentity qualified as StoreIdentity+import Test.StoreIdentityMock qualified as StoreIdentityMock import Test.StreamBridgeTermination qualified as StreamBridgeTermination import Test.StreamHead qualified as StreamHead import Test.StreamHeadIsolation qualified as StreamHeadIsolation@@ -85,6 +94,15 @@  main :: IO () main = withSharedMigratedPostgres $ hspec $ do+  CompactionApply.spec+  CompactionApplyMock.spec+  CompactionPreview.spec+  CompactionPreviewMock.spec+  CompactionManifest.spec+  EventReferences.spec+  EventReferencesMock.spec+  StoreIdentity.spec+  StoreIdentityMock.spec   BrowseReads.spec   BrowseReadsMock.spec   SubscriptionDeadLetters.spec
+ test/Test/CompactionApply.hs view
@@ -0,0 +1,395 @@+{-# LANGUAGE MultilineStrings #-}+{-# LANGUAGE OverloadedRecordDot #-}++module Test.CompactionApply (spec) where++import Control.Concurrent (myThreadId, threadDelay)+import Control.Concurrent.Async qualified as Async+import Control.Concurrent.MVar (newEmptyMVar)+import Control.Lens ((&), (.~), (^.))+import Control.Monad (forM_, void, when)+import Data.Aeson qualified as Aeson+import Data.Generics.Labels ()+import Data.IORef (modifyIORef', newIORef, readIORef, writeIORef)+import Data.List.NonEmpty (NonEmpty (..))+import Data.Text (Text)+import Data.Text qualified as T+import Data.Text.Encoding qualified as TE+import Data.UUID qualified as UUID+import Data.Vector qualified as V+import Hasql.Decoders qualified as D+import Hasql.Encoders qualified as E+import Hasql.Pool qualified as Pool+import Hasql.Session qualified as Session+import Hasql.Statement (preparable)+import Hasql.Transaction qualified as Tx+import Hedgehog hiding (property)+import Hedgehog.Gen qualified as Gen+import Hedgehog.Range qualified as Range+import Kiroku.Store hiding (id, target)+import Kiroku.Store.SQL qualified as StoreSQL+import Kiroku.Test.Postgres (withMigratedTestDatabase)+import Test.CompactionHelpers+import Test.Helpers+import Test.Hspec+import Test.Hspec.Hedgehog (hedgehog, modifyMaxSuccess)++spec :: Spec+spec = describe "compaction apply" $ do+  it "removes exactly the selections, preserves retained envelopes and records the preview report" $ withTestStore $ \store -> do+    events <- seed store origin 6+    input <- inputFor store [events !! 1, events !! 3]+    let m = manifest input+        kept = filter (\e -> e.eventId `notElem` map (\s -> s.eventId) input.selections) events+    preview <- readyReport store m+    Right (CompactionAppliedNow record) <- storeRead =<< runStoreIO store (applyCompaction m)+    record.report `shouldBe` preview+    runStoreIO store (readAllForward (GlobalPosition 0) 100) `shouldReturn` Right (V.fromList kept)+    countEvents store `shouldReturn` 4+    Just info <- storeRead =<< runStoreIO store (getStream origin)+    info.version `shouldBe` StreamVersion 6+    runStoreIO store (compactionLedger (ledgerQuery 10)) `shouldReturn` Right (V.singleton record)+    runStoreIO store (runTransaction (compactionLedgerTx (ledgerQuery 10))) `shouldReturn` Right (V.singleton record)+    record.report.reportDigest `shouldBe` compactionReportDigest record.report+    runStoreIO store (applyCompaction m) `shouldReturn` Right (Right (CompactionAlreadyApplied record))+    runStoreIO store (previewCompaction m) `shouldReturn` Right (Right (CompactionPreviewAlreadyApplied record))+    let retainedVersions = [1, 3, 5, 6]+    forward <- storeRead =<< runStoreIO store (readStreamForward origin (StreamVersion 0) 100)+    map (\e -> e.streamVersion) (V.toList forward) `shouldBe` map StreamVersion retainedVersions+    backward <- storeRead =<< runStoreIO store (readStreamBackward origin (StreamVersion 100) 100)+    map (\e -> e.streamVersion) (V.toList backward) `shouldBe` map StreamVersion (reverse retainedVersions)+    allBack <- storeRead =<< runStoreIO store (readAllBackward (GlobalPosition 100) 100)+    allBack `shouldBe` V.fromList (reverse kept)+    afterGap <- storeRead =<< runStoreIO store (readAllForward (GlobalPosition 2) 100)+    afterGap `shouldBe` V.fromList (filter (\e -> e.globalPosition > GlobalPosition 2) kept)+    category <- storeRead =<< runStoreIO store (readCategory (CategoryName "orders") (GlobalPosition 0) 100)+    category `shouldBe` V.fromList kept+    appended <- storeRead =<< runStoreIO store (appendToStream origin (ExactVersion (StreamVersion 6)) [makeEvent "AfterCompaction" (Aeson.object [])])+    appended.streamVersion `shouldBe` StreamVersion 7+    runStoreIO store (appendToStream origin (ExactVersion (StreamVersion 4)) [makeEvent "Stale" (Aeson.object [])])+      `shouldReturn` Left (WrongExpectedVersion origin (ExactVersion (StreamVersion 4)) (StreamVersion 0))++  it "rolls back both deletion and ledger insertion when the caller condemns its transaction" $ withTestStore $ \store -> do+    events <- seed store origin 3+    input <- inputFor store [events !! 1]+    Right (CompactionAppliedNow _) <- storeRead =<< runStoreIO store (runTransaction (applyCompactionTx (manifest input) <* Tx.condemn))+    runStoreIO store (readAllForward (GlobalPosition 0) 100) `shouldReturn` Right (V.fromList events)+    runStoreIO store (compactionLedger (ledgerQuery 10)) `shouldReturn` Right V.empty++  it "refuses unexpected links and preserves retained links and all heads" $ withTestStore $ \store -> do+    events <- seed store origin 3+    _ <- storeRead =<< runStoreIO store (linkToStream target (map (\e -> e.eventId) events))+    _ <- storeRead =<< runStoreIO store (linkToStream mirror [(events !! 1).eventId])+    input <- inputFor store [events !! 1]+    let [selection] = input.selections+        refused = manifest (input & #selections .~ [selection & #acknowledgedLinks .~ V.empty] & #streamHeads .~ [StreamHeadWitness origin (StreamVersion 3)])+    Right (Left _) <- runStoreIO store (applyCompaction refused)+    countEvents store `shouldReturn` 3+    runStoreIO store (compactionLedger (ledgerQuery 10)) `shouldReturn` Right V.empty+    before <- storeRead =<< runStoreIO store (readStreamForward target (StreamVersion 0) 100)+    Right (CompactionAppliedNow record) <- storeRead =<< runStoreIO store (applyCompaction (manifest input))+    record.report.linkMemberships `shouldBe` 2+    after <- storeRead =<< runStoreIO store (readStreamForward target (StreamVersion 0) 100)+    after `shouldBe` V.filter (\e -> e.eventId /= selection.eventId) before+    Just info <- storeRead =<< runStoreIO store (getStream target)+    info.version `shouldBe` StreamVersion 3++  it "retains high-water frontiers after tail and complete-stream compaction" $ withTestStore $ \store -> do+    events <- seed store origin 3+    input <- inputFor store (drop 1 events)+    _ <- applied store (manifest input)+    runStoreIO store visibleGlobalHeadPosition `shouldReturn` Right (GlobalPosition 1)+    frontier <- storeRead =<< runStoreIO store subscriptionCheckpointInventory+    frontier.storePosition `shouldBe` GlobalPosition 3+    lastInput <- inputFor store [head events]+    _ <- applied store (manifest lastInput)+    runStoreIO store visibleGlobalHeadPosition `shouldReturn` Right (GlobalPosition 0)+    Just info <- storeRead =<< runStoreIO store (getStream origin)+    info.version `shouldBe` StreamVersion 3+    append <- storeRead =<< runStoreIO store (appendToStream origin (ExactVersion (StreamVersion 3)) [makeEvent "AfterEmpty" (Aeson.object [])])+    (append.streamVersion, append.globalPosition) `shouldBe` (StreamVersion 4, GlobalPosition 4)++  it "refuses changed heads, missing events and a ledger whose IDs reappear" $ withTestStore $ \store -> do+    events <- seed store origin 3+    input <- inputFor store [events !! 1]+    _ <- seed store origin 1+    runStoreIO store (applyCompaction (manifest input))+      `shouldReturn` Right (Left (CompactionStreamHeadDrift origin (StreamVersion 3) (StreamVersion 4) :| []))+    fresh <- inputFor store [events !! 1]+    record <- applied store (manifest fresh)+    let missing = manifest (fresh & #operation .~ operation "never-applied")+    runStoreIO store (applyCompaction missing) `shouldReturn` Right (Left (CompactionSelectedEventMissing (events !! 1).eventId :| []))+    _ <- storeRead =<< runStoreIO store (appendToStream (StreamName "reused-id") NoStream [makeEvent "Reused" (Aeson.object []) & #eventId .~ Just (events !! 1).eventId])+    runStoreIO store (applyCompaction (manifest fresh)) `shouldReturn` Right (Left (CompactionLedgerConflict record.compactionId 1 :| []))++  it "refuses a partially matching store before deleting any surviving selection" $ withTestStore $ \store -> do+    events <- seed store origin 3+    whole <- inputFor store (take 2 events)+    first <- inputFor store [head events]+    _ <- applied store (manifest first)+    runStoreIO store (applyCompaction (manifest whole))+      `shouldReturn` Right (Left (CompactionSelectedEventMissing (head events).eventId :| []))+    runStoreIO store (readAllForward (GlobalPosition 0) 100) `shouldReturn` Right (V.fromList (tail events))+    records <- storeRead =<< runStoreIO store (compactionLedger (ledgerQuery 10))+    V.length records `shouldBe` 1++  it "removes dead letters only by policy and permits explicitly dangling causation" $ withTestStore $ \store -> do+    [event] <- seed store origin 1+    let EventId uid = event.eventId+    _ <- storeRead =<< runStoreIO store (appendToStream target NoStream [makeEvent "Dependent" (Aeson.object []) & #causationId .~ Just uid])+    insertDeadLetterForEvent store "projection" event+    input <- inputFor store [event]+    Right (Left _) <- runStoreIO store (applyCompaction (manifest input))+    countEvents store `shouldReturn` 2+    record <- applied store (manifest (input & #deadLetterPolicy .~ RemoveDeadLetters & #causationPolicy .~ AllowDanglingCausation))+    (record.report.deadLettersRemoved, record.report.causationDependents) `shouldBe` (1, 1)+    countDeadLettersForEvents store [EventId uid] `shouldReturn` 0+    kept <- storeRead =<< runStoreIO store (readAllForward (GlobalPosition 0) 100)+    (V.head kept).causationId `shouldBe` Just uid++  it "refuses an active lease, then applies below a logical truncate marker" $ withTestStore $ \store -> do+    events <- seed store origin 3+    input <- inputFor store [head events]+    _ <- storeRead =<< runStoreIO store (setStreamTruncateBefore origin (StreamVersion 3))+    let Right owner = mkHistoryRetentionLeaseOwner "apply"+        Right reason = mkHistoryRetentionLeaseReason "test"+        Right duration = mkHistoryRetentionLeaseDuration 60+    lease <- storeRead =<< runStoreIO store (acquireHistoryRetentionLease (HistoryRetentionLeaseRequest owner reason duration))+    Right (Left (CompactionHistoryRetentionActive _ :| [])) <- runStoreIO store (applyCompaction (manifest input))+    countEvents store `shouldReturn` 3+    _ <- storeRead =<< runStoreIO store (releaseHistoryRetentionLease (HistoryRetentionLeaseHandle lease.leaseId owner))+    _ <- applied store (manifest input)+    Just info <- storeRead =<< runStoreIO store (getStream origin)+    info.truncateBefore `shouldBe` StreamVersion 3++  forM_ [(AllStreams, 1, False), (AllStreams, 2, False), (Category (CategoryName "orders"), 2, False), (AllStreams, 2, True), (Category (CategoryName "orders"), 2, True)] $ \(subscriptionTarget, checkpoint, grouped) ->+    it ("continues subscription catch-up and live delivery across removed positions: " <> show (subscriptionTarget, checkpoint, grouped)) $ do+      ready <- newEmptyMVar+      let name = SubscriptionName "compaction-resume"+          tweak settings = settings & #eventHandler .~ Just (caughtUpEventHandler name ready Nothing)+      withTestStoreSettings tweak $ \store -> do+        events <- seed store origin 6+        input <- inputFor store [events !! 1, events !! 3]+        _ <- applied store (manifest input)+        _ <- storeRead =<< runStoreIO store (runTransaction (Tx.statement ("compaction-resume", checkpoint) StoreSQL.saveCheckpointStmt))+        received <- newIORef []+        let handler event = do+              modifyIORef' received (event.globalPosition :)+              pure (if event.globalPosition == GlobalPosition 7 then Stop else Continue)+            config = (defaultSubscriptionConfig name subscriptionTarget handler) {consumerGroup = if grouped then Just (validConsumerGroup 0 1) else Nothing}+        handle <- subscribe store config+        within (waitForSubscriptionLive ready)+        _ <- storeRead =<< runStoreIO store (appendToStream origin (ExactVersion (StreamVersion 6)) [makeEvent "PostGap" (Aeson.object [])])+        completed <- waitWithTimeout 5_000_000 handle+        completed `shouldSatisfy` \case Right (Right ()) -> True; _ -> False+        reverse <$> readIORef received `shouldReturn` map GlobalPosition [3, 5, 6, 7]+        inventory <- storeRead =<< runStoreIO store subscriptionCheckpointInventory+        -- The worker persists the terminal delivery through the normal save path.+        rows <- storeRead =<< runStoreIO store (runTransaction (Tx.statement ("compaction-resume", 0) StoreSQL.getCheckpointMemberStmt))+        rows `shouldBe` Just 7+        inventory.storePosition `shouldBe` GlobalPosition 7++  it "serializes competing applies into one commit and one identical ledgered no-op" $ withTestStore $ \store -> do+    events <- seed store origin 3+    input <- inputFor store [head events]+    (first, second) <- Async.concurrently (runStoreIO store (applyCompaction (manifest input))) (runStoreIO store (applyCompaction (manifest input)))+    let records = [record | Right (Right (CompactionAppliedNow record)) <- [first, second]]+        repeated = [record | Right (Right (CompactionAlreadyApplied record)) <- [first, second]]+    length records `shouldBe` 1+    repeated `shouldBe` records++  it "blocks an append while apply holds stream locks, then continues from the unchanged head" $ withTestStore $ \store -> do+    events <- seed store origin 3+    input <- inputFor store [head events]+    Async.withAsync (runStoreIO store (runTransaction (applyCompactionTx (manifest input) <* pause "apply-held"))) $ \worker -> do+      waitPhase store "apply-held"+      Async.withAsync (runStoreIO store (appendToStream origin (ExactVersion (StreamVersion 3)) [makeEvent "BlockedAppend" (Aeson.object [])])) $ \writer -> do+        threadDelay 50_000+        Async.poll writer >>= (`shouldSatisfy` maybe True (const False))+        Right (Right (CompactionAppliedNow _)) <- within (Async.wait worker)+        Right result <- within (Async.wait writer)+        result.streamVersion `shouldBe` StreamVersion 4++  it "waits for a replay stream guard, but a recorded no-op never takes stream locks" $ withTestStore $ \store -> do+    events <- seed store origin 3+    input <- inputFor store [head events]+    let m = manifest input+    Async.withAsync (runStoreIO store (runTransaction (lockStreamHistoryForReplayTx origin <* pause "guard-held"))) $ \guard -> do+      waitPhase store "guard-held"+      Async.withAsync (runStoreIO store (applyCompaction m)) $ \worker -> do+        threadDelay 50_000+        Async.poll worker >>= (`shouldSatisfy` maybe True (const False))+        _ <- within (Async.wait guard)+        Right (Right (CompactionAppliedNow _)) <- within (Async.wait worker)+        pure ()+    Async.withAsync (runStoreIO store (runTransaction (lockStreamHistoryForReplayTx origin <* pause "noop-guard"))) $ \guard -> do+      waitPhase store "noop-guard"+      Right (Right (CompactionAlreadyApplied _)) <- withinShort (runStoreIO store (applyCompaction m))+      void (within (Async.wait guard))++  it "rejects another store's manifest before queuing for the coordinator" $ withTestStore $ \store -> do+    events <- seed store origin 1+    input <- inputFor store events+    let wrong = manifest (input & #storeIdentity .~ StoreIdentity UUID.nil)+    Async.withAsync (runStoreIO store (runTransaction (Tx.sql "SELECT singleton FROM history_retention_coordinator WHERE singleton FOR UPDATE" >> pause "coordinator-held"))) $ \holder -> do+      waitPhase store "coordinator-held"+      withinShort (runStoreIO store (applyCompaction wrong))+        `shouldReturn` Right (Left (CompactionStoreIdentityMismatch (StoreIdentity UUID.nil) input.storeIdentity :| []))+      void (within (Async.wait holder))++  it "keeps an unlisted concurrent link and rolls back every deletion (direct composer FK path)" $ withTestStore $ \store -> do+    events <- seed store origin 2+    input <- inputFor store [head events]+    Async.withAsync (runStoreIO store (runTransaction (applyCompactionTxWith (pause "concurrent-link") (manifest input)))) $ \worker -> do+      waitPhase store "concurrent-link"+      _ <- storeRead =<< runStoreIO store (linkToStream target [(head events).eventId])+      result <- within (Async.wait worker)+      result `shouldSatisfy` \case Left (UnexpectedServerError "23503" _) -> True; _ -> False+    runStoreIO store (readAllForward (GlobalPosition 0) 100) `shouldReturn` Right (V.fromList events)+    runStoreIO store (compactionLedger (ledgerQuery 10)) `shouldReturn` Right V.empty+    links <- storeRead =<< runStoreIO store (readStreamForward target (StreamVersion 0) 100)+    V.length links `shouldBe` 1++  forM_ [RefuseDeadLetters, RemoveDeadLetters] $ \policy ->+    it ("rolls back a concurrent dead letter under " <> show policy) $ withTestStore $ \store -> do+      events <- seed store origin 2+      input <- inputFor store [head events]+      let m = manifest (input & #deadLetterPolicy .~ policy)+      Async.withAsync (runStoreIO store (runTransaction (applyCompactionTxWith (pause "concurrent-dead-letter") m))) $ \worker -> do+        waitPhase store "concurrent-dead-letter"+        insertDeadLetterForEvent store "concurrent" (head events)+        result <- within (Async.wait worker)+        case policy of+          RefuseDeadLetters -> result `shouldSatisfy` \case Left (UnexpectedServerError "23503" _) -> True; _ -> False+          RemoveDeadLetters -> result `shouldSatisfy` \case Left (CompactionConcurrentMutation digest "dead-letter count") -> digest == compactionManifestDigest m; _ -> False+      countEvents store `shouldReturn` 2+      let EventId uid = (head events).eventId+      countDeadLettersForEvents store [EventId uid] `shouldReturn` 1+      runStoreIO store (compactionLedger (ledgerQuery 10)) `shouldReturn` Right V.empty++  it "maps the real effect's concurrent foreign-key failure to a typed retryable error" $ withTestStore $ \store -> do+    events <- seed store origin 2+    input <- inputFor store [head events]+    raw store "CREATE FUNCTION kiroku.test_compaction_pause() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN PERFORM pg_sleep(0.6); RETURN NULL; END $$; CREATE TRIGGER test_compaction_pause BEFORE DELETE ON kiroku.stream_events FOR EACH STATEMENT EXECUTE FUNCTION kiroku.test_compaction_pause()"+    Async.withAsync (runStoreIO store (applyCompaction (manifest input))) $ \worker -> do+      waitUntil store "SELECT EXISTS (SELECT 1 FROM pg_stat_activity WHERE datname=current_database() AND state='active' AND wait_event='PgSleep' AND query LIKE 'DELETE FROM stream_events%')"+      _ <- storeRead =<< runStoreIO store (linkToStream target [(head events).eventId])+      result <- within (Async.wait worker)+      result `shouldSatisfy` \case Left (CompactionConcurrentMutation digest _) -> digest == compactionManifestDigest (manifest input); _ -> False+    countEvents store `shouldReturn` 2+    runStoreIO store (compactionLedger (ledgerQuery 10)) `shouldReturn` Right V.empty++  it "documents causation refusal as validation-time best-effort for unlisted writers" $ withTestStore $ \store -> do+    events <- seed store origin 2+    input <- inputFor store [head events]+    let EventId uid = (head events).eventId+    Async.withAsync (runStoreIO store (runTransaction (applyCompactionTxWith (pause "concurrent-causation") (manifest input)))) $ \worker -> do+      waitPhase store "concurrent-causation"+      _ <- storeRead =<< runStoreIO store (appendToStream target NoStream [makeEvent "LateDependent" (Aeson.object []) & #causationId .~ Just uid])+      Right (Right (CompactionAppliedNow record)) <- within (Async.wait worker)+      record.report.causationDependents `shouldBe` 0+    kept <- storeRead =<< runStoreIO store (readAllForward (GlobalPosition 0) 100)+    (V.last kept).causationId `shouldBe` Just uid++  it "accepts sequential disjoint chunks without changing their shared stream-head witness" $ withTestStore $ \store -> do+    events <- seed store origin 4+    first <- inputFor store [events !! 0]+    second <- inputFor store [events !! 1]+    older <- applied store (manifest first)+    newer <- applied store (manifest second)+    runStoreIO store (compactionLedger (ledgerQuery 1)) `shouldReturn` Right (V.singleton newer)+    runStoreIO store (compactionLedger (ledgerQuery 10)) `shouldReturn` Right (V.fromList [newer, older])+    retained <- storeRead =<< runStoreIO store (readAllForward (GlobalPosition 0) 100)+    retained `shouldBe` V.fromList (drop 2 events)++  it "shares KRCMP error mapping with every transaction composer and rejects malformed details" $ withTestStore $ \store -> do+    events <- seed store origin 1+    input <- inputFor store events+    let digest = compactionManifestDigest (manifest input)+        raise detail = runStoreIO store (runTransaction (Tx.sql (TE.encodeUtf8 ("DO $$ BEGIN RAISE EXCEPTION USING ERRCODE='KRCMP', MESSAGE='events count', DETAIL='" <> detail <> "'; END $$"))))+    raise (compactionDigestHex digest) `shouldReturn` Left (CompactionConcurrentMutation digest "events count")+    raise "malformed" `shouldReturn` Left (UnexpectedServerError "KRCMP" "events count")++  it "emits committed, already-applied and refused events with one caller checkout per apply" $ do+    owner <- myThreadId+    calls <- newIORef (0 :: Int)+    eventsRef <- newIORef []+    let tweak settings =+          settings+            { eventHandler = Just (\event -> modifyIORef' eventsRef (event :)),+              observationHandler = Just $ \case+                ConnectionObservation _ InUseConnectionStatus -> do current <- myThreadId; when (current == owner) (modifyIORef' calls (+ 1))+                _ -> pure ()+            }+    withTestStoreSettings tweak $ \store -> do+      events <- seed store origin 2+      input <- inputFor store [head events]+      writeIORef eventsRef []+      before <- readIORef calls+      record <- applied store (manifest input)+      readIORef calls `shouldReturn` before + 1+      emitted <- readIORef eventsRef+      [(cid, digest, count, memberships)] <- pure [(cid, d, n, ms) | KirokuEventCompactionApplied cid d n ms <- emitted]+      (cid, digest, count, memberships) `shouldBe` (record.compactionId, compactionManifestDigest (manifest input), 1, 2)+      _ <- storeRead =<< runStoreIO store (applyCompaction (manifest input))+      again <- readIORef eventsRef+      [repeated] <- pure [cid | KirokuEventCompactionAlreadyApplied cid _ <- again]+      repeated `shouldBe` record.compactionId+      let changed = manifest (input & #operation .~ operation "unrecorded")+      _ <- storeRead =<< runStoreIO store (applyCompaction changed)+      refused <- readIORef eventsRef+      [total] <- pure [n | KirokuEventCompactionRefused _ _ n <- refused]+      total `shouldBe` 1++  modifyMaxSuccess (const 20) $ it "compactingARandomSubsetPreservesTheRest across streams and links" $ hedgehog $ do+    widths <- forAll (Gen.list (Range.linear 2 4) (Gen.int (Range.linear 3 8)))+    picks <- forAll (Gen.list (Range.singleton (sum widths)) Gen.bool)+    evalIO $ withMigratedTestDatabase $ \conn -> withStore (defaultConnectionSettings conn) $ \store -> do+      groups <- sequence [seed store (StreamName ("random-" <> T.pack (show n))) width | (n, width) <- zip [1 :: Int ..] widths]+      let allEvents = concat groups+          chosen = case [e | (e, True) <- zip allEvents picks] of [] -> [head allEvents]; xs -> xs+      _ <- storeRead =<< runStoreIO store (linkToStream target (map (\e -> e.eventId) (take 3 allEvents)))+      input <- inputFor store chosen+      before <- storeRead =<< runStoreIO store (readAllForward (GlobalPosition 0) 100)+      linked <- storeRead =<< runStoreIO store (readStreamForward target (StreamVersion 0) 100)+      let selected = map (\e -> e.eventId) chosen+      _ <- applied store (manifest input)+      runStoreIO store (readAllForward (GlobalPosition 0) 100) `shouldReturn` Right (V.filter (\e -> e.eventId `notElem` selected) before)+      runStoreIO store (readStreamForward target (StreamVersion 0) 100) `shouldReturn` Right (V.filter (\e -> e.eventId `notElem` selected) linked)+      forM_ input.streamHeads $ \h -> do+        Just stream <- storeRead =<< runStoreIO store (getStream h.stream)+        stream.version `shouldBe` h.headVersion+      countEvents store `shouldReturn` fromIntegral (length allEvents - length chosen)++origin, target, mirror :: StreamName+origin = StreamName "orders-1"+target = StreamName "audit-1"+mirror = StreamName "audit-2"++operation :: Text -> CompactionOperation+operation = either (error . show) id . mkCompactionOperation++applied :: KirokuStore -> CompactionManifest -> IO CompactionRecord+applied store m = do+  Right (CompactionAppliedNow record) <- storeRead =<< runStoreIO store (applyCompaction m)+  pure record++pause :: Text -> Tx.Transaction ()+pause marker = void (Tx.statement () (preparable ("SELECT pg_sleep(0.6) IS NULL /* " <> marker <> " */") E.noParams (D.singleRow (D.column (D.nonNullable D.bool)))))++waitPhase :: KirokuStore -> Text -> IO ()+waitPhase store marker = waitUntil store ("SELECT EXISTS (SELECT 1 FROM pg_stat_activity WHERE datname=current_database() AND state='active' AND query='SELECT pg_sleep(0.6) IS NULL /* " <> marker <> " */')")++waitUntil :: KirokuStore -> Text -> IO ()+waitUntil store query = within $ loop (150 :: Int)+  where+    loop 0 = fail "compaction barrier was not reached"+    loop n = do+      found <- storeRead =<< Pool.use (store ^. #pool) (Session.statement () (preparable query E.noParams (D.singleRow (D.column (D.nonNullable D.bool)))))+      if found then pure () else threadDelay 10_000 >> loop (n - 1)++within :: IO a -> IO a+within action = Async.race (threadDelay 5_000_000) action >>= either (const (fail "compaction test deadline expired")) pure++withinShort :: IO a -> IO a+withinShort action = Async.race (threadDelay 250_000) action >>= either (const (fail "no-op or identity refusal waited for an unrelated lock")) pure
+ test/Test/CompactionApplyMock.hs view
@@ -0,0 +1,41 @@+module Test.CompactionApplyMock (spec) where++import Control.Monad.IO.Class (liftIO)+import Data.IORef (IORef, modifyIORef', newIORef, readIORef)+import Data.Time (UTCTime (..), fromGregorian)+import Data.UUID qualified as UUID+import Data.Vector qualified as V+import Effectful (Eff, IOE, runEff, (:>))+import Effectful.Dispatch.Dynamic (interpret_)+import Kiroku.Store hiding (id)+import Test.CompactionHelpers (ledgerQuery)+import Test.Hspec++spec :: Spec+spec = describe "compaction apply mock" $+  it "dispatches apply and ledger once each with their original arguments" $ do+    calls <- newIORef []+    let Right op = mkCompactionOperation "mock"+        identity = StoreIdentity UUID.nil+        heads = [StreamHeadWitness (StreamName "mock-1") (StreamVersion 1)]+        selection = CompactionSelection (EventId UUID.nil) (StreamName "mock-1") (StreamVersion 1) (GlobalPosition 1) V.empty+        Right m = mkCompactionManifest (CompactionManifestInput identity op RefuseDeadLetters RefuseCausationDependents heads [selection] Nothing)+        digest = compactionManifestDigest m+        report = sealCompactionReport (CompactionReport digest identity op RefuseDeadLetters RefuseCausationDependents 1 1 1 0 0 0 (GlobalPosition 1) (GlobalPosition 1) (V.fromList heads) digest)+        record = CompactionRecord (CompactionId UUID.nil) report (UTCTime (fromGregorian 2026 10 27) 0) "mock"+        query = ledgerQuery 10+    actual <- runEff $ runApplyMock calls m query record $ (,) <$> applyCompaction m <*> compactionLedger query+    actual `shouldBe` (Right (CompactionAppliedNow record), V.singleton record)+    readIORef calls `shouldReturn` ["ledger", "apply"]++runApplyMock :: (IOE :> es) => IORef [String] -> CompactionManifest -> CompactionLedgerQuery -> CompactionRecord -> Eff (Store : es) a -> Eff es a+runApplyMock calls expected query record = interpret_ $ \case+  ApplyCompaction m -> do+    liftIO $ m `shouldBe` expected+    liftIO $ modifyIORef' calls ("apply" :)+    pure (Right (CompactionAppliedNow record))+  GetCompactionLedger got -> do+    liftIO $ got `shouldBe` query+    liftIO $ modifyIORef' calls ("ledger" :)+    pure (V.singleton record)+  _ -> error "unexpected Store operation in compaction apply mock"
+ test/Test/CompactionHelpers.hs view
@@ -0,0 +1,58 @@+{-# LANGUAGE OverloadedRecordDot #-}++module Test.CompactionHelpers (seed, inputFor, manifest, ledgerQuery, raw, storeRead, readyReport) where++import Control.Lens ((^.))+import Control.Monad (forM)+import Data.Aeson qualified as Aeson+import Data.Generics.Labels ()+import Data.Int (Int32)+import Data.Map.Strict qualified as Map+import Data.Set qualified as Set+import Data.Text (Text)+import Data.Vector qualified as V+import Hasql.Pool qualified as Pool+import Hasql.Session qualified as Session+import Kiroku.Store hiding (id)+import Test.Helpers (makeEvent)++seed :: KirokuStore -> StreamName -> Int -> IO [RecordedEvent]+seed store name count = do+  _ <- storeRead =<< runStoreIO store (appendToStream name AnyVersion [makeEvent "CompactionFixture" (Aeson.object ["ordinal" Aeson..= n]) | n <- [1 .. count]])+  Just sid <- storeRead =<< runStoreIO store (lookupStreamId name)+  events <- storeRead =<< runStoreIO store (readAllForward (GlobalPosition 0) 1000000)+  pure (filter (\event -> event.originalStreamId == sid) (V.toList events))++inputFor :: KirokuStore -> [RecordedEvent] -> IO CompactionManifestInput+inputFor store events = do+  identity <- storeRead =<< runStoreIO store storeIdentity+  references <- storeRead =<< runStoreIO store (lookupEventReferences (map (\e -> e.eventId) events))+  selections <- forM events $ \event -> do+    let inventory = references Map.! event.eventId+        [HomeMembership name _ version] = [home | home@HomeMembership {} <- V.toList inventory.memberships]+        [GlobalMembership position] = [global | global@GlobalMembership {} <- V.toList inventory.memberships]+        links = V.fromList [LinkWitness target version | LinkMembership target _ version <- V.toList inventory.memberships]+    pure (CompactionSelection event.eventId name version position links)+  let names = Set.toList (Set.fromList (concatMap (\s -> s.originStream : map (\l -> l.stream) (V.toList s.acknowledgedLinks)) selections))+  heads <- forM names $ \name -> do+    Just stream <- storeRead =<< runStoreIO store (getStream name)+    pure (StreamHeadWitness name (stream ^. #version))+  operation <- either (fail . show) pure (mkCompactionOperation "test-compaction")+  pure (CompactionManifestInput identity operation RefuseDeadLetters RefuseCausationDependents heads selections Nothing)++manifest :: CompactionManifestInput -> CompactionManifest+manifest = either (error . show) id . mkCompactionManifest++ledgerQuery :: Int32 -> CompactionLedgerQuery+ledgerQuery = CompactionLedgerQuery . either (error . show) id . mkCompactionLedgerLimit++raw :: KirokuStore -> Text -> IO ()+raw store sql = storeRead =<< Pool.use (store ^. #pool) (Session.script sql)++storeRead :: (Show e) => Either e a -> IO a+storeRead = either (fail . show) pure++readyReport :: KirokuStore -> CompactionManifest -> IO CompactionReport+readyReport store m = do+  Right (CompactionPreviewReady report) <- storeRead =<< runStoreIO store (previewCompaction m)+  pure report
+ test/Test/CompactionManifest.hs view
@@ -0,0 +1,235 @@+{-# LANGUAGE OverloadedRecordDot #-}++module Test.CompactionManifest (spec) where++import Control.Lens ((&), (.~))+import Control.Monad (forM_)+import Data.Aeson qualified as Aeson+import Data.Aeson.KeyMap qualified as KM+import Data.ByteString qualified as BS+import Data.Either (isLeft)+import Data.Generics.Labels ()+import Data.Maybe (fromJust)+import Data.Text (Text)+import Data.Text qualified as T+import Data.Time (UTCTime (..), fromGregorian)+import Data.UUID qualified as UUID+import Data.Vector qualified as V+import Hedgehog hiding (property)+import Hedgehog.Gen qualified as Gen+import Hedgehog.Range qualified as Range+import Kiroku.Store.Compaction.Types+import Kiroku.Store.HistoryRetention.Types (HistoryRetentionConflict (..))+import Kiroku.Store.Types (EventId (..), GlobalPosition (..), StoreIdentity (..), StreamName (..), StreamVersion (..))+import Test.Hspec+import Test.Hspec.Hedgehog (hedgehog, modifyMaxSuccess)++spec :: Spec+spec = describe "compaction manifest" $ do+  describe "validation" $ do+    it "rejects empty and oversized selections" $ do+      mkCompactionManifest (base & #selections .~ []) `shouldBe` Left CompactionManifestEmpty+      mkCompactionManifest (base & #selections .~ replicate (maxCompactionSelections + 1) selection)+        `shouldBe` Left (CompactionManifestTooLarge (maxCompactionSelections + 1))+    it "rejects duplicate event IDs before duplicate positions" $+      mkCompactionManifest (base & #selections .~ [selection, selection]) `shouldBe` Left (CompactionManifestDuplicateEvent selection.eventId)+    it "rejects duplicate global positions" $+      mkCompactionManifest (base & #selections .~ [selection, selection & #eventId .~ eid 2 & #originVersion .~ StreamVersion 4])+        `shouldBe` Left (CompactionManifestDuplicatePosition (GlobalPosition 42))+    it "rejects duplicate originating stream slots" $+      mkCompactionManifest (base & #selections .~ [selection, selection & #eventId .~ eid 2 & #globalPosition .~ GlobalPosition 43])+        `shouldBe` Left (CompactionManifestDuplicateOriginVersion origin (StreamVersion 3))+    it "rejects reserved origin, link and head names" $ do+      mkCompactionManifest (changeSelection (#originStream .~ reserved)) `shouldBe` Left (CompactionManifestReservedStream reserved)+      mkCompactionManifest (changeSelection (#acknowledgedLinks .~ V.singleton (LinkWitness reserved (StreamVersion 1)))) `shouldBe` Left (CompactionManifestReservedStream reserved)+      mkCompactionManifest (base & #streamHeads .~ [StreamHeadWitness reserved (StreamVersion 1)]) `shouldBe` Left (CompactionManifestReservedStream reserved)+    it "measures names and operation labels in UTF-8 bytes" $ do+      let longName = StreamName (T.replicate 257 "ψ")+      mkCompactionManifest (changeSelection (#originStream .~ longName)) `shouldBe` Left (CompactionManifestInvalidStreamName longName 514)+      mkCompactionOperation "" `shouldBe` Left CompactionManifestOperationEmpty+      mkCompactionOperation (T.replicate 513 "x") `shouldBe` Left (CompactionManifestOperationTooLong 513)+    it "rejects nonpositive origin versions, link versions and global positions" $ do+      mkCompactionManifest (changeSelection (#originVersion .~ StreamVersion 0)) `shouldBe` Left (CompactionManifestNonPositiveVersion selection.eventId)+      mkCompactionManifest (changeSelection (#globalPosition .~ GlobalPosition 0)) `shouldBe` Left (CompactionManifestNonPositivePosition selection.eventId)+      mkCompactionManifest (changeSelection (#acknowledgedLinks .~ V.singleton (LinkWitness target (StreamVersion 0)))) `shouldBe` Left (CompactionManifestNonPositiveVersion selection.eventId)+    it "rejects duplicate links and links into the origin" $ do+      let link = LinkWitness target (StreamVersion 1)+      mkCompactionManifest (changeSelection (#acknowledgedLinks .~ V.fromList [link, link])) `shouldBe` Left (CompactionManifestDuplicateLink selection.eventId link)+      mkCompactionManifest (changeSelection (#acknowledgedLinks .~ V.singleton (LinkWitness origin (StreamVersion 1)))) `shouldBe` Left (CompactionManifestLinkIntoOrigin selection.eventId)+    it "requires exactly one head for every touched stream" $ do+      mkCompactionManifest (base & #streamHeads .~ []) `shouldBe` Left (CompactionManifestMissingStreamHead origin)+      mkCompactionManifest (base & #streamHeads .~ [headWitness, headWitness]) `shouldBe` Left (CompactionManifestDuplicateStreamHead origin)+      mkCompactionManifest (base & #streamHeads .~ [headWitness, StreamHeadWitness target (StreamVersion 1)]) `shouldBe` Left (CompactionManifestUnusedStreamHead target)+    it "refuses an expected digest mismatch" $ do+      let computed = compactionManifestDigest (manifest base)+      mkCompactionManifest (base & #expectedDigest .~ Just zeroDigest)+        `shouldBe` Left (CompactionManifestDigestMismatch zeroDigest computed)+    it "bounds ledger queries and validates digest representations" $ do+      forM_ [0, 1001] $ \n -> mkCompactionLedgerLimit n `shouldBe` Left (CompactionLedgerLimitOutOfRange n)+      mkCompactionDigest (BS.replicate 31 0) `shouldBe` Nothing+      parseCompactionDigestHex (T.replicate 63 "0") `shouldBe` Nothing+      parseCompactionDigestHex (T.replicate 64 "z") `shouldBe` Nothing+      parseCompactionDigestHex (T.replicate 64 "F") `shouldBe` Nothing+      parseCompactionDigestHex (compactionDigestHex zeroDigest) `shouldBe` Just zeroDigest++  describe "canonical encoding" $ do+    it "pins the independent 134-byte worked example" $ do+      let m = manifest base+      BS.length (canonicalCompactionManifestBytes m) `shouldBe` 134+      canonicalCompactionManifestBytes m+        `shouldBe` BS.pack+          ( [107, 105, 114, 111, 107, 117, 45, 99, 111, 109, 112, 97, 99, 116, 105, 111, 110, 45, 109, 97, 110, 105, 102, 101, 115, 116, 47, 49, 10]+              <> [0, 0, 0, 0, 0, 0, 112, 0, 128, 0, 0, 0, 0, 0, 0, 1]+              <> [0, 0, 0, 7, 99, 111, 109, 112, 97, 99, 116, 0, 0, 0, 0, 0, 1]+              <> [0, 0, 0, 8, 111, 114, 100, 101, 114, 115, 45, 49, 0, 0, 0, 0, 0, 0, 0, 7, 0, 0, 0, 1]+              <> [0, 0, 0, 0, 0, 0, 112, 0, 128, 0, 0, 0, 0, 0, 0, 170]+              <> [0, 0, 0, 8, 111, 114, 100, 101, 114, 115, 45, 49, 0, 0, 0, 0, 0, 0, 0, 3, 0, 0, 0, 0, 0, 0, 0, 42, 0, 0, 0, 0]+          )+      compactionDigestHex (compactionManifestDigest m) `shouldBe` "965e364734a98abdc509ceb0b4f082ab7a2dbd66713d56ce77d2071e55589d08"+    it "requires format and digest on JSON input" $ do+      let Aeson.Object object = Aeson.toJSON (manifest base)+      (Aeson.fromJSON (Aeson.Object (KM.delete "digest" object)) :: Aeson.Result CompactionManifest) `shouldSatisfy` isError+      (Aeson.fromJSON (Aeson.Object (KM.insert "format" (Aeson.String "wrong") object)) :: Aeson.Result CompactionManifest) `shouldSatisfy` isError+    it "rejects a corrupted report digest" $+      (Aeson.eitherDecode (Aeson.encode (reportFor base & #reportDigest .~ zeroDigest)) :: Either String CompactionReport) `shouldSatisfy` isLeft+    it "round-trips every refusal constructor" $ do+      let now = UTCTime (fromGregorian 2026 10 27) 0+          link = LinkWitness target (StreamVersion 1)+          identity = base.storeIdentity+          refusals =+            [ CompactionStoreIdentityMismatch identity identity,+              CompactionHistoryRetentionActive (HistoryRetentionConflict 2 now),+              CompactionStreamMissing origin,+              CompactionStreamSoftDeleted origin,+              CompactionStreamHeadDrift origin (StreamVersion 1) (StreamVersion 2),+              CompactionSelectedEventMissing selection.eventId,+              CompactionWitnessMismatch selection.eventId (WitnessMismatch origin (StreamVersion 1) Nothing),+              CompactionUnexpectedLink selection.eventId link,+              CompactionAcknowledgedLinkMissing selection.eventId link,+              CompactionDeadLettersPresent selection.eventId 2,+              CompactionCausationDependentsPresent selection.eventId 3,+              CompactionLedgerConflict (CompactionId UUID.nil) 4+            ]+      forM_ refusals $ \r -> Aeson.eitherDecode (Aeson.encode r) `shouldBe` Right r++  modifyMaxSuccess (const 200) $ describe "properties" $ do+    it "is deterministic and insensitive to enumeration order" $ hedgehog $ do+      input <- forAll genInput+      shuffledSelections <- forAll (Gen.shuffle input.selections)+      shuffledHeads <- forAll (Gen.shuffle input.streamHeads)+      shuffledLinks <- traverse (\s -> do ls <- forAll (Gen.shuffle (V.toList s.acknowledgedLinks)); pure (s & #acknowledgedLinks .~ V.fromList ls)) shuffledSelections+      let first = manifest input+          second = manifest (input & #selections .~ shuffledLinks & #streamHeads .~ shuffledHeads)+      compactionManifestDigest first === compactionManifestDigest (manifest input)+      canonicalCompactionManifestBytes first === canonicalCompactionManifestBytes second+    it "changes its digest for every witnessed input field" $ hedgehog $ do+      input <- forAll genInput+      let m = manifest input+          s : ss = input.selections+          h : hs = input.streamHeads+          swapped = StreamName "changed-origin"+          variants =+            [ input & #operation .~ operation "changed",+              input & #deadLetterPolicy .~ RemoveDeadLetters,+              input & #causationPolicy .~ AllowDanglingCausation,+              input & #storeIdentity .~ StoreIdentity UUID.nil,+              input & #streamHeads .~ ((h & #headVersion .~ StreamVersion 9000000) : hs),+              input & #selections .~ ((s & #originVersion .~ StreamVersion 9000000) : ss),+              input & #selections .~ ((s & #globalPosition .~ GlobalPosition 9000000) : ss),+              input & #selections .~ ((s & #eventId .~ eid 9000000) : ss),+              input+                & #selections .~ ((s & #originStream .~ swapped) : ss)+                & #streamHeads .~ (StreamHeadWitness swapped (StreamVersion 1) : filter (\headValue -> headValue.stream /= s.originStream) input.streamHeads),+              input+                & #selections .~ ((s & #acknowledgedLinks .~ V.filter (\l -> l.stream /= target) s.acknowledgedLinks) : ss)+                & #streamHeads .~ filter (\headValue -> headValue.stream /= target) input.streamHeads+            ]+      forM_ variants $ \variant -> assert (compactionManifestDigest (manifest variant) /= compactionManifestDigest m)+    it "round-trips manifests, reports and ledger records through JSON" $ hedgehog $ do+      input <- forAll genInput+      let m = manifest input+          r = reportFor input+          record = CompactionRecord (CompactionId UUID.nil) r (UTCTime (fromGregorian 2026 10 27) 0) "operator"+      Aeson.eitherDecode (Aeson.encode m) === Right m+      Aeson.eitherDecode (Aeson.encode r) === Right r+      Aeson.eitherDecode (Aeson.encode record) === Right record+    it "detects digest corruption on decoding" $ hedgehog $ do+      input <- forAll genInput+      let Aeson.Object object = Aeson.toJSON (manifest input)+          damaged = Aeson.Object (KM.insert "digest" (Aeson.toJSON zeroDigest) object)+      assert (isError (Aeson.fromJSON damaged :: Aeson.Result CompactionManifest))++origin, target, reserved :: StreamName+origin = StreamName "orders-1"+target = StreamName "audit-2026"+reserved = StreamName "$all"++eid :: (Integral a) => a -> EventId+eid n = EventId (UUID.fromWords64 0 (fromIntegral n))++operation :: Text -> CompactionOperation+operation = either (error . show) id . mkCompactionOperation++zeroDigest :: CompactionDigest+zeroDigest = fromJust (mkCompactionDigest (BS.replicate 32 0))++selection :: CompactionSelection+selection = CompactionSelection (EventId (fromJust (UUID.fromText "00000000-0000-7000-8000-0000000000aa"))) origin (StreamVersion 3) (GlobalPosition 42) V.empty++headWitness :: StreamHeadWitness+headWitness = StreamHeadWitness origin (StreamVersion 7)++base :: CompactionManifestInput+base = CompactionManifestInput (StoreIdentity (fromJust (UUID.fromText "00000000-0000-7000-8000-000000000001"))) (operation "compact") RefuseDeadLetters RefuseCausationDependents [headWitness] [selection] Nothing++manifest :: CompactionManifestInput -> CompactionManifest+manifest = either (error . show) id . mkCompactionManifest++changeSelection :: (CompactionSelection -> CompactionSelection) -> CompactionManifestInput+changeSelection f = base & #selections .~ [f selection]++isError :: Aeson.Result a -> Bool+isError (Aeson.Error _) = True+isError _ = False++reportFor :: CompactionManifestInput -> CompactionReport+reportFor input =+  sealCompactionReport+    ( CompactionReport+        (compactionManifestDigest m)+        (manifestStoreIdentity m)+        (manifestOperation m)+        (manifestDeadLetterPolicy m)+        (manifestCausationPolicy m)+        count+        count+        count+        links+        0+        0+        (V.head sels).globalPosition+        (V.last sels).globalPosition+        (manifestStreamHeads m)+        zeroDigest+    )+  where+    m = manifest input+    sels = manifestSelections m+    count = fromIntegral (V.length sels)+    links = fromIntegral (sum (map (V.length . (\s -> s.acknowledgedLinks)) (V.toList sels)))++genInput :: Gen CompactionManifestInput+genInput = do+  size <- Gen.int (Range.linear 1 15)+  seed <- Gen.word64 (Range.linear 100 100000)+  let names = [StreamName ("ψ-" <> T.pack (show n)) | n <- [1 .. size]]+      sels =+        [ CompactionSelection+            (eid (seed + fromIntegral n))+            name+            (StreamVersion 1)+            (GlobalPosition (fromIntegral n))+            (if n == 1 then V.fromList [LinkWitness target (StreamVersion 1), LinkWitness (StreamName "audit-2027") (StreamVersion 2)] else V.empty)+        | (n, name) <- zip [1 :: Int ..] names+        ]+  pure (base & #selections .~ sels & #streamHeads .~ (StreamHeadWitness target (StreamVersion 1) : StreamHeadWitness (StreamName "audit-2027") (StreamVersion 2) : map (\name -> StreamHeadWitness name (StreamVersion 1)) names))
+ test/Test/CompactionPreview.hs view
@@ -0,0 +1,214 @@+{-# LANGUAGE OverloadedRecordDot #-}++module Test.CompactionPreview (spec) where++import Control.Concurrent (myThreadId)+import Control.Lens ((&), (.~), (^.))+import Control.Monad (when)+import Data.Aeson qualified as Aeson+import Data.ByteString.Lazy qualified as LBS+import Data.Generics.Labels ()+import Data.IORef (modifyIORef', newIORef, readIORef, writeIORef)+import Data.List.NonEmpty (NonEmpty (..))+import Data.List.NonEmpty qualified as NE+import Data.Text qualified as T+import Data.Text.Encoding qualified as TE+import Data.UUID qualified as UUID+import Data.Vector qualified as V+import Kiroku.Store hiding (id, target)+import Test.CompactionHelpers+import Test.Helpers+import Test.Hspec++spec :: Spec+spec = describe "compaction preview" $ do+  it "returns a deterministic sealed report and leaves every event unchanged" $ withTestStore $ \store -> do+    events <- seed store origin 5+    input <- inputFor store [events !! 1, events !! 2]+    before <- storeRead =<< runStoreIO store (readAllForward (GlobalPosition 0) 100)+    first <- readyReport store (manifest input)+    second <- readyReport store (manifest input)+    second `shouldBe` first+    (first.selectedEvents, first.homeMemberships, first.globalMemberships, first.linkMemberships) `shouldBe` (2, 2, 2, 0)+    (first.lowestGlobalPosition, first.highestGlobalPosition) `shouldBe` (GlobalPosition 2, GlobalPosition 3)+    first.reportDigest `shouldBe` compactionReportDigest first+    runStoreIO store (readAllForward (GlobalPosition 0) 100) `shouldReturn` Right before+    runStoreIO store (runTransaction (previewCompactionTx (manifest input))) `shouldReturn` Right (Right (CompactionPreviewReady first))++  it "previews the stream head and logically truncated events" $ withTestStore $ \store -> do+    events <- seed store origin 3+    input <- inputFor store [head events, last events]+    _ <- storeRead =<< runStoreIO store (setStreamTruncateBefore origin (StreamVersion 3))+    report <- readyReport store (manifest input)+    report.selectedEvents `shouldBe` 2++  it "checks acknowledged links per fact, including links of links" $ withTestStore $ \store -> do+    [event] <- seed store origin 1+    _ <- storeRead =<< runStoreIO store (linkToStream target [event.eventId])+    _ <- storeRead =<< runStoreIO store (linkToStream mirror [event.eventId])+    input <- inputFor store [event]+    report <- readyReport store (manifest input)+    report.linkMemberships `shouldBe` 2+    let [selection] = input.selections+        unacknowledged = manifest (input & #selections .~ [selection & #acknowledgedLinks .~ V.singleton (LinkWitness target (StreamVersion 1))] & #streamHeads .~ filter (\h -> h.stream /= mirror) input.streamHeads)+    assertRefusals store unacknowledged [CompactionUnexpectedLink event.eventId (LinkWitness mirror (StreamVersion 1))]+    let badLink = LinkWitness target (StreamVersion 2)+        bad = manifest (input & #selections .~ [selection & #acknowledgedLinks .~ V.fromList [badLink, LinkWitness mirror (StreamVersion 1)]])+    assertRefusals store bad [CompactionUnexpectedLink event.eventId (LinkWitness target (StreamVersion 1)), CompactionAcknowledgedLinkMissing event.eventId badLink]++  it "reports missing streams without fabricating event or link refusals" $ withTestStore $ \store -> do+    [event] <- seed store origin 1+    input <- inputFor store [event]+    let [selection] = input.selections+        missing = StreamName "missing"+        changed =+          input+            & #selections .~ [selection & #originStream .~ missing]+            & #streamHeads .~ [StreamHeadWitness missing (StreamVersion 1)]+    assertRefusals store (manifest changed) [CompactionStreamMissing missing]+    let linked =+          input+            & #selections .~ [selection & #acknowledgedLinks .~ V.singleton (LinkWitness missing (StreamVersion 1))]+            & #streamHeads .~ (StreamHeadWitness missing (StreamVersion 1) : input.streamHeads)+    assertRefusals store (manifest linked) [CompactionStreamMissing missing]++  it "reports soft deletion and head drift" $ withTestStore $ \store -> do+    [event] <- seed store origin 1+    input <- inputFor store [event]+    let drifted = manifest (input & #streamHeads .~ [StreamHeadWitness origin (StreamVersion 2)])+    assertRefusals store drifted [CompactionStreamHeadDrift origin (StreamVersion 2) (StreamVersion 1)]+    _ <- storeRead =<< runStoreIO store (softDeleteStream origin)+    assertRefusals store (manifest input) [CompactionStreamSoftDeleted origin]++  it "reports wrong origin versions and global positions, and unknown selected IDs" $ withTestStore $ \store -> do+    [event] <- seed store origin 1+    input <- inputFor store [event]+    let [selection] = input.selections+        actual = WitnessMismatch origin (StreamVersion 1) (Just (GlobalPosition 1))+    assertRefusals store (manifest (input & #selections .~ [selection & #originVersion .~ StreamVersion 2])) [CompactionWitnessMismatch event.eventId actual]+    assertRefusals store (manifest (input & #selections .~ [selection & #globalPosition .~ GlobalPosition 2])) [CompactionWitnessMismatch event.eventId actual]+    assertRefusals store (manifest (input & #selections .~ [selection & #eventId .~ EventId UUID.nil])) [CompactionSelectedEventMissing (EventId UUID.nil)]++  it "detects missing home/global rows and inconsistent original-version columns" $ withTestStore $ \store -> do+    [event] <- seed store origin 1+    input <- inputFor store [event]+    raw store "BEGIN; ALTER TABLE kiroku.stream_events DISABLE TRIGGER no_update_stream_events; UPDATE kiroku.stream_events SET original_stream_version=2 WHERE stream_id=0; ALTER TABLE kiroku.stream_events ENABLE TRIGGER no_update_stream_events; COMMIT"+    assertRefusals store (manifest input) [CompactionWitnessMismatch event.eventId (WitnessMismatch origin (StreamVersion 2) (Just (GlobalPosition 1)))]+    raw store "BEGIN; SET LOCAL kiroku.enable_hard_deletes='on'; DELETE FROM kiroku.stream_events WHERE stream_id=0; COMMIT"+    assertRefusals store (manifest input) [CompactionWitnessMismatch event.eventId (WitnessMismatch origin (StreamVersion 1) Nothing)]+    -- Restore just the global row through SQL, then remove the home row.+    let EventId uid = event.eventId+        StreamId sid = event.originalStreamId+    raw store ("BEGIN; SET LOCAL kiroku.enable_hard_deletes='on'; INSERT INTO kiroku.stream_events(event_id,stream_id,stream_version,original_stream_id,original_stream_version,category) VALUES ('" <> UUID.toText uid <> "',0,1," <> T.pack (show sid) <> ",1,'orders'); DELETE FROM kiroku.stream_events WHERE stream_id <> 0; COMMIT")+    assertRefusals store (manifest input) [CompactionWitnessMismatch event.eventId (WitnessMismatch origin (StreamVersion 1) (Just (GlobalPosition 1)))]++  it "refuses or counts dead letters according to the sealed policy" $ withTestStore $ \store -> do+    [event] <- seed store origin 1+    input <- inputFor store [event]+    insertDeadLetterForEvent store "projection" event+    assertRefusals store (manifest input) [CompactionDeadLettersPresent event.eventId 1]+    report <- readyReport store (manifest (input & #deadLetterPolicy .~ RemoveDeadLetters))+    report.deadLettersRemoved `shouldBe` 1++  it "excludes the entire selected set from causation dependents" $ withTestStore $ \store -> do+    [event] <- seed store origin 1+    let EventId uid = event.eventId+    _ <- storeRead =<< runStoreIO store (appendToStream target NoStream [makeEvent "Dependent" (Aeson.object []) & #causationId .~ Just uid])+    input <- inputFor store [event]+    assertRefusals store (manifest input) [CompactionCausationDependentsPresent event.eventId 1]+    report <- readyReport store (manifest (input & #causationPolicy .~ AllowDanglingCausation))+    report.causationDependents `shouldBe` 1+    events <- storeRead =<< runStoreIO store (readAllForward (GlobalPosition 0) 100)+    together <- inputFor store (V.toList events)+    both <- readyReport store (manifest together)+    both.causationDependents `shouldBe` 0++  it "short-circuits a wrong store identity before all other findings" $ withTestStore $ \store -> do+    [event] <- seed store origin 1+    input <- inputFor store [event]+    let wrong = StoreIdentity UUID.nil+        changed = input & #storeIdentity .~ wrong & #streamHeads .~ [StreamHeadWitness origin (StreamVersion 99)]+    assertRefusals store (manifest changed) [CompactionStoreIdentityMismatch wrong input.storeIdentity]++  it "reports active leases and succeeds after release" $ withTestStore $ \store -> do+    [event] <- seed store origin 1+    input <- inputFor store [event]+    let Right owner = mkHistoryRetentionLeaseOwner "preview"+        Right reason = mkHistoryRetentionLeaseReason "test"+        Right duration = mkHistoryRetentionLeaseDuration 60+    lease <- storeRead =<< runStoreIO store (acquireHistoryRetentionLease (HistoryRetentionLeaseRequest owner reason duration))+    Right (Left (CompactionHistoryRetentionActive conflict :| [])) <- runStoreIO store (previewCompaction (manifest input))+    conflict.activeLeaseCount `shouldBe` 1+    _ <- storeRead =<< runStoreIO store (releaseHistoryRetentionLease (HistoryRetentionLeaseHandle (lease ^. #leaseId) owner))+    _ <- readyReport store (manifest input)+    pure ()++  it "returns every independent finding in deterministic order" $ withTestStore $ \store -> do+    [event] <- seed store origin 1+    input <- inputFor store [event]+    let [selection] = input.selections+        missing = StreamName "zz-missing"+        extra = CompactionSelection (EventId UUID.nil) missing (StreamVersion 1) (GlobalPosition 2) V.empty+        changed =+          input+            & #selections .~ [selection & #originVersion .~ StreamVersion 2, extra]+            & #streamHeads .~ [StreamHeadWitness origin (StreamVersion 2), StreamHeadWitness missing (StreamVersion 1)]+    assertRefusals+      store+      (manifest changed)+      [ CompactionStreamHeadDrift origin (StreamVersion 2) (StreamVersion 1),+        CompactionStreamMissing missing,+        CompactionWitnessMismatch event.eventId (WitnessMismatch origin (StreamVersion 1) (Just (GlobalPosition 1)))+      ]++  it "recognizes ledger conflicts and returns stored records without resealing them" $ withTestStore $ \store -> do+    events <- seed store origin 2+    input <- inputFor store events+    let m = manifest input+        StoreIdentity identity = input.storeIdentity+        digest = compactionDigestHex (compactionManifestDigest m)+        headsJson = TE.decodeUtf8 (LBS.toStrict (Aeson.encode input.streamHeads))+    raw store ("INSERT INTO kiroku.event_compactions(manifest_digest,report_digest,store_id,operation,dead_letter_policy,causation_policy,selected_events,home_memberships,global_memberships,link_memberships,dead_letters_removed,causation_dependents,lowest_global_position,highest_global_position,affected_streams) VALUES (decode('" <> digest <> "','hex'),decode(repeat('00',32),'hex'),'" <> UUID.toText identity <> "','stored-operation','refuse','refuse',2,2,2,0,0,0,1,2,'" <> headsJson <> "'::jsonb)")+    Right (Left (CompactionLedgerConflict cid 2 :| [])) <- runStoreIO store (previewCompaction m)+    raw store "BEGIN; SET LOCAL kiroku.enable_hard_deletes='on'; DELETE FROM kiroku.stream_events; DELETE FROM kiroku.events; COMMIT"+    Right (Right (CompactionPreviewAlreadyApplied record)) <- runStoreIO store (previewCompaction m)+    record.compactionId `shouldBe` cid+    record.report.operation `shouldBe` either (error . show) id (mkCompactionOperation "stored-operation")+    compactionDigestHex record.report.reportDigest `shouldBe` T.replicate 64 "0"++  it "uses one caller checkout and emits one typed outcome event" $ do+    ownerThread <- myThreadId+    checkouts <- newIORef (0 :: Int)+    observed <- newIORef []+    let tweak settings =+          settings+            { eventHandler = Just (\event -> modifyIORef' observed (event :)),+              observationHandler = Just $ \case+                ConnectionObservation _ InUseConnectionStatus -> do+                  current <- myThreadId+                  when (current == ownerThread) (modifyIORef' checkouts (+ 1))+                _ -> pure ()+            }+    withTestStoreSettings tweak $ \store -> do+      events <- seed store origin 2+      input <- inputFor store events+      writeIORef observed []+      before <- readIORef checkouts+      _ <- readyReport store (manifest input)+      readIORef checkouts `shouldReturn` (before + 1)+      recorded <- readIORef observed+      [(digest, count)] <- pure [(d, n) | KirokuEventCompactionPreviewed d n <- recorded]+      (digest, count) `shouldBe` (compactionManifestDigest (manifest input), 2)+      let drifted = manifest (input & #streamHeads .~ [StreamHeadWitness origin (StreamVersion 3)])+      _ <- storeRead =<< runStoreIO store (previewCompaction drifted)+      refused <- readIORef observed+      [total] <- pure [n | KirokuEventCompactionRefused _ _ n <- refused]+      total `shouldBe` 1++origin, target, mirror :: StreamName+origin = StreamName "orders-1"+target = StreamName "audit-1"+mirror = StreamName "audit-2"++assertRefusals :: KirokuStore -> CompactionManifest -> [CompactionRefusal] -> Expectation+assertRefusals store m expected = runStoreIO store (previewCompaction m) `shouldReturn` Right (Left (NE.fromList expected))
+ test/Test/CompactionPreviewMock.hs view
@@ -0,0 +1,35 @@+module Test.CompactionPreviewMock (spec) where++import Control.Monad.IO.Class (liftIO)+import Data.IORef (IORef, modifyIORef', newIORef, readIORef)+import Data.List.NonEmpty (NonEmpty)+import Data.UUID qualified as UUID+import Data.Vector qualified as V+import Effectful (Eff, IOE, runEff, (:>))+import Effectful.Dispatch.Dynamic (interpret_)+import Kiroku.Store hiding (id)+import Test.Hspec++spec :: Spec+spec = describe "compaction preview mock" $+  it "dispatches the sealed manifest exactly once and returns the configured result" $ do+    calls <- newIORef (0 :: Int)+    let Right op = mkCompactionOperation "mock"+        identity = StoreIdentity UUID.nil+        heads = [StreamHeadWitness (StreamName "mock-1") (StreamVersion 1)]+        selection = CompactionSelection (EventId UUID.nil) (StreamName "mock-1") (StreamVersion 1) (GlobalPosition 1) V.empty+        Right m = mkCompactionManifest (CompactionManifestInput identity op RefuseDeadLetters RefuseCausationDependents heads [selection] Nothing)+        digest = compactionManifestDigest m+        report = sealCompactionReport (CompactionReport digest identity op RefuseDeadLetters RefuseCausationDependents 1 1 1 0 0 0 (GlobalPosition 1) (GlobalPosition 1) (V.fromList heads) digest)+        result = Right (CompactionPreviewReady report)+    actual <- runEff $ runPreviewMock calls m result (previewCompaction m)+    actual `shouldBe` result+    readIORef calls `shouldReturn` 1++runPreviewMock :: (IOE :> es) => IORef Int -> CompactionManifest -> Either (NonEmpty CompactionRefusal) CompactionPreview -> Eff (Store : es) a -> Eff es a+runPreviewMock calls expected result = interpret_ $ \case+  PreviewCompaction m -> do+    liftIO $ m `shouldBe` expected+    liftIO $ modifyIORef' calls (+ 1)+    pure result+  _ -> error "unexpected Store operation in preview mock"
+ test/Test/EventReferences.hs view
@@ -0,0 +1,116 @@+module Test.EventReferences (spec) where++import Control.Concurrent (myThreadId)+import Control.Lens ((&), (.~), (^.))+import Control.Monad (forM_, when)+import Data.Aeson qualified as Aeson+import Data.Generics.Labels ()+import Data.IORef (modifyIORef', newIORef, readIORef)+import Data.Map.Strict qualified as Map+import Data.UUID qualified as UUID+import Data.Vector qualified as V+import Kiroku.Store+import Kiroku.Store.Effect (maxEventReferenceBatch)+import Test.Helpers+import Test.Hspec++spec :: Spec+spec = describe "event references" $ do+  it "reports home and global memberships with actual global positions" $ withTestStore $ \store -> do+    event <- firstEvent store+    let eid = event ^. #eventId+    Right refs <- runStoreIO store (lookupEventReferences [eid])+    refs+      `shouldBe` Map.singleton+        eid+        ( EventReferenceInventory+            (V.fromList [GlobalMembership (GlobalPosition 1), HomeMembership (StreamName "order-1") (event ^. #originalStreamId) (StreamVersion 1)])+            0+            0+        )++  it "reports all link targets, including a link of a link and soft-deleted targets" $ withTestStore $ \store -> do+    event <- firstEvent store+    let eid = event ^. #eventId+    forM_ [(StreamName "hub"), (StreamName "mirror")] $ \name -> do+      Right _ <- runStoreIO store (linkToStream name [eid])+      pure ()+    Right _ <- runStoreIO store (softDeleteStream (StreamName "hub"))+    Right refs <- runStoreIO store (lookupEventReferences [eid])+    let ms = (refs Map.! eid) ^. #memberships+    V.length ms `shouldBe` 4+    [name | LinkMembership name _ _ <- V.toList ms] `shouldBe` [(StreamName "hub"), (StreamName "mirror")]+    [name | HomeMembership name _ _ <- V.toList ms] `shouldBe` [(StreamName "order-1")]++  it "counts dead letters across subscriptions" $ withTestStore $ \store -> do+    event <- firstEvent store+    insertDeadLetterForEvent store "proj-a" event+    insertDeadLetterForEvent store "proj-b" event+    Right refs <- runStoreIO store (lookupEventReferences [event ^. #eventId])+    (refs Map.! (event ^. #eventId)) ^. #deadLetterCount `shouldBe` 2++  it "excludes requested causation dependents and deduplicates input ids" $ withTestStore $ \store -> do+    event <- firstEvent store+    let eid@(EventId uid) = event ^. #eventId+        dependent = makeEvent "Dependent" (Aeson.object []) & #causationId .~ Just uid+    Right _ <- runStoreIO store (appendToStream (StreamName "caused-1") NoStream [dependent, dependent])+    Right caused <- runStoreIO store (readStreamForward (StreamName "caused-1") (StreamVersion 0) 10)+    Right single <- runStoreIO store (lookupEventReferences [eid])+    (single Map.! eid) ^. #causationDependentCount `shouldBe` 2+    let ids = [eid, caused V.! 0 ^. #eventId]+    Right refs <- runStoreIO store (lookupEventReferences (ids <> ids))+    (refs Map.! eid) ^. #causationDependentCount `shouldBe` 1+    V.length ((refs Map.! eid) ^. #memberships) `shouldBe` 2+    runStoreIO store (runTransaction (lookupEventReferencesTx ids)) `shouldReturn` Right refs++  it "omits unknown ids and agrees with the transaction combinator" $ withTestStore $ \store -> do+    event <- firstEvent store+    let ids = [event ^. #eventId, EventId UUID.nil]+    Right refs <- runStoreIO store (lookupEventReferences ids)+    Map.keys refs `shouldBe` [event ^. #eventId]+    runStoreIO store (runTransaction (lookupEventReferencesTx ids)) `shouldReturn` Right refs++  it "uses no checkout for empty input and one checkout across batches" $ do+    owner <- myThreadId+    checkouts <- newIORef (0 :: Int)+    let tweak settings =+          settings+            { observationHandler = Just $ \case+                ConnectionObservation _ InUseConnectionStatus -> do+                  current <- myThreadId+                  when (current == owner) (modifyIORef' checkouts (+ 1))+                _ -> pure ()+            }+    withTestStoreSettings tweak $ \store -> do+      beforeEmpty <- readIORef checkouts+      runStoreIO store (lookupEventReferences []) `shouldReturn` Right Map.empty+      readIORef checkouts `shouldReturn` beforeEmpty+      -- Last requested event depends on the first: the exclusion must span batches.+      let firstId = UUID.fromWords64 0 1+          events =+            [ makeEvent "Batch" (Aeson.object [])+                & #eventId .~ Just (EventId (UUID.fromWords64 0 (fromIntegral n)))+                & #causationId .~ (if n == maxEventReferenceBatch + 1 then Just firstId else Nothing)+            | n <- [1 .. maxEventReferenceBatch + 1]+            ]+      forM_ (chunks 2000 events) $ \batch -> do+        Right _ <- runStoreIO store (appendToStream (StreamName "batch-1") AnyVersion batch)+        pure ()+      let ids = [EventId (UUID.fromWords64 0 (fromIntegral n)) | n <- [1 .. maxEventReferenceBatch + 1]]+      beforeRead <- readIORef checkouts+      Right refs <- runStoreIO store (lookupEventReferences ids)+      readIORef checkouts `shouldReturn` (beforeRead + 1)+      Map.size refs `shouldBe` maxEventReferenceBatch + 1+      forM_ (Map.elems refs) $ \inventory -> do+        V.length (inventory ^. #memberships) `shouldBe` 2+        inventory ^. #causationDependentCount `shouldBe` 0++firstEvent :: KirokuStore -> IO RecordedEvent+firstEvent store = do+  Right _ <- runStoreIO store (appendToStream (StreamName "order-1") NoStream [makeEvent "Placed" (Aeson.object [])])+  Right events <- runStoreIO store (readAllForward (GlobalPosition 0) 10)+  pure (V.head events)++chunks :: Int -> [a] -> [[a]]+chunks _ [] = []+chunks size values = let (batch, rest) = splitAt size values in batch : chunks size rest
+ test/Test/EventReferencesMock.hs view
@@ -0,0 +1,35 @@+module Test.EventReferencesMock (spec) where++import Control.Monad.IO.Class (liftIO)+import Data.IORef (IORef, modifyIORef', newIORef, readIORef)+import Data.Map.Strict (Map)+import Data.Map.Strict qualified as Map+import Data.UUID qualified as UUID+import Effectful (Eff, IOE, runEff, (:>))+import Effectful.Dispatch.Dynamic (interpret_)+import Kiroku.Store.Effect (Store (..))+import Kiroku.Store.Read (lookupEventReferences)+import Kiroku.Store.Types+import Test.Hspec++spec :: Spec+spec = describe "event references mock" $+  it "returns the configured identity through one Store effect call" $ do+    calls <- newIORef (0 :: Int)+    let expected = Map.singleton (EventId UUID.nil) (EventReferenceInventory mempty 2 3)+    actual <- runEff $ runIdentityMock calls expected (lookupEventReferences [EventId UUID.nil])+    actual `shouldBe` expected+    readIORef calls `shouldReturn` 1++runIdentityMock ::+  (IOE :> es) =>+  IORef Int ->+  Map EventId EventReferenceInventory ->+  Eff (Store : es) a ->+  Eff es a+runIdentityMock calls expected = interpret_ $ \case+  LookupEventReferences ids -> do+    liftIO $ ids `shouldBe` [EventId UUID.nil]+    liftIO $ modifyIORef' calls (+ 1)+    pure expected+  _ -> error "unexpected Store operation in identity mock"
test/Test/NotifyGuard.hs view
@@ -11,11 +11,13 @@ import Data.Generics.Labels () import Data.Text qualified as T import Data.Text.Encoding qualified as T+import Data.Vector qualified as V import Hasql.Connection qualified as Connection import Hasql.Connection.Settings qualified as Conn import Hasql.Notifications qualified as Notifications import Kiroku.Store import Kiroku.Test.Postgres (withMigratedTestDatabase)+import Test.CompactionHelpers qualified as CompactionHelpers import Test.Helpers (makeEvent) import Test.Hspec @@ -45,6 +47,10 @@               runStoreIO store $                 appendToStream secondStream NoStream [makeEvent "NotifyGuardSecond" (Aeson.object [])]             waitForPayload payloads (expectedPayload secondStream second)++            Right allEvents <- runStoreIO store (readAllForward (GlobalPosition 0) 10)+            input <- CompactionHelpers.inputFor store [V.head allEvents]+            Right (Right (CompactionAppliedNow _)) <- runStoreIO store (applyCompaction (CompactionHelpers.manifest input))              actual <- atomically (readTVar payloads)             actual
test/Test/PerformanceStructure.hs view
@@ -5,7 +5,7 @@ import Control.Concurrent (myThreadId) import Control.Exception (fromException) import Control.Lens ((^.))-import Control.Monad (forM_, unless)+import Control.Monad (forM_, unless, when) import Data.Aeson (Value (..)) import Data.Aeson qualified as Aeson import Data.Aeson.KeyMap qualified as KeyMap@@ -22,11 +22,13 @@ import Hasql.Statement (Statement, unpreparable) import Hasql.Statement qualified as Statement import Kiroku.Store+import Kiroku.Store.Compaction.SQL qualified as CompactionSQL import Kiroku.Store.SQL qualified as SQL import Kiroku.Store.Subscription.Stream qualified as Buffer import Kiroku.Store.Subscription.Worker (withLoadCheckpointHookForTest) import Kiroku.Test.Fixtures.CategoryScaling (categoryScalingFixtureSql, categoryScalingHead) import Kiroku.Test.Fixtures.StreamHead (streamHeadFixtureSql)+import Test.CompactionHelpers qualified as CompactionHelpers import Test.DeadLetterQueryPlans qualified as DeadLetterQueryPlans import Test.Helpers (makeEvent, validConsumerGroup, waitWithTimeout, withTestStore, withTestStoreSettings) import Test.Hspec@@ -167,6 +169,59 @@         checkoutsAfter <- readIORef checkouts         checkoutsAfter - checkoutsBefore `shouldBe` 0 +    it "applies a manifest with one caller pool checkout" $ do+      owner <- myThreadId+      checkouts <- newIORef (0 :: Int)+      let tweak settings =+            settings+              { observationHandler = Just $ \case+                  ConnectionObservation _ InUseConnectionStatus -> do+                    current <- myThreadId+                    when (current == owner) (modifyIORef' checkouts (+ 1))+                  _ -> pure ()+              }+      withTestStoreSettings tweak $ \store -> do+        events <- CompactionHelpers.seed store (StreamName "apply-checkout") 1+        input <- CompactionHelpers.inputFor store events+        beforeApply <- readIORef checkouts+        Right (Right (CompactionAppliedNow _)) <- runStoreIO store (applyCompaction (CompactionHelpers.manifest input))+        readIORef checkouts `shouldReturn` (beforeApply + 1)++    it "guards compaction UPDATE, DELETE and TRUNCATE while leaving INSERT unguarded" $+      withTestStore $ \store -> do+        Right shape <- Pool.use (store ^. #pool) (Session.statement () compactionTriggerShapeStmt)+        shape `shouldBe` (6, 2, 0)++    it "previews a manifest with one caller pool checkout" $ do+      owner <- myThreadId+      checkouts <- newIORef (0 :: Int)+      let tweak settings =+            settings+              { observationHandler = Just $ \case+                  ConnectionObservation _ InUseConnectionStatus -> do+                    current <- myThreadId+                    when (current == owner) (modifyIORef' checkouts (+ 1))+                  _ -> pure ()+              }+      withTestStoreSettings tweak $ \store -> do+        events <- CompactionHelpers.seed store (StreamName "preview-checkout") 1+        input <- CompactionHelpers.inputFor store events+        beforePreview <- readIORef checkouts+        _ <- CompactionHelpers.readyReport store (CompactionHelpers.manifest input)+        readIORef checkouts `shouldReturn` (beforePreview + 1)++    it "keeps preview SQL free of row locks and writes" $ do+      let statements =+            [ Statement.toSql CompactionSQL.resolveStreamsStmt,+              Statement.toSql CompactionSQL.ledgerRecordByDigestStmt,+              Statement.toSql CompactionSQL.survivingSelectedEventsStmt,+              Statement.toSql SQL.eventMembershipsStmt,+              Statement.toSql SQL.deadLetterCountsStmt,+              Statement.toSql SQL.causationDependentCountsStmt+            ]+      forM_ statements $ \sql -> forM_ ["FOR UPDATE", "FOR SHARE", "DELETE FROM", "INSERT INTO", "UPDATE ", "SET LOCAL"] $ \forbidden ->+        T.toUpper sql `shouldNotSatisfy` T.isInfixOf forbidden+     it "keeps every ordinary statement free of retention coordination" $ do       let ordinarySql =             [ Statement.toSql SQL.appendExpectedVersion,@@ -180,12 +235,47 @@               Statement.toSql SQL.setStreamTruncateBeforeStmt             ]       forM_ ordinarySql $ \sql ->-        T.toLower sql `shouldNotSatisfy` T.isInfixOf "history_retention"+        forM_ ["history_retention", "store_identity", "event_compactions"] $ \name ->+          T.toLower sql `shouldNotSatisfy` T.isInfixOf name  queryPlanSpec :: Spec queryPlanSpec =   describe "production query plans" $     aroundAll withQueryPlanStore $ do+      it "serves the ledger digest lookup through its unique index" $ \store -> do+        plan <- explainProductionStatement store CompactionSQL.ledgerRecordByDigestStmt [("$1", "decode(repeat('00',32),'hex')")]+        expectIndex "event_compactions_manifest_digest_key" plan++      it "serves newest ledger records through ix_event_compactions_applied_at without Sort" $ \store -> do+        plan <- explainProductionStatement store CompactionSQL.compactionLedgerStmt [("$1", "10")]+        expectIndex "ix_event_compactions_applied_at" plan+        expectNoNodeType "Sort" plan++      it "serves a 500-pair junction delete through stream_events_pkey" $ \store -> do+        let streamKeys = "ARRAY[" <> T.intercalate "," (replicate 500 "1") <> "]::bigint[]"+        plan <- explainProductionStatement store CompactionSQL.deleteJunctionRowsStmt [("$1::uuid[]", inventoryUuidArray), ("$2::bigint[]", streamKeys)]+        expectIndex "stream_events_pkey" plan++      it "resolves a 500-name compaction inventory through ix_streams_stream_name" $ \_ ->+        withTestStore $ \store -> do+          seeded <- Pool.use (store ^. #pool) (Session.script "INSERT INTO streams(stream_name,stream_version) SELECT 'compaction-plan-' || n,0 FROM generate_series(1,50000) n; ANALYZE streams")+          seeded `shouldBe` Right ()+          let names = "ARRAY[" <> T.intercalate "," ["'compaction-plan-" <> T.pack (show n) <> "'" | n <- [1 .. 500 :: Int]] <> "]::text[]"+          plan <- explainProductionStatement store CompactionSQL.resolveStreamsStmt [("$1::text[]", names)]+          expectIndex "ix_streams_stream_name" plan++      it "event membership inventory uses stream_events_pkey" $ \store -> do+        plan <- explainProductionStatement store SQL.eventMembershipsStmt [("$1::uuid[]", inventoryUuidArray)]+        expectIndex "stream_events_pkey" plan++      it "dead-letter counts use ix_dead_letters_event_id" $ \store -> do+        plan <- explainProductionStatement store SQL.deadLetterCountsStmt [("$1::uuid[]", inventoryUuidArray)]+        expectIndex "ix_dead_letters_event_id" plan++      it "causation dependent counts use ix_events_causation_id" $ \store -> do+        plan <- explainProductionStatement store SQL.causationDependentCountsStmt [("$1::uuid[]", inventoryUuidArray)]+        expectIndex "ix_events_causation_id" plan+       it "visible global head lookup uses ux_stream_events_stream_version without Sort" $ \store -> do         plan <-           explainProductionStatement@@ -448,6 +538,15 @@          CASE WHEN n <= 9900 THEN clock_timestamp() ELSE NULL END   FROM generate_series(1, 10000) AS n;   ANALYZE history_retention_leases;+  INSERT INTO event_compactions(manifest_digest,report_digest,store_id,operation,+    dead_letter_policy,causation_policy,selected_events,home_memberships,global_memberships,+    link_memberships,dead_letters_removed,causation_dependents,lowest_global_position,+    highest_global_position,affected_streams,applied_at)+  SELECT decode(lpad(to_hex(n),64,'0'),'hex'),decode(repeat('01',32),'hex'),uuidv7(),+    'structural-fixture','refuse','refuse',1,1,1,0,0,0,1,1,'[]'::jsonb,+    clock_timestamp() - n * interval '1 second'+  FROM generate_series(1,500) n;+  ANALYZE event_compactions;   """  explainProductionStatement ::@@ -646,3 +745,16 @@ planNumber key fields = case KeyMap.lookup key fields of   Just (Number n) -> realToFrac n   _ -> 0++-- Hundreds of distinct IDs exercise the actual maintenance-array plan shape.+inventoryUuidArray :: Text+inventoryUuidArray = "ARRAY[" <> T.intercalate "," ["'00000000-0000-0000-0000-" <> T.justifyRight 12 '0' (T.pack (show n)) <> "'" | n <- [1 .. 500 :: Int]] <> "]::uuid[]"++compactionTriggerShapeStmt :: Statement () (Int64, Int64, Int64)+compactionTriggerShapeStmt =+  unpreparable+    "SELECT count(*),count(*) FILTER (WHERE (tgtype & 16) <> 0),count(*) FILTER (WHERE (tgtype & 4) <> 0) FROM pg_trigger WHERE NOT tgisinternal AND tgrelid IN ('kiroku.store_identity'::regclass,'kiroku.event_compactions'::regclass)"+    E.noParams+    (D.singleRow ((,,) <$> col D.int8 <*> col D.int8 <*> col D.int8))+  where+    col = D.column . D.nonNullable
+ test/Test/StoreIdentity.hs view
@@ -0,0 +1,43 @@+module Test.StoreIdentity (spec) where++import Control.Lens ((^.))+import Data.Either (isLeft)+import Data.Generics.Labels ()+import Data.Text (Text)+import Hasql.Pool qualified as Pool+import Hasql.Session qualified as Session+import Kiroku.Store+import Test.Helpers (truncateRejected, withTestStore)+import Test.Hspec++spec :: Spec+spec = describe "store identity" $ do+  it "is stable across calls, pooled connections and transaction reads" $+    withTestStore $ \store -> do+      Right first <- runStoreIO store storeIdentity+      runStoreIO store storeIdentity `shouldReturn` Right first+      runStoreIO store (runTransaction storeIdentityTx) `shouldReturn` Right first++  it "is shared by separate databases cloned from the migrated template" $+    withTestStore $ \first -> withTestStore $ \second -> do+      Right identity <- runStoreIO first storeIdentity+      runStoreIO second storeIdentity `shouldReturn` Right identity++  it "rejects identity and ledger mutation without the hard-delete guard" $+    withTestStore $ \store -> do+      rejects store "UPDATE store_identity SET store_id = gen_random_uuid()"+      rejects store "DELETE FROM store_identity"+      truncateRejected store "store_identity" `shouldReturn` True+      seeded <-+        Pool.use (store ^. #pool) $+          Session.script+            "INSERT INTO event_compactions (manifest_digest, report_digest, store_id, operation, dead_letter_policy, causation_policy, selected_events, home_memberships, global_memberships, link_memberships, dead_letters_removed, causation_dependents, lowest_global_position, highest_global_position, affected_streams) VALUES (decode(repeat('ab',32),'hex'), decode(repeat('cd',32),'hex'), gen_random_uuid(), 'trigger-test', 'refuse', 'refuse', 1, 1, 1, 0, 0, 0, 1, 1, '[]'::jsonb)"+      seeded `shouldBe` Right ()+      rejects store "UPDATE event_compactions SET operation = 'changed'"+      rejects store "DELETE FROM event_compactions"+      truncateRejected store "event_compactions" `shouldReturn` True++rejects :: KirokuStore -> Text -> Expectation+rejects store sql = do+  result <- Pool.use (store ^. #pool) (Session.script sql)+  result `shouldSatisfy` isLeft
+ test/Test/StoreIdentityMock.hs view
@@ -0,0 +1,32 @@+module Test.StoreIdentityMock (spec) where++import Control.Monad.IO.Class (liftIO)+import Data.IORef (IORef, modifyIORef', newIORef, readIORef)+import Data.UUID qualified as UUID+import Effectful (Eff, IOE, runEff, (:>))+import Effectful.Dispatch.Dynamic (interpret_)+import Kiroku.Store.Effect (Store (..))+import Kiroku.Store.Read (storeIdentity)+import Kiroku.Store.Types (StoreIdentity (..))+import Test.Hspec++spec :: Spec+spec = describe "store identity mock" $+  it "returns the configured identity through one Store effect call" $ do+    calls <- newIORef (0 :: Int)+    let expected = StoreIdentity UUID.nil+    actual <- runEff $ runIdentityMock calls expected storeIdentity+    actual `shouldBe` expected+    readIORef calls `shouldReturn` 1++runIdentityMock ::+  (IOE :> es) =>+  IORef Int ->+  StoreIdentity ->+  Eff (Store : es) a ->+  Eff es a+runIdentityMock calls expected = interpret_ $ \case+  GetStoreIdentity -> do+    liftIO $ modifyIORef' calls (+ 1)+    pure expected+  _ -> error "unexpected Store operation in identity mock"
test/Test/SubscriptionDeadLetters.hs view
@@ -35,7 +35,7 @@       events <- seedEvents store 7       mapM_ (insertDeadLetterForEvent store "page") (take 5 events)       mapM_ (insertDeadLetterForEvent store "other") (drop 5 events)-      let query = (defaultSubscriptionDeadLetterQuery (SubscriptionName "page")) {limit = validLimit 2}+      let query = defaultSubscriptionDeadLetterQuery (SubscriptionName "page") & #limit .~ validLimit 2       first <- page store query       second <- page store query {after = first ^. #nextCursor}       third <- page store query {after = second ^. #nextCursor}@@ -50,7 +50,7 @@     it "merges historical members and resolves same-position ties with an exclusive cursor" $ \store -> do       [event] <- seedEvents store 1       forM_ [0, 1, 9] $ \member -> insertDeadLetterWith store "ties" member (object []) "tie" 2 event-      let query = (defaultSubscriptionDeadLetterQuery (SubscriptionName "ties")) {limit = validLimit 1}+      let query = defaultSubscriptionDeadLetterQuery (SubscriptionName "ties") & #limit .~ validLimit 1       first <- page store query       second <- page store query {after = first ^. #nextCursor}       third <- page store query {after = second ^. #nextCursor}@@ -79,7 +79,7 @@     it "keeps a cursor valid after its event and row are hard-deleted" $ \store -> do       events <- seedEvents store 3       mapM_ (insertDeadLetterForEvent store "delete") events-      let query = (defaultSubscriptionDeadLetterQuery (SubscriptionName "delete")) {limit = validLimit 1}+      let query = defaultSubscriptionDeadLetterQuery (SubscriptionName "delete") & #limit .~ validLimit 1       first <- page store query       Right _ <- runStoreIO store $ hardDeleteStream (StreamName "dead-3")       older <- page store query {after = first ^. #nextCursor, limit = validLimit 10}