Store¶
ConversationStore ¶
Bases: ABC
Persistent storage for rooms, events, bindings, and participants.
Implement this ABC to plug in any storage backend (SQL, Redis, etc.).
The library ships with InMemoryStore for development and testing.
patch_room_metadata
async
¶
Merge patch into a room's metadata, removing unset keys first.
The targeted alternative to update_room for metadata-only changes.
update_room rewrites the whole room row from an in-memory Room
(read-modify-write), so a caller holding a stale object silently
clobbers concurrent metadata patches and regresses the counters
(event_count / latest_index / timers) advanced by
commit_event. This method touches only the metadata keys it is
given and stamps updated_at.
Returns the updated room, or None when room_id does not exist.
The default implementation is NOT atomic — it reads, merges, then
writes back via update_room. Persistent backends that may be
shared across processes MUST override this with a single storage-level
partial update (e.g. a JSONB merge).
delete_room
abstractmethod
async
¶
Delete a room. Returns True if the room existed.
find_rooms
abstractmethod
async
¶
Find rooms matching the given filters.
find_latest_room
abstractmethod
async
¶
Find the most recent room for a participant.
find_room_id_by_channel
abstractmethod
async
¶
Find a room ID that has a binding for the given channel_id.
find_room_ids_by_channel
async
¶
Room IDs bound to channel_id, deterministically ordered.
Routing must be able to tell "exactly one room" from "several", because
picking one of several delivers a message into a conversation it does
not belong to (RFC §10.4). find_room_id_by_channel cannot express
that — it returns one id either way — so this returns up to limit,
and the default of 2 is enough to answer "is this ambiguous?".
Implementations MUST order the result deterministically: the same stored state has to give the same answer whatever the backend.
This is not abstract, so a store written before it existed keeps working — it falls back to the single lookup and therefore cannot report ambiguity. Override it.
update_event
abstractmethod
async
¶
Update an existing event (e.g., mark as edited or deleted).
delete_event
abstractmethod
async
¶
Hard-delete a persisted event, optionally cascading to its thread replies.
parent_event_id has no DB-level FK, so a bare root delete would
orphan its replies — cascade_replies (default) removes them in the
same operation. Returns the deleted event IDs (root first, then
replies); empty when event_id does not exist in room_id.
list_events
abstractmethod
async
¶
list_events(room_id, offset=0, limit=50, visibility_filter=None, *, after_index=None, before_index=None, event_filter=None, newest_first=False)
List events in a room with pagination and filtering.
Supports two pagination modes:
- Offset-based (default):
offset+limitfor simple page access. - Cursor-based:
after_indexorbefore_indexfor efficient keyset pagination on large rooms. When either is set,offsetis ignored.
after_index and before_index are mutually exclusive.
When event_filter is provided, its visibility field takes
precedence over visibility_filter.
By default the offset-based mode returns the oldest limit events
(the head of the room). Pass newest_first=True to return the most
recent limit events instead — still in ascending chronological
order, so a "give me the latest page" snapshot reads top-to-bottom.
newest_first only applies to the offset-based mode; it is ignored
when a cursor (after_index / before_index) is supplied.
.. note::
Cursor pagination relies on events having a valid ``index``
assigned by :meth:`add_event_auto_index`. Events stored via
:meth:`add_event` keep the model default ``index=0`` and will
all compare equal, producing incorrect cursor results. Use
``add_event_auto_index`` for rooms that need cursor pagination.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
room_id
|
str
|
Room to query. |
required |
offset
|
int
|
Number of events to skip (offset-based mode). |
0
|
limit
|
int
|
Maximum number of events to return. |
50
|
visibility_filter
|
str | None
|
Optional visibility value to filter by. Ignored when event_filter provides a visibility. |
None
|
after_index
|
int | None
|
Return events with |
None
|
before_index
|
int | None
|
Return the last |
None
|
event_filter
|
EventFilter | None
|
Rich filter criteria (event types, source, time range,
correlation ID). See :class: |
None
|
newest_first
|
bool
|
In offset-based mode, return the most recent |
False
|
get_thread_summaries
abstractmethod
async
¶
Return reply aggregates for the given thread roots.
For each root that has replies, the result maps its id to a
:class:ThreadSummary (reply count + last-reply timestamp). Roots with
no replies are absent from the mapping. Used to render a "N replies"
affordance without fetching every reply.
check_idempotency
abstractmethod
async
¶
Check if an idempotency key has been seen. Returns True if duplicate.
get_event_count
abstractmethod
async
¶
Return the total number of events in a room.
add_event_auto_index
async
¶
Atomically assign the next index and store the event.
The default implementation reads the count and writes in two steps. Backends should override this with an atomic implementation (e.g. a single SQL transaction) to prevent race conditions on the index.
commit_event
async
¶
Store event and bump the room counters as ONE atomic commit.
The commit point of RFC §10.1 (step 12) / §14.3: (re)assigning the
authoritative index, persisting the event, and updating the room's
event_count / latest_index / timers.last_activity_at form a
single logical transaction. An observer MUST never see a stored
event that the room counters do not reflect, nor counters that count an
event absent from the timeline.
The authoritative index is (re)computed inside the commit (RFC §8.1) so a persistent store shared across processes serializes concurrent writers on the store, not only on an in-process room lock.
Returns the committed event — its index may differ from any
provisional value the caller assigned before hooks ran, if the store
serialized a concurrent writer.
The default implementation is NOT atomic — it indexes, inserts, then updates the room in separate steps. Persistent backends that may be shared across processes MUST override this with a single storage transaction.
get_conversation
async
¶
Return message events only — suitable for AI context rebuilding.
Filters to MESSAGE events, excluding tool calls, lifecycle,
and system noise. Tool call history is not included because AI
providers track their own tool call context internally via the
message history passed to each generation call.
Use :meth:get_timeline to retrieve the full activity log
including tool calls.
get_timeline
async
¶
Return the full activity timeline for a room.
Returns all persisted events in order. Use event_filter to narrow results (e.g. only tool calls, only a specific correlation group).
Without a cursor the default is the oldest limit events; pass
newest_first=True for the most recent limit (still ascending) —
the right shape for a reconnect snapshot that must show recent history,
not the room's opening events.
get_binding
abstractmethod
async
¶
Get a channel binding, or None if not attached.
remove_binding
abstractmethod
async
¶
Detach a channel from a room. Returns True if it was attached.
get_participant
abstractmethod
async
¶
Get a participant by ID within a room.
list_participants
abstractmethod
async
¶
List all participants in a room.
resolve_identity
abstractmethod
async
¶
Look up an identity by channel type and address.
link_address
abstractmethod
async
¶
Link a channel address to an identity.
list_tasks
abstractmethod
async
¶
List tasks for a room, optionally filtered by status.
list_observations
abstractmethod
async
¶
List all observations for a room.
mark_read
abstractmethod
async
¶
Mark an event as read for a channel.
mark_all_read
abstractmethod
async
¶
Mark all events as read for a channel.
get_unread_count
abstractmethod
async
¶
Return the number of unread events for a channel in a room.
list_read_markers
abstractmethod
async
¶
Return every channel's read high-water-mark in a room.
Maps channel_id -> the highest read event index. Channels with
no marker are absent. With one channel per member, this is the raw
material for aggregating per-member "seen by" receipts.
close
async
¶
Release any resources held by the store (e.g. a connection pool).
Called by RoomKit.close(). The default is a no-op — override it in
backends that own external resources. Implementations MUST be
idempotent and MUST NOT close resources they do not own.
InMemoryStore ¶
Bases: ConversationStore
Dict-based in-memory store for development and testing.
PostgresStore ¶
Bases: ConversationStore
PostgreSQL-backed conversation store using asyncpg.
Uses a fully relational schema with indexed columns for all frequently queried fields. JSONB is used only for flexible/extensible data (metadata, content, capabilities).
init
async
¶
Create the connection pool (if needed) and ensure the schema exists.
Runs only additive, idempotent DDL (CREATE TABLE IF NOT EXISTS).
It never drops a table, so calling init() after a library upgrade
cannot destroy data.
If a v1 (JSONB-blob) schema is detected, init() refuses to
touch it and raises :class:PostgresSchemaError. Back up your data,
then run the explicit :meth:migrate to move v1 → v2.
migrate
async
¶
Explicit, opt-in schema migration. Never runs automatically.
The only path that performs the destructive v1 → v2 migration, which DROPs every RoomKit table (irreversible data loss). Serialized across processes with a PostgreSQL advisory lock.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
dry_run
|
bool
|
When |
True
|
confirm
|
bool
|
Required together with |
False
|
Returns:
| Type | Description |
|---|---|
dict[str, Any]
|
A report |
dict[str, Any]
|
|
dict[str, Any]
|
|
Raises:
| Type | Description |
|---|---|
PostgresSchemaError
|
on |
dedupe_event_indices
async
¶
Repair duplicate event indices, then enforce UNIQUE(room_id, index).
A pre-fix release could assign the same index to two events in a room
under concurrency (RFC §8.1). This renumbers each affected room's events
to a unique, sequential 0..N-1 ordered by (index, created_at, id),
reconciles the room counters, and (re)creates idx_events_room_index
as UNIQUE — all in one transaction.
Renumbering shifts indices, so read markers (last_read_index /
read_markers.event_index) may be off afterwards. Run it in a
maintenance window, on a backup first.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
dry_run
|
bool
|
When |
True
|
Returns:
| Type | Description |
|---|---|
dict[str, Any]
|
|
dict[str, Any]
|
where |
dict[str, Any]
|
|
drop_legacy_parent_index
async
¶
Drop the pre-composite single-column idx_events_parent.
idx_events_parent_index on (parent_event_id, index) — created
additively by :meth:init — supersedes it: the leading column still
serves plain parent_event_id lookups while the second lets thread
pagination read the page pre-sorted. :meth:init is additive and never
drops, so this opt-in call removes the now-redundant single-column index
on databases that predate the composite. Idempotent — a no-op once gone.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
dry_run
|
bool
|
When |
True
|
Returns:
| Type | Description |
|---|---|
dict[str, Any]
|
|
dict[str, Any]
|
|
patch_room_metadata
async
¶
Atomic override: one partial UPDATE, no read-modify-write.
(metadata - unset) || patch runs inside the row update itself, so
concurrent patches merge instead of clobbering and the counters
maintained by commit_event are never touched.
add_event_auto_index
async
¶
Atomically assign the next index and store the event in one transaction.
Uses SELECT ... FOR UPDATE on the rooms table to serialise
concurrent index assignments for the same room.
commit_event
async
¶
Atomic commit (RFC §10.1 step 12 / §8.1 / §14.3) in ONE transaction.
SELECT ... FOR UPDATE on the room row serializes concurrent commits
for the same room across connections (i.e. across processes); the index
is computed, the event inserted, and the room counters
(event_count / latest_index / timers.last_activity_at)
updated together, so the timeline and the counters can never diverge —
even under a crash or without a cross-process room lock.