Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
3463578
feat(api): /sessions filters by type and paginates by cursor
Menci Jun 18, 2026
312288e
fix(sessions): rewrite SQLite paged list as hand-rolled query
Menci Jun 19, 2026
95043ba
fix(sessions): keep paging alive when filter trims the page
Menci Jun 19, 2026
09473cd
feat(sessions): index bot_sessions(bot_id, updated_at DESC, id DESC)
Menci Jun 19, 2026
45f5c75
refactor(session): collapse duplicated paged row mappers
Menci Jun 19, 2026
8d081a0
chore(sessions): satisfy lint on new paged code
Menci Jun 19, 2026
eaba49b
fix(session): treat partial SessionCursor as zero
Menci Jun 19, 2026
760ef9d
refactor(sqlite): simplify paged cursor binding and surface parse errors
Menci Jun 19, 2026
756c8e8
docs(handlers): note cursor precision differs across backends
Menci Jun 19, 2026
dd9f0e9
test(sqlite): cover same-second cursor tiebreak path
Menci Jun 19, 2026
d17de32
test(sqlite): document why paged tests skip the migration baseline
Menci Jun 19, 2026
fcd3c56
chore(sqlite): use errors.New for the static timestamp parse error
Menci Jun 19, 2026
3ade0d1
chore(sqlite): clean up sessions_paged scan helper + parse error sent…
Menci Jun 19, 2026
9443715
docs(sqlite): pin updated_at second-precision invariant for paged cursor
Menci Jun 19, 2026
5ee1818
refactor(sessions): pageful has-more probe, int64 limits, strict cursor
Menci Jun 19, 2026
f9da794
fix(handlers): empty session page renders as [] and hoist cursor inva…
Menci Jun 19, 2026
f3c2263
test(db): seed bot_sessions stub in pre-model-enable fixture
Menci Jun 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions db/postgres/migrations/0001_init.up.sql
Original file line number Diff line number Diff line change
Expand Up @@ -459,6 +459,7 @@ CREATE INDEX IF NOT EXISTS idx_bot_sessions_bot_active ON bot_sessions(bot_id, d
CREATE INDEX IF NOT EXISTS idx_bot_sessions_parent ON bot_sessions(parent_session_id) WHERE parent_session_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_bot_sessions_created_by_user_id ON bot_sessions(created_by_user_id) WHERE created_by_user_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_bot_sessions_bot_created_by ON bot_sessions(bot_id, created_by_user_id, deleted_at);
CREATE INDEX IF NOT EXISTS idx_bot_sessions_bot_active_updated ON bot_sessions(bot_id, updated_at DESC, id DESC) WHERE deleted_at IS NULL;

-- Add FK from routes to sessions (deferred to avoid circular dependency during CREATE).
ALTER TABLE bot_channel_routes
Expand Down
3 changes: 3 additions & 0 deletions db/postgres/migrations/0098_paged_sessions_index.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- 0098_paged_sessions_index (down)

DROP INDEX IF EXISTS idx_bot_sessions_bot_active_updated;
9 changes: 9 additions & 0 deletions db/postgres/migrations/0098_paged_sessions_index.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
-- 0098_paged_sessions_index
-- Add a composite (bot_id, updated_at DESC, id DESC) partial index on
-- non-deleted rows to back the new paged /sessions endpoint. The previous
-- indexes covered (bot_id) and (bot_id, deleted_at) but did not help when
-- ordering by updated_at DESC, id DESC for keyset pagination.

CREATE INDEX IF NOT EXISTS idx_bot_sessions_bot_active_updated
ON bot_sessions(bot_id, updated_at DESC, id DESC)
WHERE deleted_at IS NULL;
40 changes: 40 additions & 0 deletions db/postgres/queries/sessions.sql
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,46 @@ WHERE s.bot_id = sqlc.arg(bot_id)
AND s.deleted_at IS NULL
ORDER BY s.updated_at DESC;

-- name: ListSessionsByBotPaged :many
-- Cursor uses (updated_at, id) so pages stay stable when many rows share an
-- updated_at. Callers always pass an explicit types filter; to opt out of
-- filtering, pass every known type.
SELECT
s.id, s.bot_id, s.route_id, s.channel_type, s.type, s.title, s.metadata,
s.parent_session_id, s.created_by_user_id, s.created_at, s.updated_at, s.deleted_at,
r.metadata AS route_metadata,
r.conversation_type AS route_conversation_type
FROM bot_sessions s
LEFT JOIN bot_channel_routes r ON r.id = s.route_id
WHERE s.bot_id = sqlc.arg(bot_id)
AND s.deleted_at IS NULL
AND s.type = ANY(sqlc.arg(types)::text[])
AND (
NOT sqlc.arg(use_cursor)::bool
OR (s.updated_at, s.id) < (sqlc.arg(cursor_updated_at)::timestamptz, sqlc.arg(cursor_id)::uuid)
)
ORDER BY s.updated_at DESC, s.id DESC
LIMIT sqlc.arg(limit_count)::int;

-- name: ListSessionsByBotAndCreatedByUserPaged :many
SELECT
s.id, s.bot_id, s.route_id, s.channel_type, s.type, s.title, s.metadata,
s.parent_session_id, s.created_by_user_id, s.created_at, s.updated_at, s.deleted_at,
r.metadata AS route_metadata,
r.conversation_type AS route_conversation_type
FROM bot_sessions s
LEFT JOIN bot_channel_routes r ON r.id = s.route_id
WHERE s.bot_id = sqlc.arg(bot_id)
AND s.created_by_user_id = sqlc.arg(created_by_user_id)
AND s.deleted_at IS NULL
AND s.type = ANY(sqlc.arg(types)::text[])
AND (
NOT sqlc.arg(use_cursor)::bool
OR (s.updated_at, s.id) < (sqlc.arg(cursor_updated_at)::timestamptz, sqlc.arg(cursor_id)::uuid)
)
ORDER BY s.updated_at DESC, s.id DESC
LIMIT sqlc.arg(limit_count)::int;

-- name: ListSessionsByRoute :many
SELECT *
FROM bot_sessions
Expand Down
7 changes: 7 additions & 0 deletions db/sqlite/migrations/0001_init.up.sql
Original file line number Diff line number Diff line change
Expand Up @@ -445,6 +445,12 @@ CREATE TABLE IF NOT EXISTS bot_sessions (
parent_session_id TEXT REFERENCES bot_sessions(id) ON DELETE SET NULL,
created_by_user_id TEXT REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
-- updated_at is stored at second precision via CURRENT_TIMESTAMP. The
-- keyset cursor in `internal/db/sqlite/store/sessions_paged.go` truncates
-- its bound timestamp to seconds to match this storage. If we ever upgrade
-- this column to sub-second precision, the cursor formatter MUST keep
-- sub-second too so the lexicographic compare stays consistent — otherwise
-- rows in the same second would be skipped or returned indefinitely.
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TEXT
);
Expand All @@ -455,6 +461,7 @@ CREATE INDEX IF NOT EXISTS idx_bot_sessions_bot_active ON bot_sessions(bot_id, d
CREATE INDEX IF NOT EXISTS idx_bot_sessions_parent ON bot_sessions(parent_session_id) WHERE parent_session_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_bot_sessions_created_by_user_id ON bot_sessions(created_by_user_id) WHERE created_by_user_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_bot_sessions_bot_created_by ON bot_sessions(bot_id, created_by_user_id, deleted_at);
CREATE INDEX IF NOT EXISTS idx_bot_sessions_bot_active_updated ON bot_sessions(bot_id, updated_at DESC, id DESC) WHERE deleted_at IS NULL;

-- bot_session_events: DCP pipeline event store for cold-start replay.
CREATE TABLE IF NOT EXISTS bot_session_events (
Expand Down
3 changes: 3 additions & 0 deletions db/sqlite/migrations/0023_paged_sessions_index.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
-- 0023_paged_sessions_index (down)

DROP INDEX IF EXISTS idx_bot_sessions_bot_active_updated;
9 changes: 9 additions & 0 deletions db/sqlite/migrations/0023_paged_sessions_index.up.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
-- 0023_paged_sessions_index
-- Add a composite (bot_id, updated_at DESC, id DESC) partial index on
-- non-deleted rows to back the new paged /sessions endpoint. The previous
-- indexes covered (bot_id) and (bot_id, deleted_at) but did not help when
-- ordering by updated_at DESC, id DESC for keyset pagination.

CREATE INDEX IF NOT EXISTS idx_bot_sessions_bot_active_updated
ON bot_sessions(bot_id, updated_at DESC, id DESC)
WHERE deleted_at IS NULL;
8 changes: 8 additions & 0 deletions db/sqlite/queries/sessions.sql
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,14 @@ WHERE s.bot_id = sqlc.arg(bot_id)
AND s.deleted_at IS NULL
ORDER BY s.updated_at DESC;

-- ListSessionsByBotPaged and ListSessionsByBotAndCreatedByUserPaged are not
-- generated for SQLite. The Postgres versions live in
-- db/postgres/queries/sessions.sql; the SQLite shim hand-rolls the query in
-- internal/db/sqlite/store/sessions_paged.go because sqlc-sqlite cannot mix
-- sqlc.slice with reused numbered placeholders without colliding bind indexes.



-- name: ListSessionsByRoute :many
SELECT *
FROM bot_sessions
Expand Down
11 changes: 11 additions & 0 deletions internal/db/model_enable_migration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,17 @@ CREATE TABLE model_variants (
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);

-- Minimal stub of bot_sessions so migrations targeting that table (e.g.
-- the 0023 paged-sessions index) parse and apply against this pre-22
-- fixture. Columns mirror only what newer migrations reference; this is
-- not a full replica of the production schema.
CREATE TABLE bot_sessions (
id TEXT PRIMARY KEY,
bot_id TEXT NOT NULL,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at TEXT
);
`)
if err != nil {
t.Fatalf("create pre-model-enable schema: %v", err)
Expand Down
180 changes: 180 additions & 0 deletions internal/db/postgres/sqlc/sessions.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions internal/db/sqlite/sqlc/sessions.sql.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

8 changes: 8 additions & 0 deletions internal/db/sqlite/store/queries.go
Original file line number Diff line number Diff line change
Expand Up @@ -4158,6 +4158,14 @@ func (q *Queries) ListSessionsByBotAndCreatedByUser(ctx context.Context, arg pgs
return result, nil
}

func (q *Queries) ListSessionsByBotPaged(ctx context.Context, arg pgsqlc.ListSessionsByBotPagedParams) ([]pgsqlc.ListSessionsByBotPagedRow, error) {
return q.listSessionsByBotPaged(ctx, arg)
}

func (q *Queries) ListSessionsByBotAndCreatedByUserPaged(ctx context.Context, arg pgsqlc.ListSessionsByBotAndCreatedByUserPagedParams) ([]pgsqlc.ListSessionsByBotAndCreatedByUserPagedRow, error) {
return q.listSessionsByBotAndCreatedByUserPaged(ctx, arg)
}

func (q *Queries) ListSessionsByRoute(ctx context.Context, routeID pgtype.UUID) ([]pgsqlc.BotSession, error) {
if q == nil || q.store == nil || q.store.queries == nil {
return nil, errSQLiteQueriesNotConfigured
Expand Down
Loading
Loading