kioku-core 0.9.0.2 → 0.10.0.0
raw patch · 13 files changed
+934/−55 lines, 13 filesdep +hasql-dynamic-statementsdep +relay-paginationdep +relay-pagination-hasqldep ~kioku-apidep ~kioku-coredep ~kioku-migrationsPVP ok
version bump matches the API change (PVP)
Dependencies added: hasql-dynamic-statements, relay-pagination, relay-pagination-hasql
Dependency ranges changed: kioku-api, kioku-core, kioku-migrations
API changes (from Hackage documentation)
- Kioku.Session: instance GHC.Classes.Eq Kioku.Session.SessionStatus
- Kioku.Session: instance GHC.Internal.Show.Show Kioku.Session.SessionStatus
+ Kioku.Memory: BoundedReadFailed :: !ReadModelError -> BoundedReadError
+ Kioku.Memory: BoundedReadInvalidPage :: !BoundedPageError -> BoundedReadError
+ Kioku.Memory: BoundedReadNotPermitted :: !MemoryPermission -> BoundedReadError
+ Kioku.Memory: FilteredMemories :: !MemoryFilter -> MemorySelection
+ Kioku.Memory: MemoryCounts :: !Int64 -> !Int64 -> !Int64 -> !Int64 -> !Int64 -> MemoryCounts
+ Kioku.Memory: MemoryFilter :: !RecallTarget -> !Maybe MemoryStatus -> !Maybe MemoryType -> !Maybe Text -> MemoryFilter
+ Kioku.Memory: MemoryRow :: !MemorySpaceId -> !Text -> !Text -> !Maybe Text -> !Text -> !Maybe Text -> !Maybe Text -> !Text -> !Text -> !Int -> !Text -> !Set Text -> !Text -> !Maybe Text -> !Maybe Text -> !UTCTime -> !UTCTime -> MemoryRow
+ Kioku.Memory: SupersessionChain :: !Text -> MemorySelection
+ Kioku.Memory: [active] :: MemoryCounts -> !Int64
+ Kioku.Memory: [agentId] :: MemoryRow -> !Text
+ Kioku.Memory: [archived] :: MemoryCounts -> !Int64
+ Kioku.Memory: [confidence] :: MemoryRow -> !Text
+ Kioku.Memory: [content] :: MemoryRow -> !Text
+ Kioku.Memory: [createdAt] :: MemoryRow -> !UTCTime
+ Kioku.Memory: [memoryId] :: MemoryRow -> !Text
+ Kioku.Memory: [memorySpaceId] :: MemoryRow -> !MemorySpaceId
+ Kioku.Memory: [memoryType] :: MemoryRow -> !Text
+ Kioku.Memory: [merged] :: MemoryCounts -> !Int64
+ Kioku.Memory: [namespace] :: MemoryRow -> !Text
+ Kioku.Memory: [priority] :: MemoryRow -> !Int
+ Kioku.Memory: [scopeKind] :: MemoryRow -> !Maybe Text
+ Kioku.Memory: [scopeRef] :: MemoryRow -> !Maybe Text
+ Kioku.Memory: [sessionId] :: MemoryRow -> !Maybe Text
+ Kioku.Memory: [status] :: MemoryRow -> !Text
+ Kioku.Memory: [supersededBy] :: MemoryRow -> !Maybe Text
+ Kioku.Memory: [superseded] :: MemoryCounts -> !Int64
+ Kioku.Memory: [supersedes] :: MemoryRow -> !Maybe Text
+ Kioku.Memory: [tags] :: MemoryRow -> !Set Text
+ Kioku.Memory: [target] :: MemoryFilter -> !RecallTarget
+ Kioku.Memory: [total] :: MemoryCounts -> !Int64
+ Kioku.Memory: [updatedAt] :: MemoryRow -> !UTCTime
+ Kioku.Memory: archiveWithContextChanged :: forall (es :: [Effect]). (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) => MemoryAccessContext -> ArchiveMemoryData -> Eff es (Either MemoryWriteError (MemoryId, Bool))
+ Kioku.Memory: data BoundedReadError
+ Kioku.Memory: data MemoryCounts
+ Kioku.Memory: data MemoryFilter
+ Kioku.Memory: data MemoryRow
+ Kioku.Memory: data MemorySelection
+ Kioku.Memory: getCountsWithContext :: forall (es :: [Effect]). (IOE :> es, Store :> es) => MemoryAccessContext -> MemorySelection -> Eff es (Either BoundedReadError MemoryCounts)
+ Kioku.Memory: getPageWithContext :: forall (es :: [Effect]). (IOE :> es, Store :> es) => MemoryAccessContext -> MemorySelection -> PageRequest -> Eff es (Either BoundedReadError (Connection MemoryRow))
+ Kioku.Memory.ReadModel: FilteredMemories :: !MemoryFilter -> MemorySelection
+ Kioku.Memory.ReadModel: MemoriesCountQuery :: !MemoryAccessContext -> !MemorySelection -> MemoriesCountQuery
+ Kioku.Memory.ReadModel: MemoriesPageQuery :: !MemoryAccessContext -> !MemorySelection -> !PageRequest -> MemoriesPageQuery
+ Kioku.Memory.ReadModel: MemoryCounts :: !Int64 -> !Int64 -> !Int64 -> !Int64 -> !Int64 -> MemoryCounts
+ Kioku.Memory.ReadModel: MemoryFilter :: !RecallTarget -> !Maybe MemoryStatus -> !Maybe MemoryType -> !Maybe Text -> MemoryFilter
+ Kioku.Memory.ReadModel: SupersessionChain :: !Text -> MemorySelection
+ Kioku.Memory.ReadModel: [active] :: MemoryCounts -> !Int64
+ Kioku.Memory.ReadModel: [archived] :: MemoryCounts -> !Int64
+ Kioku.Memory.ReadModel: [context] :: MemoriesCountQuery -> !MemoryAccessContext
+ Kioku.Memory.ReadModel: [merged] :: MemoryCounts -> !Int64
+ Kioku.Memory.ReadModel: [page] :: MemoriesPageQuery -> !PageRequest
+ Kioku.Memory.ReadModel: [selection] :: MemoriesCountQuery -> !MemorySelection
+ Kioku.Memory.ReadModel: [superseded] :: MemoryCounts -> !Int64
+ Kioku.Memory.ReadModel: [target] :: MemoryFilter -> !RecallTarget
+ Kioku.Memory.ReadModel: [total] :: MemoryCounts -> !Int64
+ Kioku.Memory.ReadModel: data MemoriesCountQuery
+ Kioku.Memory.ReadModel: data MemoriesPageQuery
+ Kioku.Memory.ReadModel: data MemoryCounts
+ Kioku.Memory.ReadModel: data MemoryFilter
+ Kioku.Memory.ReadModel: data MemorySelection
+ Kioku.Memory.ReadModel: instance GHC.Classes.Eq Kioku.Memory.ReadModel.MemoryCounts
+ Kioku.Memory.ReadModel: instance GHC.Classes.Eq Kioku.Memory.ReadModel.MemoryFilter
+ Kioku.Memory.ReadModel: instance GHC.Classes.Eq Kioku.Memory.ReadModel.MemorySelection
+ Kioku.Memory.ReadModel: instance GHC.Internal.Generics.Generic Kioku.Memory.ReadModel.MemoryCounts
+ Kioku.Memory.ReadModel: instance GHC.Internal.Show.Show Kioku.Memory.ReadModel.MemoryCounts
+ Kioku.Memory.ReadModel: instance GHC.Internal.Show.Show Kioku.Memory.ReadModel.MemoryFilter
+ Kioku.Memory.ReadModel: instance GHC.Internal.Show.Show Kioku.Memory.ReadModel.MemorySelection
+ Kioku.Memory.ReadModel: memoriesCountReadModel :: ReadModel MemoriesCountQuery MemoryCounts
+ Kioku.Memory.ReadModel: memoriesPageReadModel :: ReadModel MemoriesPageQuery (Either BoundedPageError (Connection MemoryRow))
+ Kioku.Pagination: BoundedReadFailed :: !ReadModelError -> BoundedReadError
+ Kioku.Pagination: BoundedReadInvalidPage :: !BoundedPageError -> BoundedReadError
+ Kioku.Pagination: BoundedReadNotPermitted :: !MemoryPermission -> BoundedReadError
+ Kioku.Pagination: InvalidPageCursor :: !CursorError -> BoundedPageError
+ Kioku.Pagination: InvalidPageSize :: !PageRequestError -> BoundedPageError
+ Kioku.Pagination: PageRequest :: !Int -> !Direction -> !Maybe Cursor -> PageRequest
+ Kioku.Pagination: [cursor] :: PageRequest -> !Maybe Cursor
+ Kioku.Pagination: [direction] :: PageRequest -> !Direction
+ Kioku.Pagination: [pageSize] :: PageRequest -> !Int
+ Kioku.Pagination: boundedPage :: SortSpec row -> Row row -> Snippet -> PageRequest -> Transaction (Either BoundedPageError (Connection row))
+ Kioku.Pagination: contextSpaceSnippet :: MemoryAccessContext -> Snippet
+ Kioku.Pagination: data BoundedPageError
+ Kioku.Pagination: data BoundedReadError
+ Kioku.Pagination: data Connection a
+ Kioku.Pagination: data PageRequest
+ Kioku.Pagination: gatedPage :: Applicative f => MemoryAccessContext -> f (Either ReadModelError (Either BoundedPageError a)) -> f (Either BoundedReadError a)
+ Kioku.Pagination: gatedRead :: Applicative f => MemoryAccessContext -> f (Either ReadModelError a) -> f (Either BoundedReadError a)
+ Kioku.Pagination: instance GHC.Classes.Eq Kioku.Pagination.BoundedPageError
+ Kioku.Pagination: instance GHC.Classes.Eq Kioku.Pagination.BoundedReadError
+ Kioku.Pagination: instance GHC.Internal.Show.Show Kioku.Pagination.BoundedPageError
+ Kioku.Pagination: instance GHC.Internal.Show.Show Kioku.Pagination.BoundedReadError
+ Kioku.Pagination: maxBoundedPageSize :: Int
+ Kioku.Pagination: recallTargetSnippet :: RecallTarget -> Snippet
+ Kioku.Session: BoundedReadFailed :: !ReadModelError -> BoundedReadError
+ Kioku.Session: BoundedReadInvalidPage :: !BoundedPageError -> BoundedReadError
+ Kioku.Session: BoundedReadNotPermitted :: !MemoryPermission -> BoundedReadError
+ Kioku.Session: ContinuationChain :: !Text -> SessionSelection
+ Kioku.Session: DelegationChildren :: !Text -> SessionSelection
+ Kioku.Session: FilteredSessions :: !SessionFilter -> SessionSelection
+ Kioku.Session: SessionCounts :: !Int64 -> !Int64 -> !Int64 -> !Int64 -> !Int64 -> !Int64 -> SessionCounts
+ Kioku.Session: SessionFilter :: !RecallTarget -> !Maybe Text -> !Maybe SessionStatus -> !Maybe Text -> !Maybe UTCTime -> !Maybe UTCTime -> SessionFilter
+ Kioku.Session: SessionRelations :: !Bool -> !Int64 -> !Int64 -> !Int64 -> SessionRelations
+ Kioku.Session: StatusAwaiting :: SessionStatus
+ Kioku.Session: StatusCompleted :: SessionStatus
+ Kioku.Session: StatusFailed :: SessionStatus
+ Kioku.Session: StatusInteractive :: SessionStatus
+ Kioku.Session: StatusRunning :: SessionStatus
+ Kioku.Session: TurnRow :: !MemorySpaceId -> !Text -> !Text -> !Int -> !Text -> !Text -> !Maybe Text -> !Maybe Int -> !Maybe Int -> !UTCTime -> TurnRow
+ Kioku.Session: [awaiting] :: SessionCounts -> !Int64
+ Kioku.Session: [chainMembers] :: SessionRelations -> !Int64
+ Kioku.Session: [completed] :: SessionCounts -> !Int64
+ Kioku.Session: [content] :: TurnRow -> !Text
+ Kioku.Session: [delegationChildren] :: SessionRelations -> !Int64
+ Kioku.Session: [failed] :: SessionCounts -> !Int64
+ Kioku.Session: [found] :: SessionRelations -> !Bool
+ Kioku.Session: [interactive] :: SessionCounts -> !Int64
+ Kioku.Session: [outputTokens] :: TurnRow -> !Maybe Int
+ Kioku.Session: [promptTokens] :: TurnRow -> !Maybe Int
+ Kioku.Session: [recordedAt] :: TurnRow -> !UTCTime
+ Kioku.Session: [role] :: TurnRow -> !Text
+ Kioku.Session: [running] :: SessionCounts -> !Int64
+ Kioku.Session: [startedSince] :: SessionFilter -> !Maybe UTCTime
+ Kioku.Session: [startedUntil] :: SessionFilter -> !Maybe UTCTime
+ Kioku.Session: [target] :: SessionFilter -> !RecallTarget
+ Kioku.Session: [toolSummary] :: TurnRow -> !Maybe Text
+ Kioku.Session: [total] :: SessionCounts -> !Int64
+ Kioku.Session: [turnId] :: TurnRow -> !Text
+ Kioku.Session: [turnIndex] :: TurnRow -> !Int
+ Kioku.Session: [turns] :: SessionRelations -> !Int64
+ Kioku.Session: data BoundedReadError
+ Kioku.Session: data SessionCounts
+ Kioku.Session: data SessionFilter
+ Kioku.Session: data SessionRelations
+ Kioku.Session: data SessionSelection
+ Kioku.Session: data SessionStatus
+ Kioku.Session: data TurnRow
+ Kioku.Session: getCountsWithContext :: forall (es :: [Effect]). (IOE :> es, Store :> es) => MemoryAccessContext -> SessionSelection -> Eff es (Either BoundedReadError SessionCounts)
+ Kioku.Session: getPageWithContext :: forall (es :: [Effect]). (IOE :> es, Store :> es) => MemoryAccessContext -> SessionSelection -> PageRequest -> Eff es (Either BoundedReadError (Connection SessionRow))
+ Kioku.Session: getRelationsWithContext :: forall (es :: [Effect]). (IOE :> es, Store :> es) => MemoryAccessContext -> SessionId -> Eff es (Either BoundedReadError SessionRelations)
+ Kioku.Session: getTurnsPageWithContext :: forall (es :: [Effect]). (IOE :> es, Store :> es) => MemoryAccessContext -> SessionId -> PageRequest -> Eff es (Either BoundedReadError (Connection TurnRow))
+ Kioku.Session.ReadModel: ContinuationChain :: !Text -> SessionSelection
+ Kioku.Session.ReadModel: DelegationChildren :: !Text -> SessionSelection
+ Kioku.Session.ReadModel: FilteredSessions :: !SessionFilter -> SessionSelection
+ Kioku.Session.ReadModel: SessionCounts :: !Int64 -> !Int64 -> !Int64 -> !Int64 -> !Int64 -> !Int64 -> SessionCounts
+ Kioku.Session.ReadModel: SessionFilter :: !RecallTarget -> !Maybe Text -> !Maybe SessionStatus -> !Maybe Text -> !Maybe UTCTime -> !Maybe UTCTime -> SessionFilter
+ Kioku.Session.ReadModel: SessionRelations :: !Bool -> !Int64 -> !Int64 -> !Int64 -> SessionRelations
+ Kioku.Session.ReadModel: SessionRelationsQuery :: !MemoryAccessContext -> !Text -> SessionRelationsQuery
+ Kioku.Session.ReadModel: SessionsCountQuery :: !MemoryAccessContext -> !SessionSelection -> SessionsCountQuery
+ Kioku.Session.ReadModel: SessionsPageQuery :: !MemoryAccessContext -> !SessionSelection -> !PageRequest -> SessionsPageQuery
+ Kioku.Session.ReadModel: StatusAwaiting :: SessionStatus
+ Kioku.Session.ReadModel: StatusCompleted :: SessionStatus
+ Kioku.Session.ReadModel: StatusFailed :: SessionStatus
+ Kioku.Session.ReadModel: StatusInteractive :: SessionStatus
+ Kioku.Session.ReadModel: StatusRunning :: SessionStatus
+ Kioku.Session.ReadModel: TurnsPageQuery :: !MemoryAccessContext -> !Text -> !PageRequest -> TurnsPageQuery
+ Kioku.Session.ReadModel: [awaiting] :: SessionCounts -> !Int64
+ Kioku.Session.ReadModel: [chainMembers] :: SessionRelations -> !Int64
+ Kioku.Session.ReadModel: [completed] :: SessionCounts -> !Int64
+ Kioku.Session.ReadModel: [context] :: SessionRelationsQuery -> !MemoryAccessContext
+ Kioku.Session.ReadModel: [delegationChildren] :: SessionRelations -> !Int64
+ Kioku.Session.ReadModel: [failed] :: SessionCounts -> !Int64
+ Kioku.Session.ReadModel: [found] :: SessionRelations -> !Bool
+ Kioku.Session.ReadModel: [interactive] :: SessionCounts -> !Int64
+ Kioku.Session.ReadModel: [page] :: TurnsPageQuery -> !PageRequest
+ Kioku.Session.ReadModel: [running] :: SessionCounts -> !Int64
+ Kioku.Session.ReadModel: [selection] :: SessionsCountQuery -> !SessionSelection
+ Kioku.Session.ReadModel: [startedSince] :: SessionFilter -> !Maybe UTCTime
+ Kioku.Session.ReadModel: [startedUntil] :: SessionFilter -> !Maybe UTCTime
+ Kioku.Session.ReadModel: [target] :: SessionFilter -> !RecallTarget
+ Kioku.Session.ReadModel: [total] :: SessionCounts -> !Int64
+ Kioku.Session.ReadModel: [turns] :: SessionRelations -> !Int64
+ Kioku.Session.ReadModel: data SessionCounts
+ Kioku.Session.ReadModel: data SessionFilter
+ Kioku.Session.ReadModel: data SessionRelations
+ Kioku.Session.ReadModel: data SessionRelationsQuery
+ Kioku.Session.ReadModel: data SessionSelection
+ Kioku.Session.ReadModel: data SessionStatus
+ Kioku.Session.ReadModel: data SessionsCountQuery
+ Kioku.Session.ReadModel: data SessionsPageQuery
+ Kioku.Session.ReadModel: data TurnsPageQuery
+ Kioku.Session.ReadModel: instance GHC.Classes.Eq Kioku.Session.ReadModel.SessionCounts
+ Kioku.Session.ReadModel: instance GHC.Classes.Eq Kioku.Session.ReadModel.SessionFilter
+ Kioku.Session.ReadModel: instance GHC.Classes.Eq Kioku.Session.ReadModel.SessionRelations
+ Kioku.Session.ReadModel: instance GHC.Classes.Eq Kioku.Session.ReadModel.SessionSelection
+ Kioku.Session.ReadModel: instance GHC.Classes.Eq Kioku.Session.ReadModel.SessionStatus
+ Kioku.Session.ReadModel: instance GHC.Internal.Enum.Bounded Kioku.Session.ReadModel.SessionStatus
+ Kioku.Session.ReadModel: instance GHC.Internal.Enum.Enum Kioku.Session.ReadModel.SessionStatus
+ Kioku.Session.ReadModel: instance GHC.Internal.Generics.Generic Kioku.Session.ReadModel.SessionCounts
+ Kioku.Session.ReadModel: instance GHC.Internal.Generics.Generic Kioku.Session.ReadModel.SessionRelations
+ Kioku.Session.ReadModel: instance GHC.Internal.Show.Show Kioku.Session.ReadModel.SessionCounts
+ Kioku.Session.ReadModel: instance GHC.Internal.Show.Show Kioku.Session.ReadModel.SessionFilter
+ Kioku.Session.ReadModel: instance GHC.Internal.Show.Show Kioku.Session.ReadModel.SessionRelations
+ Kioku.Session.ReadModel: instance GHC.Internal.Show.Show Kioku.Session.ReadModel.SessionSelection
+ Kioku.Session.ReadModel: instance GHC.Internal.Show.Show Kioku.Session.ReadModel.SessionStatus
+ Kioku.Session.ReadModel: parseSessionStatus :: Text -> Maybe SessionStatus
+ Kioku.Session.ReadModel: sessionRelationsReadModel :: ReadModel SessionRelationsQuery SessionRelations
+ Kioku.Session.ReadModel: sessionStatusToText :: SessionStatus -> Text
+ Kioku.Session.ReadModel: sessionsCountReadModel :: ReadModel SessionsCountQuery SessionCounts
+ Kioku.Session.ReadModel: sessionsPageReadModel :: ReadModel SessionsPageQuery (Either BoundedPageError (Connection SessionRow))
+ Kioku.Session.ReadModel: turnsPageReadModel :: ReadModel TurnsPageQuery (Either BoundedPageError (Connection TurnRow))
- Kioku.Session: [focus] :: SessionRow -> !Text
+ Kioku.Session: [focus] :: SessionFilter -> !Maybe Text
- Kioku.Session: [memorySpaceId] :: SessionRow -> !MemorySpaceId
+ Kioku.Session: [memorySpaceId] :: TurnRow -> !MemorySpaceId
- Kioku.Session: [sessionId] :: SessionRow -> !Text
+ Kioku.Session: [sessionId] :: TurnRow -> !Text
- Kioku.Session: [status] :: SessionRow -> !Text
+ Kioku.Session: [status] :: SessionFilter -> !Maybe SessionStatus
- Kioku.Session: [subjectRef] :: SessionRow -> !Maybe Text
+ Kioku.Session: [subjectRef] :: SessionFilter -> !Maybe Text
Files
- CHANGELOG.md +23/−0
- kioku-core.cabal +11/−5
- src/Kioku/Memory.hs +57/−8
- src/Kioku/Memory/ReadModel.hs +103/−4
- src/Kioku/Pagination.hs +81/−0
- src/Kioku/ProjectionCatalog.hs +19/−4
- src/Kioku/Session.hs +40/−20
- src/Kioku/Session/ReadModel.hs +228/−3
- test/Kioku/BoundedReadSpec.hs +328/−0
- test/Kioku/ProjectionCatalogSpec.hs +35/−4
- test/Kioku/ReadModelReconcileSpec.hs +2/−2
- test/Kioku/SpaceIsolationSpec.hs +4/−4
- test/Main.hs +3/−1
CHANGELOG.md view
@@ -2,6 +2,29 @@ ## [Unreleased] +## 0.10.0.0 — 2026-10-19++### Breaking Changes++- The bounded reads (`Kioku.Session.getPageWithContext`, `getCountsWithContext`,+ `getTurnsPageWithContext`, `getRelationsWithContext`; `Kioku.Memory.getPageWithContext`,+ `getCountsWithContext`) now refuse a context that does not grant `MemoryRead` before any+ SQL runs, and return one flat `Kioku.Pagination.BoundedReadError` instead of+ `Either ReadModelError (Either BoundedPageError a)`.+- `SessionFilter.status` is a typed `SessionStatus` (now exported, with+ `parseSessionStatus` and `sessionStatusToText`) instead of `Text`.++### Changed++- The session and memory collections share one namespace/scope predicate and one space+ parameter from `Kioku.Pagination`; the page limit is exported as `maxBoundedPageSize`.+- Document that migration 0014 blocks writes to the three tables while its indexes build.+ The catalog fingerprint and read-model identities are unchanged.++## 0.9.1.0 — 2026-10-19++- Add context-first Session/Memory Relay pages, scalar status and relation counts, turns and cycle-safe lineage traversal. Preserve native Memory ordering and expose authoritative archive changed receipts. Register six read-only query models and test explicit public catalog adoption from the exact previous release. Existing facades remain compatible.+ ## 0.9.0.2 — 2026-10-16 ### Changed
kioku-core.cabal view
@@ -1,6 +1,6 @@ cabal-version: 3.0 name: kioku-core-version: 0.9.0.2+version: 0.10.0.0 synopsis: Reusable agent memory runtime description: Core runtime for kioku. M1 establishes the application effect stack; later@@ -76,6 +76,7 @@ Kioku.Memory.Embedding.Worker Kioku.Memory.EventStream Kioku.Memory.ReadModel+ Kioku.Pagination Kioku.Partition Kioku.ProjectionCatalog Kioku.ReadModel@@ -128,16 +129,19 @@ filepath >=1.4 && <1.6, generic-lens >=2.2 && <2.4, hasql >=1.6 && <1.11,+ hasql-dynamic-statements ^>=0.5.1.2, hasql-pool >=1.2 && <1.5, hasql-transaction >=1.0 && <1.3, hs-opentelemetry-api >=1.0 && <1.1, keiki ^>=0.9.1.0, keiro ^>=0.21.0.0, keiro-core ^>=0.21.0.0,- kioku-api ^>=0.9.0.2,+ kioku-api ^>=0.10.0.0, kiroku-store ^>=0.11.0.0, lens >=5.2 && <5.4, mmzk-typeid >=0.7 && <0.8,+ relay-pagination ^>=0.1.1.2,+ relay-pagination-hasql ^>=0.1.1.2, shibuya-core ^>=0.10.0.1, shibuya-kiroku-adapter ^>=0.6.0.1, shikumi ^>=0.4.0.0,@@ -156,6 +160,7 @@ other-modules: Kioku.AIRuntimeSpec Kioku.AwaitingSpec+ Kioku.BoundedReadSpec Kioku.CodecCompatSpec Kioku.DistillSpec Kioku.EmbeddingWorkerSpec@@ -201,11 +206,12 @@ hasql-transaction >=1.0, keiro ^>=0.21.0.0, keiro-core ^>=0.21.0.0,- kioku-api ^>=0.9.0.2,- kioku-core ^>=0.9.0.2,- kioku-migrations:test-support ^>=0.9.0.2,+ kioku-api ^>=0.10.0.0,+ kioku-core ^>=0.10.0.0,+ kioku-migrations:test-support ^>=0.10.0.0, kiroku-store ^>=0.11.0.0, lens >=5.2,+ relay-pagination ^>=0.1.1.2, shibuya-core ^>=0.10.0.1, shikumi ^>=0.4.0.0, shikumi-trace ^>=0.3.0.0,
src/Kioku/Memory.hs view
@@ -28,6 +28,11 @@ -- takes the space rather than the whole context because these functions return -- @Either ReadModelError@, and a permission denial has already been decided — a context exists -- only for permissions 'Kioku.Api.Access.authorizeMemoryAccess' actually checked.+--+-- The bounded reads ('getPageWithContext', 'getCountsWithContext') are the exception: they take+-- the whole context, refuse one that does not grant 'MemoryRead' with+-- 'BoundedReadNotPermitted', and only then read the context's space. A context minted only to+-- record or forget cannot be spent on listing the space. module Kioku.Memory ( MemoryWriteError (..), @@ -35,6 +40,7 @@ recordWithContext, supersedeWithContext, archiveWithContext,+ archiveWithContextChanged, updateTagsWithContext, updateConfidenceWithContext, mergeWithContext,@@ -54,13 +60,20 @@ getRowsBySession, getActiveRowsByType, getSupersessionChain,+ MemoryFilter (..),+ MemorySelection (..),+ MemoryCounts (..),+ MemoryRow (..),+ BoundedReadError (..),+ getPageWithContext,+ getCountsWithContext, ) where import Data.List (find) import Effectful (Eff, IOE, (:>)) import Effectful.Error.Static (Error)-import Keiro.Command (CommandError (..), defaultRunCommandOptions)+import Keiro.Command (CommandError (..), CommandResult (..), defaultRunCommandOptions) import Keiro.Projection (runCommandWithProjections) import Keiro.Projection.Catalog (typedInlineProjections) import Keiro.ReadModel (QueryFreshness (Immediate), ReadModelError, runQueryWithFreshness)@@ -86,16 +99,24 @@ MemoriesByScopeQuery (..), MemoriesBySessionQuery (..), MemoriesByTypeQuery (..),+ MemoriesCountQuery (..),+ MemoriesPageQuery (..), MemoryByIdQuery (..),+ MemoryCounts (..),+ MemoryFilter (..), MemoryRow (..),+ MemorySelection (..), MemorySupersessionChainQuery (..), memoriesByNamespaceRowsReadModel, memoriesByScopeRowsReadModel, memoriesBySessionRowsReadModel, memoriesByTypeRowsReadModel,+ memoriesCountReadModel,+ memoriesPageReadModel, memoryByIdReadModel, memorySupersessionChainReadModel, )+import Kioku.Pagination (BoundedReadError (..), Connection, PageRequest, gatedPage, gatedRead) import Kioku.Prelude import Kioku.ProjectionCatalog (kiokuProjectionCatalog, memoryProjectionSet) import Kiroku.Store.Effect (Store)@@ -249,16 +270,32 @@ (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) => ArchiveMemoryData -> Eff es (Either MemoryWriteError MemoryId)-archiveIn cmdData = do+archiveIn cmdData = fmap (fmap fst) (archiveInChanged cmdData)++-- | The flag records whether this command appended an archive event, including+-- concurrent retries. A matching archive by another command returns False.+archiveWithContextChanged ::+ (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>+ MemoryAccessContext -> ArchiveMemoryData -> Eff es (Either MemoryWriteError (MemoryId, Bool))+archiveWithContextChanged context cmdData =+ underContext context MemoryForget cmdData.memorySpaceId cmdData.actorPrincipal (archiveInChanged cmdData)++archiveInChanged ::+ (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>+ ArchiveMemoryData -> Eff es (Either MemoryWriteError (MemoryId, Bool))+archiveInChanged cmdData = do existing <- lookupMemory cmdData.memorySpaceId cmdData.memoryId case existing of Left err -> pure (Left (MemoryReadFailed err)) Right Nothing -> pure (Left MemoryNotFound) Right (Just row)- | row.status /= "active" -> pure (idempotentOr "archive" archiveMismatch row cmdData.memoryId)- | otherwise ->- runMemoryCommand cmdData.memoryId (ArchiveMemory cmdData)- >>= acceptRejectedIfMatches cmdData.memorySpaceId cmdData.memoryId (isNothing . archiveMismatch)+ | row.status /= "active" -> pure (fmap (,False) (idempotentOr "archive" archiveMismatch row cmdData.memoryId))+ | otherwise -> do+ outcome <- runMemoryCommandChanged cmdData.memoryId (ArchiveMemory cmdData)+ case outcome of+ Left err@(MemoryCommandRejected CommandRejected) ->+ fmap (fmap (,False)) (acceptRejectedIfMatches cmdData.memorySpaceId cmdData.memoryId (isNothing . archiveMismatch) (Left err))+ result -> pure result where archiveMismatch = mismatchOf memoryArchiveFields cmdData @@ -608,7 +645,12 @@ MemoryId -> MemoryCommand -> Eff es (Either MemoryWriteError MemoryId)-runMemoryCommand mid cmd = do+runMemoryCommand mid cmd = fmap (fmap fst) (runMemoryCommandChanged mid cmd)++runMemoryCommandChanged ::+ (IOE :> es, KirokuStoreResource :> es, Store :> es, Error StoreError :> es) =>+ MemoryId -> MemoryCommand -> Eff es (Either MemoryWriteError (MemoryId, Bool))+runMemoryCommandChanged mid cmd = do -- Keiro's catalog cannot declare the shared framework-owned timer table as -- a Kioku target. Derive Kioku's handler from the catalog, keep the timer -- callback explicit, and run both in the append transaction; see@@ -623,4 +665,11 @@ pure $ case result of Left err -> Left (MemoryCommandRejected err)- Right _ -> Right mid+ Right receipt -> Right (mid, receipt.eventsAppended > 0)++-- | Bounded reads refuse a context that does not grant 'MemoryRead' before any SQL runs.+getPageWithContext :: (IOE :> es, Store :> es) => MemoryAccessContext -> MemorySelection -> PageRequest -> Eff es (Either BoundedReadError (Connection MemoryRow))+getPageWithContext context selection page = gatedPage context (runQueryWithFreshness Nothing Immediate memoriesPageReadModel MemoriesPageQuery {context, selection, page})++getCountsWithContext :: (IOE :> es, Store :> es) => MemoryAccessContext -> MemorySelection -> Eff es (Either BoundedReadError MemoryCounts)+getCountsWithContext context selection = gatedRead context (runQueryWithFreshness Nothing Immediate memoriesCountReadModel MemoriesCountQuery {context, selection})
src/Kioku/Memory/ReadModel.hs view
@@ -6,7 +6,14 @@ -- from writing, outside the space it was authorized for, and it makes the partition visible in -- every query a reviewer reads. module Kioku.Memory.ReadModel- ( memoryInlineProjection,+ ( MemoryFilter (..),+ MemorySelection (..),+ MemoriesPageQuery (..),+ MemoriesCountQuery (..),+ MemoryCounts (..),+ memoriesPageReadModel,+ memoriesCountReadModel,+ memoryInlineProjection, MemoryRow (..), MemoryByIdQuery (..), MemoriesByNamespaceQuery (..),@@ -41,11 +48,12 @@ import Data.ByteString.Lazy qualified as BL import Data.Functor.Contravariant ((>$<)) import Data.Generics.Labels ()-import Data.Int (Int32)+import Data.Int (Int32, Int64) import Data.Set (Set) import Data.Set qualified as Set import Data.Text.Encoding qualified as TE import Hasql.Decoders qualified as D+import Hasql.DynamicStatements.Snippet qualified as S import Hasql.Encoders qualified as E import Hasql.Statement (Statement, preparable) import Hasql.Transaction qualified as Tx@@ -56,15 +64,18 @@ ReadModelBlueprint (..), immediateReadModel, )-import Kioku.Api.Access (MemorySpaceId)+import Kioku.Api.Access (MemoryAccessContext, MemorySpaceId)+import Kioku.Api.Recall (RecallTarget) import Kioku.Api.Scope (scopeFromColumns, scopeKindText, scopeNamespaceText, scopeRefText)-import Kioku.Api.Types (MemoryRecord (..), confidenceToText, memoryTypeToText)+import Kioku.Api.Types (MemoryRecord (..), MemoryStatus, MemoryType, confidenceToText, memoryStatusToText, memoryTypeToText) import Kioku.Database.Schema (kiokuSchema, memoriesRelation, memoriesTable) import Kioku.Id (idText) import Kioku.Memory.Domain+import Kioku.Pagination (BoundedPageError, Connection, PageRequest, boundedPage, contextSpaceSnippet, recallTargetSnippet) import Kioku.Partition (memorySpaceColumn, memorySpaceParam) import Kioku.Prelude import Kiroku.Store.Types (RecordedEvent)+import Relay.Pagination.Hasql qualified as R data MemoryRow = MemoryRow { memorySpaceId :: !MemorySpaceId,@@ -728,3 +739,91 @@ <> ((\c -> c.updatedAt) >$< E.param (E.nonNullable E.timestamptz)) ) D.noResult++data MemoryFilter = MemoryFilter+ {target :: !RecallTarget, status :: !(Maybe MemoryStatus), memoryType :: !(Maybe MemoryType), sessionId :: !(Maybe Text)}+ deriving stock (Eq, Show)++data MemorySelection = FilteredMemories !MemoryFilter | SupersessionChain !Text+ deriving stock (Eq, Show)++data MemoriesPageQuery = MemoriesPageQuery {context :: !MemoryAccessContext, selection :: !MemorySelection, page :: !PageRequest}++data MemoriesCountQuery = MemoriesCountQuery {context :: !MemoryAccessContext, selection :: !MemorySelection}++data MemoryCounts = MemoryCounts {total :: !Int64, active :: !Int64, superseded :: !Int64, merged :: !Int64, archived :: !Int64}+ deriving stock (Generic, Eq, Show)++memoriesPageReadModel :: ReadModel MemoriesPageQuery (Either BoundedPageError (Connection MemoryRow))+memoriesPageReadModel =+ immediateReadModel+ ReadModelBlueprint+ { name = "kioku-memories-page",+ schema = kiokuSchema,+ tableName = memoriesRelation,+ version = memoryReadModelVersion,+ shapeHash = memoryReadModelShapeHash,+ cursorAuthority = NoQueryCursor,+ query = \q -> boundedPage (memoryPageOrder q.selection) memoryRowDecoder (memorySelectionBase q.context q.selection) q.page+ }++memoriesCountReadModel :: ReadModel MemoriesCountQuery MemoryCounts+memoriesCountReadModel =+ immediateReadModel+ ReadModelBlueprint+ { name = "kioku-memories-count",+ schema = kiokuSchema,+ tableName = memoriesRelation,+ version = memoryReadModelVersion,+ shapeHash = memoryReadModelShapeHash,+ cursorAuthority = NoQueryCursor,+ query = \q ->+ Tx.statement+ ()+ ( S.toPreparableStatement+ ("SELECT count(*),count(*) FILTER(WHERE status='active'),count(*) FILTER(WHERE status='superseded'),count(*) FILTER(WHERE status='merged'),count(*) FILTER(WHERE status='archived') FROM (" <> memorySelectionBase q.context q.selection <> ") selected")+ (D.singleRow (MemoryCounts <$> int8 <*> int8 <*> int8 <*> int8 <*> int8))+ )+ }+ where+ int8 = D.column (D.nonNullable D.int8)++memoryPageOrder :: MemorySelection -> R.SortSpec MemoryRow+memoryPageOrder selection = R.SortSpec $ case selection of+ SupersessionChain _ -> R.KeyColumn "created_at" R.Asc (.createdAt) R.timestamptzKey :| [identity]+ FilteredMemories filters -> case filters.sessionId of+ Just _ -> recent :| [identity]+ Nothing -> R.KeyColumn "priority" R.Asc (fromIntegral . (.priority)) R.int8Key :| [recent, identity]+ where+ recent = R.KeyColumn "created_at" R.Desc (.createdAt) R.timestamptzKey+ identity = R.KeyColumn "memory_id" R.Asc (.memoryId) R.textKey++memorySelectionBase :: MemoryAccessContext -> MemorySelection -> S.Snippet+memorySelectionBase context = \case+ FilteredMemories filters ->+ "SELECT "+ <> S.sql memoryRowColumns+ <> " FROM "+ <> S.sql memoriesTable+ <> " WHERE memory_space_id="+ <> contextSpaceSnippet context+ <> recallTargetSnippet filters.target+ <> maybe "" (\value -> " AND status=" <> S.param (memoryStatusToText value)) filters.status+ <> maybe "" (\value -> " AND memory_type=" <> S.param (memoryTypeToText value)) filters.memoryType+ <> maybe "" (\value -> " AND session_id=" <> S.param value) filters.sessionId+ SupersessionChain identity ->+ "WITH RECURSIVE chain AS (SELECT "+ <> S.sql memoryRowColumns+ <> " FROM "+ <> S.sql memoriesTable+ <> " WHERE memory_space_id="+ <> contextSpaceSnippet context+ <> " AND memory_id="+ <> S.param identity+ <> " UNION SELECT "+ <> S.sql (qualifiedMemoryRowColumns "m")+ <> " FROM "+ <> S.sql memoriesTable+ <> " m JOIN chain c ON m.memory_space_id=c.memory_space_id AND (m.memory_id=c.supersedes OR m.supersedes=c.memory_id OR m.memory_id=c.superseded_by OR m.superseded_by=c.memory_id)) SELECT "+ <> S.sql memoryRowColumns+ <> " FROM chain"
+ src/Kioku/Pagination.hs view
@@ -0,0 +1,81 @@+-- | SQL-bounded core reads using the shared Relay keyset implementation.+-- Hosts exposing these cursors over an API bind their collection/filter context+-- around the public Relay cursor; this module never forks that codec.+--+-- Unlike the older space-first reads, every bounded read checks that its context grants+-- 'MemoryRead' before any SQL runs. A context minted only to record or forget cannot be spent+-- on listing or counting the space.+module Kioku.Pagination+ ( BoundedPageError (..),+ BoundedReadError (..),+ boundedPage,+ maxBoundedPageSize,+ gatedRead,+ gatedPage,+ contextSpaceSnippet,+ recallTargetSnippet,+ Connection,+ PageRequest (..),+ )+where++import Data.Bifunctor (first)+import Data.Text (Text)+import Hasql.Decoders qualified as D+import Hasql.DynamicStatements.Snippet qualified as S+import Hasql.Transaction qualified as Tx+import Keiro.ReadModel (ReadModelError)+import Kioku.Api.Access (MemoryAccessContext, MemoryPermission (MemoryRead), memoryContextAllows, memoryContextSpace, memorySpaceIdText)+import Kioku.Api.Recall (RecallTarget (..))+import Kioku.Api.Scope (MemoryScope (..), Namespace (..), ScopeKind (..))+import Relay.Pagination (Connection, CursorError, PageRequest (..), PageRequestError (..))+import Relay.Pagination.Hasql qualified as R++data BoundedPageError = InvalidPageSize !PageRequestError | InvalidPageCursor !CursorError+ deriving stock (Eq, Show)++-- | Every way a bounded read can fail, flattened so callers match one type.+data BoundedReadError+ = -- | the context authorized other actions, but not reading+ BoundedReadNotPermitted !MemoryPermission+ | BoundedReadFailed !ReadModelError+ | BoundedReadInvalidPage !BoundedPageError+ deriving stock (Eq, Show)++-- | The largest page any bounded read returns. A zero-size page is valid Relay and returns+-- only page info.+maxBoundedPageSize :: Int+maxBoundedPageSize = 100++boundedPage :: R.SortSpec row -> D.Row row -> S.Snippet -> PageRequest -> Tx.Transaction (Either BoundedPageError (Connection row))+boundedPage order decoder base request+ | request.pageSize < 0 = pure (Left (InvalidPageSize (NegativePageSize request.pageSize)))+ | request.pageSize > maxBoundedPageSize = pure (Left (InvalidPageSize (PageSizeTooLarge request.pageSize maxBoundedPageSize)))+ | otherwise = case R.paginate order request base decoder of+ Left failure -> pure (Left (InvalidPageCursor failure))+ Right statement -> Right <$> Tx.statement () statement++-- | Run a scalar read only if the context grants 'MemoryRead'.+gatedRead :: (Applicative f) => MemoryAccessContext -> f (Either ReadModelError a) -> f (Either BoundedReadError a)+gatedRead context run+ | not (memoryContextAllows MemoryRead context) = pure (Left (BoundedReadNotPermitted MemoryRead))+ | otherwise = first BoundedReadFailed <$> run++-- | Run a page read only if the context grants 'MemoryRead'.+gatedPage :: (Applicative f) => MemoryAccessContext -> f (Either ReadModelError (Either BoundedPageError a)) -> f (Either BoundedReadError a)+gatedPage context run = (>>= first BoundedReadInvalidPage) <$> gatedRead context run++-- | The context's memory space as a typed parameter.+contextSpaceSnippet :: MemoryAccessContext -> S.Snippet+contextSpaceSnippet = S.param . memorySpaceIdText . memoryContextSpace++-- | The namespace/scope predicate shared by the session and memory collections. The global+-- bucket is matched with @IS NULL@; an entity scope always names both its kind and ref.+recallTargetSnippet :: RecallTarget -> S.Snippet+recallTargetSnippet = \case+ NamespaceWide (Namespace ns) -> namespace ns+ ExactScope (ScopeGlobal (Namespace ns)) -> namespace ns <> " AND scope_kind IS NULL AND scope_ref IS NULL"+ ExactScope (ScopeEntity (Namespace ns) (ScopeKind kind) ref) -> namespace ns <> " AND scope_kind=" <> S.param kind <> " AND scope_ref=" <> S.param ref+ where+ namespace :: Text -> S.Snippet+ namespace ns = " AND namespace=" <> S.param ns
src/Kioku/ProjectionCatalog.hs view
@@ -69,6 +69,8 @@ memoriesBySessionRowsReadModel, memoriesByTypeReadModel, memoriesByTypeRowsReadModel,+ memoriesCountReadModel,+ memoriesPageReadModel, memoryByIdReadModel, memoryInlineProjection, memorySupersessionChainReadModel,@@ -82,11 +84,15 @@ sessionChainReadModel, sessionDelegationChildrenReadModel, sessionInlineProjection,+ sessionRelationsReadModel, sessionsByFocusReadModel, sessionsByNamespaceReadModel, sessionsByScopeReadModel, sessionsByStartedRangeReadModel,+ sessionsCountReadModel,+ sessionsPageReadModel, turnsBySessionReadModel,+ turnsPageReadModel, ) import Kiroku.Store.Types (CategoryName (..)) @@ -166,7 +172,9 @@ memoryQueryBindings :: [SomeQueryModelBinding] memoryQueryBindings =- [ binding memoryByIdReadModel memoryGroupId memoriesTargetId,+ [ binding memoriesPageReadModel memoryGroupId memoriesTargetId,+ binding memoriesCountReadModel memoryGroupId memoriesTargetId,+ binding memoryByIdReadModel memoryGroupId memoriesTargetId, binding memoriesByNamespaceReadModel memoryGroupId memoriesTargetId, binding memoriesByNamespaceRowsReadModel memoryGroupId memoriesTargetId, binding memoriesByScopeReadModel memoryGroupId memoriesTargetId,@@ -180,7 +188,11 @@ sessionQueryBindings :: [SomeQueryModelBinding] sessionQueryBindings =- [ binding sessionByIdReadModel sessionGroupId sessionsTargetId,+ [ binding sessionsPageReadModel sessionGroupId sessionsTargetId,+ binding sessionsCountReadModel sessionGroupId sessionsTargetId,+ binding turnsPageReadModel sessionGroupId turnsTargetId,+ bindingMany sessionRelationsReadModel sessionGroupId [sessionsTargetId, turnsTargetId],+ binding sessionByIdReadModel sessionGroupId sessionsTargetId, binding sessionsByNamespaceReadModel sessionGroupId sessionsTargetId, binding sessionsByScopeReadModel sessionGroupId sessionsTargetId, binding sessionsByFocusReadModel sessionGroupId sessionsTargetId,@@ -192,13 +204,16 @@ ] binding :: ReadModel q r -> RebuildGroupId -> TargetId -> SomeQueryModelBinding-binding readModel rebuildGroup observedTarget =+binding readModel rebuildGroup observedTarget = bindingMany readModel rebuildGroup [observedTarget]++bindingMany :: ReadModel q r -> RebuildGroupId -> [TargetId] -> SomeQueryModelBinding+bindingMany readModel rebuildGroup observedTargets = SomeQueryModelBinding QueryModelBinding { queryModelId = must (mkQueryModelId readModel.name), readModel, rebuildGroup,- observedTargets = [observedTarget],+ observedTargets, claimSite = site ("query model " <> readModel.name) }
src/Kioku/Session.hs view
@@ -47,6 +47,17 @@ getDelegationChildren, getAwaitingByCorrelationKey, getTurns,+ SessionStatus (..),+ SessionFilter (..),+ SessionSelection (..),+ SessionCounts (..),+ SessionRelations (..),+ TurnRow (..),+ BoundedReadError (..),+ getPageWithContext,+ getCountsWithContext,+ getTurnsPageWithContext,+ getRelationsWithContext, ) where @@ -72,6 +83,7 @@ import Kioku.Api.Scope (MemoryScope, Namespace (..), scopeKindText, scopeNamespaceText, scopeRefText) import Kioku.Distill.Timer (l1TimerScheduleProjection) import Kioku.Id (SessionId, idText)+import Kioku.Pagination (BoundedReadError (..), Connection, PageRequest, gatedPage, gatedRead) import Kioku.Prelude import Kioku.ProjectionCatalog (kiokuProjectionCatalog, sessionProjectionSet) import Kioku.Session.Domain@@ -80,23 +92,37 @@ ( AwaitingSessionsByCorrelationKeyQuery (..), SessionByIdQuery (..), SessionChainQuery (..),+ SessionCounts (..), SessionDelegationChildrenQuery (..),+ SessionFilter (..),+ SessionRelations (..),+ SessionRelationsQuery (..), SessionRow (..),+ SessionSelection (..),+ SessionStatus (..), SessionsByFocusQuery (..), SessionsByNamespaceQuery (..), SessionsByScopeQuery (..), SessionsByStartedRangeQuery (..),+ SessionsCountQuery (..),+ SessionsPageQuery (..), TurnRow (..), TurnsBySessionQuery (..),+ TurnsPageQuery (..), awaitingSessionsByCorrelationKeyReadModel,+ parseSessionStatus, sessionByIdReadModel, sessionChainReadModel, sessionDelegationChildrenReadModel,+ sessionRelationsReadModel, sessionsByFocusReadModel, sessionsByNamespaceReadModel, sessionsByScopeReadModel, sessionsByStartedRangeReadModel,+ sessionsCountReadModel,+ sessionsPageReadModel, turnsBySessionReadModel,+ turnsPageReadModel, ) import Kiroku.Store.Effect (Store) import Kiroku.Store.Effect.Resource (KirokuStoreResource)@@ -214,26 +240,6 @@ -- * Idempotent accepts --- | The five statuses the projection writes. Parsing at the point of decision keeps--- 'SessionRow.status' a 'Text' (it is part of the read-model shape hosts consume) while--- removing stringly-typed comparisons from the decision logic.-data SessionStatus- = StatusRunning- | StatusAwaiting- | StatusCompleted- | StatusFailed- | StatusInteractive- deriving stock (Eq, Show)--parseSessionStatus :: Text -> Maybe SessionStatus-parseSessionStatus = \case- "running" -> Just StatusRunning- "awaiting" -> Just StatusAwaiting- "completed" -> Just StatusCompleted- "failed" -> Just StatusFailed- "interactive" -> Just StatusInteractive- _ -> Nothing- -- | Look up the session and hand its row plus parsed status to the caller. withExistingSession :: (IOE :> es, Store :> es) =>@@ -821,3 +827,17 @@ case result of Left err -> Left (SessionCommandRejected err) Right _ -> Right sid++-- | Filters and lineage selection are applied in SQL before the bounded page. Like every+-- bounded read, this refuses a context that does not grant 'MemoryRead' before any SQL runs.+getPageWithContext :: (IOE :> es, Store :> es) => MemoryAccessContext -> SessionSelection -> PageRequest -> Eff es (Either BoundedReadError (Connection SessionRow))+getPageWithContext context selection page = gatedPage context (runQueryWithFreshness Nothing Immediate sessionsPageReadModel SessionsPageQuery {context, selection, page})++getCountsWithContext :: (IOE :> es, Store :> es) => MemoryAccessContext -> SessionSelection -> Eff es (Either BoundedReadError SessionCounts)+getCountsWithContext context selection = gatedRead context (runQueryWithFreshness Nothing Immediate sessionsCountReadModel SessionsCountQuery {context, selection})++getTurnsPageWithContext :: (IOE :> es, Store :> es) => MemoryAccessContext -> SessionId -> PageRequest -> Eff es (Either BoundedReadError (Connection TurnRow))+getTurnsPageWithContext context sid page = gatedPage context (runQueryWithFreshness Nothing Immediate turnsPageReadModel TurnsPageQuery {context, sessionId = idText sid, page})++getRelationsWithContext :: (IOE :> es, Store :> es) => MemoryAccessContext -> SessionId -> Eff es (Either BoundedReadError SessionRelations)+getRelationsWithContext context sid = gatedRead context (runQueryWithFreshness Nothing Immediate sessionRelationsReadModel SessionRelationsQuery {context, sessionId = idText sid})
src/Kioku/Session/ReadModel.hs view
@@ -4,7 +4,22 @@ -- space. Both columns are written by the projection from the event payload rather than looked -- up, and every query below filters on the space as well as on whatever it is keyed by. module Kioku.Session.ReadModel- ( sessionInlineProjection,+ ( SessionStatus (..),+ parseSessionStatus,+ sessionStatusToText,+ SessionFilter (..),+ SessionSelection (..),+ SessionsPageQuery (..),+ SessionsCountQuery (..),+ SessionCounts (..),+ TurnsPageQuery (..),+ SessionRelationsQuery (..),+ SessionRelations (..),+ sessionsPageReadModel,+ sessionsCountReadModel,+ turnsPageReadModel,+ sessionRelationsReadModel,+ sessionInlineProjection, SessionRow (..), TurnRow (..), SessionByIdQuery (..),@@ -38,9 +53,11 @@ where import Data.Functor.Contravariant ((>$<))-import Data.Int (Int32)+import Data.Generics.Labels ()+import Data.Int (Int32, Int64) import Data.Text qualified as Text import Hasql.Decoders qualified as D+import Hasql.DynamicStatements.Snippet qualified as S import Hasql.Encoders qualified as E import Hasql.Statement (Statement, preparable) import Hasql.Transaction qualified as Tx@@ -51,7 +68,8 @@ ReadModelBlueprint (..), immediateReadModel, )-import Kioku.Api.Access (MemorySpaceId)+import Kioku.Api.Access (MemoryAccessContext, MemorySpaceId)+import Kioku.Api.Recall (RecallTarget) import Kioku.Api.Scope (scopeKindText, scopeNamespaceText, scopeRefText) import Kioku.Database.Schema ( kiokuSchema,@@ -61,10 +79,12 @@ turnsTable, ) import Kioku.Id (idText)+import Kioku.Pagination (BoundedPageError, Connection, PageRequest, boundedPage, contextSpaceSnippet, recallTargetSnippet) import Kioku.Partition (memorySpaceColumn, memorySpaceParam) import Kioku.Prelude import Kioku.Session.Domain import Kiroku.Store.Types (RecordedEvent)+import Relay.Pagination.Hasql qualified as R data SessionRow = SessionRow { memorySpaceId :: !MemorySpaceId,@@ -466,6 +486,16 @@ turnReadModelShapeHash :: Text turnReadModelShapeHash = "kioku-turn-v3" +-- | The relations count has its own identity rather than the session row's: its result is one+-- existence flag and three counts over keys that have not changed since the base schema, so a+-- session or turn row reshape does not change what it returns. Changing either value changes+-- the persisted catalog fingerprint and requires a catalog adoption.+sessionRelationsReadModelVersion :: Int+sessionRelationsReadModelVersion = 1++sessionRelationsReadModelShapeHash :: Text+sessionRelationsReadModelShapeHash = "kioku-session-relations-count-v1"+ sessionRowDecoder :: D.Row SessionRow sessionRowDecoder = SessionRow@@ -872,3 +902,198 @@ <> ((\row -> fmap (fromIntegral @Int @Int32) row.promptTokens) >$< E.param (E.nullable E.int4)) <> ((\row -> fmap (fromIntegral @Int @Int32) row.outputTokens) >$< E.param (E.nullable E.int4)) <> ((\row -> row.recordedAt) >$< E.param (E.nonNullable E.timestamptz))++-- | The five statuses the projection writes. Parsing at the point of decision keeps+-- 'SessionRow.status' a 'Text' (it is part of the read-model shape hosts consume) while+-- removing stringly-typed comparisons from the decision logic and from filters.+data SessionStatus+ = StatusRunning+ | StatusAwaiting+ | StatusCompleted+ | StatusFailed+ | StatusInteractive+ deriving stock (Eq, Show, Enum, Bounded)++parseSessionStatus :: Text -> Maybe SessionStatus+parseSessionStatus = \case+ "running" -> Just StatusRunning+ "awaiting" -> Just StatusAwaiting+ "completed" -> Just StatusCompleted+ "failed" -> Just StatusFailed+ "interactive" -> Just StatusInteractive+ _ -> Nothing++sessionStatusToText :: SessionStatus -> Text+sessionStatusToText = \case+ StatusRunning -> "running"+ StatusAwaiting -> "awaiting"+ StatusCompleted -> "completed"+ StatusFailed -> "failed"+ StatusInteractive -> "interactive"++-- All optional predicates apply inside the trusted SQL relation before paging.+data SessionFilter = SessionFilter+ { target :: !RecallTarget,+ focus :: !(Maybe Text),+ status :: !(Maybe SessionStatus),+ subjectRef :: !(Maybe Text),+ startedSince :: !(Maybe UTCTime),+ startedUntil :: !(Maybe UTCTime)+ }+ deriving stock (Eq, Show)++data SessionSelection = FilteredSessions !SessionFilter | ContinuationChain !Text | DelegationChildren !Text+ deriving stock (Eq, Show)++data SessionsPageQuery = SessionsPageQuery {context :: !MemoryAccessContext, selection :: !SessionSelection, page :: !PageRequest}++data SessionsCountQuery = SessionsCountQuery {context :: !MemoryAccessContext, selection :: !SessionSelection}++data TurnsPageQuery = TurnsPageQuery {context :: !MemoryAccessContext, sessionId :: !Text, page :: !PageRequest}++data SessionRelationsQuery = SessionRelationsQuery {context :: !MemoryAccessContext, sessionId :: !Text}++data SessionCounts = SessionCounts+ {total :: !Int64, running :: !Int64, interactive :: !Int64, awaiting :: !Int64, completed :: !Int64, failed :: !Int64}+ deriving stock (Generic, Eq, Show)++data SessionRelations = SessionRelations {found :: !Bool, turns :: !Int64, chainMembers :: !Int64, delegationChildren :: !Int64}+ deriving stock (Generic, Eq, Show)++sessionsPageReadModel :: ReadModel SessionsPageQuery (Either BoundedPageError (Connection SessionRow))+sessionsPageReadModel =+ immediateReadModel+ ReadModelBlueprint+ { name = "kioku-sessions-page",+ schema = kiokuSchema,+ tableName = sessionsRelation,+ version = sessionReadModelVersion,+ shapeHash = sessionReadModelShapeHash,+ cursorAuthority = NoQueryCursor,+ query = \q -> boundedPage (sessionPageOrder q.selection) sessionRowDecoder (sessionSelectionBase q.context q.selection) q.page+ }++sessionsCountReadModel :: ReadModel SessionsCountQuery SessionCounts+sessionsCountReadModel =+ immediateReadModel+ ReadModelBlueprint+ { name = "kioku-sessions-count",+ schema = kiokuSchema,+ tableName = sessionsRelation,+ version = sessionReadModelVersion,+ shapeHash = sessionReadModelShapeHash,+ cursorAuthority = NoQueryCursor,+ query = \q ->+ Tx.statement+ ()+ ( S.toPreparableStatement+ ("SELECT count(*),count(*) FILTER(WHERE status='running'),count(*) FILTER(WHERE status='interactive'),count(*) FILTER(WHERE status='awaiting'),count(*) FILTER(WHERE status='completed'),count(*) FILTER(WHERE status='failed') FROM (" <> sessionSelectionBase q.context q.selection <> ") selected")+ (D.singleRow (SessionCounts <$> int8 <*> int8 <*> int8 <*> int8 <*> int8 <*> int8))+ )+ }+ where+ int8 = D.column (D.nonNullable D.int8)++turnsPageReadModel :: ReadModel TurnsPageQuery (Either BoundedPageError (Connection TurnRow))+turnsPageReadModel =+ immediateReadModel+ ReadModelBlueprint+ { name = "kioku-turns-page",+ schema = kiokuSchema,+ tableName = turnsRelation,+ version = turnReadModelVersion,+ shapeHash = turnReadModelShapeHash,+ cursorAuthority = NoQueryCursor,+ query = \q ->+ boundedPage+ order+ turnRowDecoder+ ("SELECT memory_space_id,turn_id,session_id,turn_index,role,content,tool_summary,prompt_tokens,output_tokens,recorded_at FROM " <> S.sql turnsTable <> " WHERE memory_space_id=" <> contextSpaceSnippet q.context <> " AND session_id=" <> S.param q.sessionId)+ q.page+ }+ where+ order = R.SortSpec (R.KeyColumn "turn_index" R.Asc (fromIntegral . (.turnIndex)) R.int8Key :| [R.KeyColumn "turn_id" R.Asc (.turnId) R.textKey])++sessionRelationsReadModel :: ReadModel SessionRelationsQuery SessionRelations+sessionRelationsReadModel =+ immediateReadModel+ ReadModelBlueprint+ { name = "kioku-session-relations-count",+ schema = kiokuSchema,+ tableName = sessionsRelation,+ version = sessionRelationsReadModelVersion,+ shapeHash = sessionRelationsReadModelShapeHash,+ cursorAuthority = NoQueryCursor,+ query = \q ->+ Tx.statement+ ()+ ( S.toPreparableStatement+ ( "SELECT EXISTS(SELECT 1 FROM "+ <> S.sql sessionsTable+ <> " WHERE memory_space_id="+ <> contextSpaceSnippet q.context+ <> " AND session_id="+ <> S.param q.sessionId+ <> "),"+ <> "(SELECT count(*) FROM "+ <> S.sql turnsTable+ <> " WHERE memory_space_id="+ <> contextSpaceSnippet q.context+ <> " AND session_id="+ <> S.param q.sessionId+ <> "),"+ <> "(SELECT count(*) FROM ("+ <> sessionSelectionBase q.context (ContinuationChain q.sessionId)+ <> ") chain),"+ <> "(SELECT count(*) FROM "+ <> S.sql sessionsTable+ <> " WHERE memory_space_id="+ <> contextSpaceSnippet q.context+ <> " AND parent_session_id="+ <> S.param q.sessionId+ <> ")"+ )+ (D.singleRow (SessionRelations <$> D.column (D.nonNullable D.bool) <*> int8 <*> int8 <*> int8))+ )+ }+ where+ int8 = D.column (D.nonNullable D.int8)++sessionPageOrder :: SessionSelection -> R.SortSpec SessionRow+sessionPageOrder selection = R.SortSpec (R.KeyColumn "started_at" direction (.startedAt) R.timestamptzKey :| [R.KeyColumn "session_id" R.Asc (.sessionId) R.textKey])+ where+ direction = case selection of FilteredSessions _ -> R.Desc; _ -> R.Asc++sessionSelectionBase :: MemoryAccessContext -> SessionSelection -> S.Snippet+sessionSelectionBase context = \case+ FilteredSessions filters ->+ "SELECT "+ <> S.sql sessionRowColumns+ <> " FROM "+ <> S.sql sessionsTable+ <> " WHERE memory_space_id="+ <> contextSpaceSnippet context+ <> recallTargetSnippet filters.target+ <> maybe "" (\value -> " AND focus=" <> S.param value) filters.focus+ <> maybe "" (\value -> " AND status=" <> S.param (sessionStatusToText value)) filters.status+ <> maybe "" (\value -> " AND subject_ref=" <> S.param value) filters.subjectRef+ <> maybe "" (\value -> " AND started_at >= " <> S.param value) filters.startedSince+ <> maybe "" (\value -> " AND started_at < " <> S.param value) filters.startedUntil+ DelegationChildren parent -> "SELECT " <> S.sql sessionRowColumns <> " FROM " <> S.sql sessionsTable <> " WHERE memory_space_id=" <> contextSpaceSnippet context <> " AND parent_session_id=" <> S.param parent+ ContinuationChain identity ->+ "WITH RECURSIVE chain(session_id,previous_session_id) AS (SELECT session_id,previous_session_id FROM "+ <> S.sql sessionsTable+ <> " WHERE memory_space_id="+ <> contextSpaceSnippet context+ <> " AND session_id="+ <> S.param identity+ <> " UNION SELECT s.session_id,s.previous_session_id FROM "+ <> S.sql sessionsTable+ <> " s JOIN chain c ON s.session_id=c.previous_session_id WHERE s.memory_space_id="+ <> contextSpaceSnippet context+ <> ") SELECT "+ <> S.sql (qualifiedSessionRowColumns "s")+ <> " FROM "+ <> S.sql sessionsTable+ <> " s JOIN chain c ON s.session_id=c.session_id WHERE s.memory_space_id="+ <> contextSpaceSnippet context
+ test/Kioku/BoundedReadSpec.hs view
@@ -0,0 +1,328 @@+module Kioku.BoundedReadSpec (tests) where++import Control.Concurrent.Async (concurrently)+import Control.Monad (replicateM)+import Data.List (sort)+import Data.Set qualified as Set+import Data.Text qualified as Text+import Data.Time (addUTCTime)+import Effectful (Eff)+import Kioku.Api.Access (MemoryAccessContext, MemoryPermission (..), memoryContextRecordedActor, memoryContextSpace)+import Kioku.Api.Access.Internal qualified as Internal+import Kioku.Api.Recall (RecallTarget (..))+import Kioku.Api.Scope (MemoryScope (..), Namespace (..), ScopeKind (..))+import Kioku.Api.Types (Confidence (..), MemoryStatus (..), MemoryType (..))+import Kioku.App (AppEffects, runAppIO, withNoopAppEnv)+import Kioku.Id (MemoryId, SessionId, genMemoryId, genSessionId, idText)+import Kioku.Memory qualified as M+import Kioku.Memory.Domain (ArchiveMemoryData (..), RecordMemoryData (..), SupersedeMemoryData (..))+import Kioku.Migrations.TestSupport (withKiokuMigratedDatabase)+import Kioku.Pagination (BoundedPageError (..), BoundedReadError (..))+import Kioku.Prelude+import Kioku.Session qualified as S+import Kioku.Session.Domain (RecordTurnData (..), StartSessionData (..))+import Kioku.SpaceFixtures (otherContext, testActor, testContext, testSpace)+import Kiroku.Store.Connection (defaultConnectionSettings)+import Relay.Pagination (Connection (..), Direction (..), Edge (..), PageInfo (..), PageRequest (..), PageRequestError (..))+import Test.Tasty (TestTree, testGroup)+import Test.Tasty.HUnit (assertEqual, assertFailure, testCase)++tests :: TestTree+tests =+ testGroup+ "Bounded facade"+ [ testCase "session pages, scalar counts and subject filters respect space and explicit scope" (withApp sessionPages),+ testCase "memory pages filter before bounds and archive reports its own append" (withApp memoryPages),+ testCase "turn and continuation pages retain complete relation counts" (withApp relationPages),+ testCase "concurrent archives report exactly one appending command" archiveRace,+ testCase "supersession pages traverse both directions and stay in their space" (withApp supersessionPages),+ testCase "delegation children and started ranges select before bounds" (withApp delegationAndRange),+ testCase "memory type and session filters page in native session order" (withApp memoryFilters),+ testCase "every bounded read refuses a context without MemoryRead before SQL" (withApp readPermission)+ ]++ns :: Namespace+ns = Namespace "bounded"++entity :: MemoryScope+entity = ScopeEntity ns (ScopeKind "intention") "one"++right :: (Show e) => Either e a -> Eff AppEffects a+right = either (liftIO . assertFailure . show) pure++check :: (Eq a, Show a) => String -> a -> a -> Eff AppEffects ()+check label expected actual = liftIO (assertEqual label expected actual)++withApp :: Eff AppEffects a -> IO a+withApp action = withKiokuMigratedDatabase \conn -> withNoopAppEnv (defaultConnectionSettings conn) \env -> do+ result <- runAppIO env action+ either (assertFailure . show) pure result++start :: MemoryAccessContext -> MemoryScope -> UTCTime -> Maybe SessionId -> Eff AppEffects SessionId+start context scope now previous = startSession context scope now previous Nothing++startSession :: MemoryAccessContext -> MemoryScope -> UTCTime -> Maybe SessionId -> Maybe SessionId -> Eff AppEffects SessionId+startSession context scope now previous parent = do+ sid <- liftIO genSessionId+ right+ =<< S.startWithContext+ context+ StartSessionData+ { sessionId = sid,+ memorySpaceId = memoryContextSpace context,+ actorPrincipal = memoryContextRecordedActor context,+ ownerPrincipal = Nothing,+ agentId = "bounded",+ focus = "review",+ scope,+ subjectRef = Just "same-subject",+ previousSessionId = previous,+ parentSessionId = parent,+ delegationDepth = maybe 0 (const 1) parent,+ startedAt = now+ }++record :: MemoryAccessContext -> MemoryScope -> UTCTime -> Eff AppEffects MemoryId+record context scope now = recordWithPriority context scope now 100++recordWithPriority :: MemoryAccessContext -> MemoryScope -> UTCTime -> Int -> Eff AppEffects MemoryId+recordWithPriority context scope now priority = recordMemory context scope now priority Nothing MemoryFact++recordMemory :: MemoryAccessContext -> MemoryScope -> UTCTime -> Int -> Maybe SessionId -> MemoryType -> Eff AppEffects MemoryId+recordMemory context scope now priority session memoryType = do+ mid <- liftIO genMemoryId+ right+ =<< M.recordWithContext+ context+ RecordMemoryData+ { memoryId = mid,+ memorySpaceId = memoryContextSpace context,+ actorPrincipal = memoryContextRecordedActor context,+ ownerPrincipal = Nothing,+ agentId = "bounded",+ sessionId = session,+ scope,+ memoryType,+ content = "content",+ priority,+ confidence = HighConfidence,+ tags = Set.empty,+ supersedes = Nothing,+ recordedAt = now+ }++page :: Int -> PageRequest+page n = PageRequest n Forward Nothing++nodes :: Connection a -> [a]+nodes connection = map (.node) connection.edges++pageError :: Either BoundedReadError a -> Maybe BoundedPageError+pageError = \case+ Left (BoundedReadInvalidPage err) -> Just err+ _ -> Nothing++sessionPages :: Eff AppEffects ()+sessionPages = do+ now <- liftIO getCurrentTime+ ids <- replicateM 7 (start testContext entity now Nothing)+ global <- start testContext (ScopeGlobal ns) now Nothing+ _ <- start otherContext entity now Nothing+ let filters = S.SessionFilter (NamespaceWide ns) Nothing Nothing Nothing Nothing Nothing+ selection = S.FilteredSessions filters+ get req = right =<< S.getPageWithContext testContext selection req+ first <- get (page 3)+ second <- get (PageRequest 3 Forward first.pageInfo.endCursor)+ third <- get (PageRequest 3 Forward second.pageInfo.endCursor)+ check "all tied rows" (sort (map idText (global : ids))) (sort (map (.sessionId) (nodes first <> nodes second <> nodes third)))+ back <- get (PageRequest 3 Backward second.pageInfo.startCursor)+ check "backward order" (map (.sessionId) (nodes first)) (map (.sessionId) (nodes back))+ counts <- right =<< S.getCountsWithContext testContext selection+ check "complete count" 8 counts.total+ exact <- right =<< S.getPageWithContext testContext (S.FilteredSessions (filters {S.target = ExactScope (ScopeGlobal ns)})) (page 3)+ check "exact global is narrow" [idText global] (map (.sessionId) (nodes exact))+ subject <- right =<< S.getCountsWithContext testContext (S.FilteredSessions (S.SessionFilter (NamespaceWide ns) Nothing Nothing (Just "absent") Nothing Nothing))+ check "subject filter" 0 subject.total+ tooLarge <- S.getPageWithContext testContext selection (page 101)+ check "maximum enforced" (Just (InvalidPageSize (PageSizeTooLarge 101 100))) (pageError tooLarge)+ negative <- S.getPageWithContext testContext selection (page (-1))+ check "negative refused" (Just (InvalidPageSize (NegativePageSize (-1)))) (pageError negative)+ running <- right =<< S.getCountsWithContext testContext (S.FilteredSessions (S.SessionFilter (NamespaceWide ns) Nothing (Just S.StatusRunning) Nothing Nothing Nothing))+ check "typed status filter" 8 running.total+ completed <- right =<< S.getCountsWithContext testContext (S.FilteredSessions (S.SessionFilter (NamespaceWide ns) Nothing (Just S.StatusCompleted) Nothing Nothing Nothing))+ check "absent status" 0 completed.total++memoryPages :: Eff AppEffects ()+memoryPages = do+ now <- liftIO getCurrentTime+ ids <- replicateM 7 (record testContext entity now)+ global <- recordWithPriority testContext (ScopeGlobal ns) now 1+ _ <- record otherContext entity now+ let filters = M.MemoryFilter (ExactScope entity) (Just MemoryActive) Nothing Nothing+ selection = M.FilteredMemories filters+ get req = right =<< M.getPageWithContext testContext selection req+ first <- get (page 3)+ second <- get (PageRequest 3 Forward first.pageInfo.endCursor)+ third <- get (PageRequest 3 Forward second.pageInfo.endCursor)+ check "all tied rows" (sort (map idText ids)) (sort (map (.memoryId) (nodes first <> nodes second <> nodes third)))+ back <- get (PageRequest 3 Backward second.pageInfo.startCursor)+ check "backward order" (map (.memoryId) (nodes first)) (map (.memoryId) (nodes back))+ prioritized <- right =<< M.getPageWithContext testContext (M.FilteredMemories (filters {M.target = NamespaceWide ns})) (page 1)+ check "native priority order" [idText global] (map (.memoryId) (nodes prioritized))+ let archive = ArchiveMemoryData {memoryId = global, memorySpaceId = memoryContextSpace testContext, actorPrincipal = memoryContextRecordedActor testContext, archivedAt = now}+ initial <- right =<< M.archiveWithContextChanged testContext archive+ duplicate <- right =<< M.archiveWithContextChanged testContext archive+ check "archive changed" (global, True) initial+ check "archive repeated" (global, False) duplicate+ counts <- right =<< M.getCountsWithContext testContext (M.FilteredMemories (filters {M.target = NamespaceWide ns, M.status = Nothing}))+ check "all statuses count" (8, 7, 1) (counts.total, counts.active, counts.archived)+ archived <- right =<< M.getPageWithContext testContext (M.FilteredMemories (filters {M.target = NamespaceWide ns, M.status = Just MemoryArchived})) (page 1)+ check "status before bounds" [idText global] (map (.memoryId) (nodes archived))++relationPages :: Eff AppEffects ()+relationPages = do+ now <- liftIO getCurrentTime+ root <- start testContext entity now Nothing+ next <- start testContext entity now (Just root)+ latest <- start testContext entity now (Just next)+ forM_ [1 .. 7] \i -> do+ _ <-+ right+ =<< S.recordTurnWithContext+ testContext+ RecordTurnData+ { sessionId = latest,+ memorySpaceId = memoryContextSpace testContext,+ actorPrincipal = memoryContextRecordedActor testContext,+ turnId = "bounded-" <> Text.pack (show i),+ turnIndex = i,+ role = "user",+ content = "content",+ toolSummary = Nothing,+ promptTokens = Nothing,+ outputTokens = Nothing,+ recordedAt = now+ }+ pure ()+ relations <- right =<< S.getRelationsWithContext testContext latest+ check "complete counts" (True, 7, 3, 0) (relations.found, relations.turns, relations.chainMembers, relations.delegationChildren)+ turns <- right =<< S.getTurnsPageWithContext testContext latest (page 2)+ rest <- right =<< S.getTurnsPageWithContext testContext latest (PageRequest 100 Forward turns.pageInfo.endCursor)+ check "all turns" [1 .. 7] (map (.turnIndex) (nodes turns <> nodes rest))+ chain <- right =<< S.getPageWithContext testContext (S.ContinuationChain (idText latest)) (page 100)+ check "all ancestors" (sort (map idText [root, next, latest])) (sort (map (.sessionId) (nodes chain)))+ hidden <- right =<< S.getRelationsWithContext otherContext latest+ check "foreign relation invisible" (False, 0, 0, 0) (hidden.found, hidden.turns, hidden.chainMembers, hidden.delegationChildren)++archiveRace :: IO ()+archiveRace = withKiokuMigratedDatabase \conn -> withNoopAppEnv (defaultConnectionSettings conn) \env -> do+ let run :: Eff AppEffects a -> IO a+ run action = runAppIO env action >>= either (assertFailure . show) pure+ now <- getCurrentTime+ mid <- run (record testContext entity now)+ let command =+ M.archiveWithContextChanged+ testContext+ ArchiveMemoryData+ { memoryId = mid,+ memorySpaceId = memoryContextSpace testContext,+ actorPrincipal = memoryContextRecordedActor testContext,+ archivedAt = now+ }+ (a, b) <- concurrently (run (right =<< command)) (run (right =<< command))+ assertEqual "one authoritative append" [False, True] (sort [snd a, snd b])++supersessionPages :: Eff AppEffects ()+supersessionPages = do+ now <- liftIO getCurrentTime+ a <- record testContext entity now+ b <- record testContext entity now+ c <- record testContext entity now+ _ <-+ right+ =<< M.supersedeWithContext+ testContext+ SupersedeMemoryData+ { memoryId = a,+ memorySpaceId = memoryContextSpace testContext,+ actorPrincipal = memoryContextRecordedActor testContext,+ supersededBy = b,+ supersededAt = now+ }+ _ <-+ right+ =<< M.supersedeWithContext+ testContext+ SupersedeMemoryData+ { memoryId = b,+ memorySpaceId = memoryContextSpace testContext,+ actorPrincipal = memoryContextRecordedActor testContext,+ supersededBy = c,+ supersededAt = now+ }+ let selection = M.SupersessionChain (idText b)+ get req = right =<< M.getPageWithContext testContext selection req+ first <- get (page 1)+ rest <- get (PageRequest 100 Forward first.pageInfo.endCursor)+ check "both directions" (sort (map idText [a, b, c])) (sort (map (.memoryId) (nodes first <> nodes rest)))+ counts <- right =<< M.getCountsWithContext testContext selection+ check "complete chain counts" (3, 1, 2) (counts.total, counts.active, counts.superseded)+ foreignPage <- right =<< M.getPageWithContext otherContext selection (page 100)+ check "foreign chain invisible" [] (map (.memoryId) (nodes foreignPage))++delegationAndRange :: Eff AppEffects ()+delegationAndRange = do+ now <- liftIO getCurrentTime+ parent <- start testContext entity now Nothing+ delegated <- forM [1 .. 4] \i -> startSession testContext entity (addUTCTime (fromInteger i) now) Nothing (Just parent)+ _ <- startSession otherContext entity now Nothing Nothing+ let get req = right =<< S.getPageWithContext testContext (S.DelegationChildren (idText parent)) req+ first <- get (page 3)+ rest <- get (PageRequest 3 Forward first.pageInfo.endCursor)+ check "children ascend by start" (map idText delegated) (map (.sessionId) (nodes first <> nodes rest))+ relations <- right =<< S.getRelationsWithContext testContext parent+ check "child count" 4 relations.delegationChildren+ let filters = S.SessionFilter (ExactScope entity) Nothing Nothing Nothing (Just (addUTCTime 2 now)) (Just (addUTCTime 4 now))+ ranged <- right =<< S.getPageWithContext testContext (S.FilteredSessions filters) (page 100)+ check "half-open started range" (map idText (reverse (take 2 (drop 1 delegated)))) (map (.sessionId) (nodes ranged))+ rangedCount <- right =<< S.getCountsWithContext testContext (S.FilteredSessions filters)+ check "range count" 2 rangedCount.total++memoryFilters :: Eff AppEffects ()+memoryFilters = do+ now <- liftIO getCurrentTime+ sid <- start testContext entity now Nothing+ inSession <- forM [1 .. 5] \i -> recordMemory testContext entity (addUTCTime (fromInteger i) now) (fromInteger i) (Just sid) MemoryFact+ preference <- recordMemory testContext entity now 100 Nothing MemoryPreference+ _ <- recordMemory testContext entity now 100 Nothing MemoryFact+ let filters = M.MemoryFilter (NamespaceWide ns) Nothing Nothing (Just (idText sid))+ get req = right =<< M.getPageWithContext testContext (M.FilteredMemories filters) req+ first <- get (page 2)+ second <- get (PageRequest 2 Forward first.pageInfo.endCursor)+ third <- get (PageRequest 2 Forward second.pageInfo.endCursor)+ check "session order ignores priority" (map idText (reverse inSession)) (map (.memoryId) (nodes first <> nodes second <> nodes third))+ back <- get (PageRequest 2 Backward second.pageInfo.startCursor)+ check "session backward order" (map (.memoryId) (nodes first)) (map (.memoryId) (nodes back))+ typed <- right =<< M.getPageWithContext testContext (M.FilteredMemories (M.MemoryFilter (NamespaceWide ns) Nothing (Just MemoryPreference) Nothing)) (page 100)+ check "memory type filter" [idText preference] (map (.memoryId) (nodes typed))++readPermission :: Eff AppEffects ()+readPermission = do+ now <- liftIO getCurrentTime+ let writer = Internal.MemoryAccessContext {Internal.memorySpaceId = testSpace, Internal.actor = testActor, Internal.grantedPermissions = Set.fromList [MemoryRecord, MemoryForget], Internal.decisionToken = Nothing}+ denied :: Either BoundedReadError a -> Bool+ denied = \case+ Left (BoundedReadNotPermitted MemoryRead) -> True+ _ -> False+ sid <- start writer entity now Nothing+ _ <- record writer entity now+ sessionPage <- S.getPageWithContext writer (S.FilteredSessions (S.SessionFilter (NamespaceWide ns) Nothing Nothing Nothing Nothing Nothing)) (page 1)+ sessionCounts <- S.getCountsWithContext writer (S.ContinuationChain (idText sid))+ turnsPage <- S.getTurnsPageWithContext writer sid (page 1)+ relations <- S.getRelationsWithContext writer sid+ memoryPage <- M.getPageWithContext writer (M.FilteredMemories (M.MemoryFilter (NamespaceWide ns) Nothing Nothing Nothing)) (page 1)+ memoryCounts <- M.getCountsWithContext writer (M.SupersessionChain "absent")+ check "all six reads refused" [True, True, True, True, True, True] [denied sessionPage, denied sessionCounts, denied turnsPage, denied relations, denied memoryPage, denied memoryCounts]+ -- The gate runs before page validation, so an invalid page cannot probe a refused space.+ invalid <- S.getPageWithContext writer (S.DelegationChildren (idText sid)) (page 101)+ check "permission before page size" True (denied invalid)
test/Kioku/ProjectionCatalogSpec.hs view
@@ -18,15 +18,19 @@ ( CatalogDiagnostic (..), CatalogDiagnosticCode (..), CatalogInventory (..),+ InventoryGroup (..), InventoryQueryCursor, InventoryQueryFreshness (..), InventoryQueryModel (..), ProjectionCatalog (..), ProjectionDefinition (..), ProjectionSet (..),+ QueryModelBinding (..), SomeProjectionSet (..),+ SomeQueryModelBinding (..), SourceDeclaration (..), Validation (..),+ catalogFingerprint, catalogFingerprintText, catalogRegistrations, mkClaimSite,@@ -37,10 +41,11 @@ import Keiro.ReadModel ( HeadScope (EntireVisibleLog), QueryFreshness (WaitForHead),+ ReadModel (..), ReadModelDefinitionError (ReadModelDefinitionMissingCursor), headWaitingReadModel, )-import Keiro.ReadModel.Rebuild (registerProjectionCatalog)+import Keiro.ReadModel.Rebuild (CatalogAdoptionPlan (..), GroupAdoptionClass (..), adoptCatalogGroups, previewCatalogAdoption, registerProjectionCatalog) import Keiro.Stream qualified as Stream import Keiro.Timer (lookupTimer) import Kioku.Api.Scope (MemoryScope (..), Namespace (..))@@ -77,6 +82,7 @@ testGroup "ProjectionCatalog" [ testCase "the Kioku catalog has one stable complete inventory" testInventory,+ testCase "bounded queries adopt from the exact released catalog through public lifecycle APIs" testCompatibleAdoption, testCase "typed projection sets select only application-owned handlers" testTypedHandlers, testCase "both hand-written event streams validate explicitly" testEventStreams, testCase "duplicate target ownership is rejected with a stable diagnostic" testDuplicateOwnership,@@ -97,8 +103,8 @@ assertEqual "targets" 3 (length kiokuCatalogInventory.inventoryTargets) assertEqual "groups" 2 (length kiokuCatalogInventory.inventoryGroups) assertEqual "projection owners" 2 (length kiokuCatalogInventory.inventoryProjections)- assertEqual "query models" 19 (length kiokuCatalogInventory.inventoryQueryModels)- assertEqual "registrations" 19 (length (catalogRegistrations kiokuProjectionCatalog))+ assertEqual "query models" 25 (length kiokuCatalogInventory.inventoryQueryModels)+ assertEqual "registrations" 25 (length (catalogRegistrations kiokuProjectionCatalog)) assertBool "every query is immediate and cursorless" ( all@@ -107,7 +113,7 @@ ) assertEqual "canonical fingerprint"- "catalog-v7:54f9ac55d73f40160d87b0ae49c2477a0de860c2e6e1e38df0591a2036d002e4"+ "catalog-v7:8cce393084799278ee21a4aba3356e1f88d34c630e8c1b97aa8743e543616956" (catalogFingerprintText kiokuCatalogFingerprint) where noCursor :: Maybe InventoryQueryCursor -> Bool@@ -351,3 +357,28 @@ must :: (Show error) => Either error value -> value must = either (error . show) id++testCompatibleAdoption :: Assertion+testCompatibleAdoption = withKiokuMigratedDatabase \conn ->+ withNoopAppEnv (defaultConnectionSettings conn) \env -> do+ let added = ["kioku-memories-page", "kioku-memories-count", "kioku-sessions-page", "kioku-sessions-count", "kioku-turns-page", "kioku-session-relations-count"]+ historical definition = definition {queryModels = filter (\(SomeQueryModelBinding binding) -> binding.readModel.name `notElem` added) definition.queryModels}+ prior <- case validateKiokuProjectionCatalogWith historical of+ Failure errors -> assertFailure (show errors)+ Success catalog -> pure catalog+ assertEqual "exact previous release catalog" "catalog-v7:54f9ac55d73f40160d87b0ae49c2477a0de860c2e6e1e38df0591a2036d002e4" (catalogFingerprintText (catalogFingerprint prior))+ let groups = NonEmpty.fromList (map (.rebuildGroupId) kiokuCatalogInventory.inventoryGroups)+ result <- runAppIO env do+ reverted <- adoptCatalogGroups prior groups+ liftIO (either (assertFailure . show) (const (pure ())) reverted)+ oldRegistered <- registerProjectionCatalog prior+ liftIO (either (assertFailure . show) (const (pure ())) oldRegistered)+ drift <- registerProjectionCatalog kiokuProjectionCatalog+ liftIO $ assertBool "ordinary startup refuses drift" (case drift of Left _ -> True; Right _ -> False)+ preview <- previewCatalogAdoption kiokuProjectionCatalog+ liftIO $ assertEqual "both live groups change" 2 (length [() | (_, AdoptionSliceChanged _ _) <- preview.groupStates])+ adopted <- adoptCatalogGroups kiokuProjectionCatalog groups+ liftIO (either (assertFailure . show) (const (pure ())) adopted)+ registered <- registerProjectionCatalog kiokuProjectionCatalog+ liftIO (either (assertFailure . show) (const (pure ())) registered)+ either (assertFailure . show) pure result
test/Kioku/ReadModelReconcileSpec.hs view
@@ -119,8 +119,8 @@ -- Every Kioku read model reads one of the three relocated projections, so the relocation -- leaves none of them current and reconciliation has to advance all of them. assertEqual- "every model was advanced"- []+ "all historical models advanced; the new relation-count identity stays current"+ ["kioku-session-relations-count"] [schema.readModelName | (schema, outcome) <- outcomes, outcome /= Reconciled] repaired <- Session.getById testSpace sid
test/Kioku/SpaceIsolationSpec.hs view
@@ -448,10 +448,10 @@ liftIO $ mapM_ assertPartitionLeading- [ ("memories by scope", "kioku_memories_space_", memoriesByScope, scopeRows),- ("memories by type", "kioku_memories_space_", memoriesByType, typeRows),- ("sessions by namespace", "kioku_sessions_space_", sessionsByNamespace, namespaceSessions),- ("sessions awaiting", "kioku_sessions_space_", sessionsAwaiting, awaitingSessions)+ [ ("memories by scope", "memories_space_", memoriesByScope, scopeRows),+ ("memories by type", "memories_space_", memoriesByType, typeRows),+ ("sessions by namespace", "sessions_space_", sessionsByNamespace, namespaceSessions),+ ("sessions awaiting", "sessions_space_", sessionsAwaiting, awaitingSessions) ] where rowCount = either (const (-1)) length
test/Main.hs view
@@ -2,6 +2,7 @@ import Kioku.AIRuntimeSpec qualified as AIRuntimeSpec import Kioku.AwaitingSpec qualified as AwaitingSpec+import Kioku.BoundedReadSpec qualified as BoundedReadSpec import Kioku.CodecCompatSpec qualified as CodecCompatSpec import Kioku.DistillSpec qualified as DistillSpec import Kioku.EmbeddingWorkerSpec qualified as EmbeddingWorkerSpec@@ -28,7 +29,8 @@ defaultMain $ testGroup "kioku"- [ AIRuntimeSpec.tests,+ [ BoundedReadSpec.tests,+ AIRuntimeSpec.tests, AwaitingSpec.tests, CodecCompatSpec.tests, IdempotencySpec.tests,