feat(history,gateway): D4 会话未读/已读游标 — unreadCount + POST /sessions/{id}/read + session_read 广播
- chat_sessions 增 read_at(毫秒,与 chat_messages.created_at 同单位);迁移 0010 并把既有会话回填为各自最新消息时间(升级当天不误报)
- ListSessions 单查询派生 unreadCount(无 N+1,复用 idx_chat_msgs_session_created 覆盖索引)
- MarkSessionRead 只前进 MAX(read_at, ?);未知会话 ErrSessionNotFound;游标方法挂在网关消费侧窄接口(沿用 GetMessage/messageLookup 先例,避免污染 runtime 接口与其 fake)
- POST /api/v1/sessions/{id}/read:writeAuth + 400/404/501,成功广播 session_read、失败路径不广播
- Docs/api-client v0.37(§1 unreadCount / 新行 8a / §3.9 session_read / §4.2 用法)
- 含迁移回填测试、store/handler 测试与变异验证;顺带把 migrate_pinned_test 的「回退一步=0009」硬编码改为对迁移数量免疫
This commit is contained in:
1 parent
04a8ee0727
commit
e8eccd2bb2
14 files changed
+855
-25
No files matched your search
@@ -7,11 +7,12 @@ import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
// This file owns the last-message preview carried in the session list
|
||||
// (GET /api/v1/sessions): a display-only summary of each session's newest message so a
|
||||
// client can render a conversation row without one request per session. The preview is
|
||||
// truncated for display and never feeds identity or permission decisions — identity
|
||||
// comes from channel actor metadata / JWT (project red line).
|
||||
// This file owns the two derived per-session display fields carried in the session
|
||||
// list (GET /api/v1/sessions): the last-message preview and the unread count. Both are
|
||||
// read-only projections so a client can render a conversation row (with its unread
|
||||
// badge) without one request per session. The preview is truncated for display, and
|
||||
// neither field ever feeds identity or permission decisions — identity comes from
|
||||
// channel actor metadata / JWT (project red line).
|
||||
|
||||
// lastMessagePreviewRunes bounds the preview content so a long message cannot bloat the
|
||||
// session-list response. A longer message is whitespace-collapsed and truncated to this
|
||||
@@ -25,14 +26,19 @@ type MessageSummary struct {
|
||||
CreatedAt int64 // Unix milliseconds
|
||||
}
|
||||
|
||||
// listSessionsQuery reads every session plus its newest message in one statement. The
|
||||
// LEFT JOIN's correlated subquery resolves each session's newest row via the
|
||||
// (session_id, created_at DESC) index (rowid DESC breaks a same-millisecond tie by
|
||||
// insertion order), so a session with no messages still appears with NULL preview
|
||||
// columns. Ordering floats pinned sessions first, then keeps the main session before
|
||||
// unpinned subs, then subs oldest-first.
|
||||
// listSessionsQuery reads every session plus its newest message and its unread count
|
||||
// in one statement. The LEFT JOIN's correlated subquery resolves each session's newest
|
||||
// row via the (session_id, created_at DESC) index (rowid DESC breaks a same-millisecond
|
||||
// tie by insertion order), so a session with no messages still appears with NULL preview
|
||||
// columns. The unread count is a correlated COUNT(*) over the same index
|
||||
// (session_id = s.id AND created_at > s.read_at is an equality + range scan), so no
|
||||
// per-session round trip (N+1) is needed and no extra index is required. Ordering floats
|
||||
// pinned sessions first, then keeps the main session before unpinned subs, then subs
|
||||
// oldest-first.
|
||||
const listSessionsQuery = `SELECT s.id, s.type, s.title, s.pinned, s.created_at,
|
||||
m.role, m.content, m.created_at
|
||||
m.role, m.content, m.created_at,
|
||||
(SELECT COUNT(*) FROM chat_messages c
|
||||
WHERE c.session_id = s.id AND c.created_at > s.read_at)
|
||||
FROM chat_sessions s
|
||||
LEFT JOIN chat_messages m ON m.rowid = (
|
||||
SELECT rowid FROM chat_messages
|
||||
@@ -44,8 +50,9 @@ const listSessionsQuery = `SELECT s.id, s.type, s.title, s.pinned, s.created_at,
|
||||
|
||||
// ListSessions returns pinned sessions first, then the main session, then every sub
|
||||
// session, oldest first, each carrying a LastMessage preview of its newest message (nil
|
||||
// when the session has none). Sessions and previews are read in a single query, so the
|
||||
// query count does not grow with the number of sessions. The returned slice is non-nil.
|
||||
// when the session has none) and the UnreadCount derived from the session's read cursor.
|
||||
// Sessions, previews and unread counts are read in a single query, so the query count
|
||||
// does not grow with the number of sessions. The returned slice is non-nil.
|
||||
func (s *Store) ListSessions(ctx context.Context) ([]SessionMeta, error) {
|
||||
rows, err := s.db.QueryContext(ctx, listSessionsQuery, SessionMain)
|
||||
if err != nil {
|
||||
@@ -67,9 +74,10 @@ func (s *Store) ListSessions(ctx context.Context) ([]SessionMeta, error) {
|
||||
return sessions, nil
|
||||
}
|
||||
|
||||
// scanSessionMeta reads one joined chat_sessions + last-message row. The three message
|
||||
// columns are NULL for a session that has never received a message, so they scan into
|
||||
// nullable holders and only produce a LastMessage when a row is present.
|
||||
// scanSessionMeta reads one joined chat_sessions + last-message + unread-count row. The
|
||||
// three message columns are NULL for a session that has never received a message, so they
|
||||
// scan into nullable holders and only produce a LastMessage when a row is present. The
|
||||
// unread count is a non-NULL COUNT(*), so it scans into a plain int.
|
||||
func scanSessionMeta(sc rowScanner) (SessionMeta, error) {
|
||||
var (
|
||||
meta SessionMeta
|
||||
@@ -79,7 +87,7 @@ func scanSessionMeta(sc rowScanner) (SessionMeta, error) {
|
||||
content sql.NullString
|
||||
lastAt sql.NullInt64
|
||||
)
|
||||
if err := sc.Scan(&meta.ID, &typeStr, &meta.Title, &pinned, &meta.CreatedAt, &role, &content, &lastAt); err != nil {
|
||||
if err := sc.Scan(&meta.ID, &typeStr, &meta.Title, &pinned, &meta.CreatedAt, &role, &content, &lastAt, &meta.UnreadCount); err != nil {
|
||||
return SessionMeta{}, err
|
||||
}
|
||||
meta.Type = typeStr
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
package history
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// This file owns the per-session read cursor (design/16 §4.2, Android 协同批次
|
||||
// D4/B3). There is a single owner account and no per-device identity at this
|
||||
// layer, so the cursor is session-scoped: reading on any one end marks the session
|
||||
// read for every end of the account. The cursor lives on chat_sessions.read_at in
|
||||
// the same millisecond unit as chat_messages.created_at, so the unread badge is a
|
||||
// plain `created_at > read_at` count (see listSessionsQuery).
|
||||
|
||||
// MarkSessionRead advances the session's read cursor to before and returns the
|
||||
// resulting cursor as a Unix millisecond timestamp. A zero before means "now" — the
|
||||
// caller's "mark everything I have seen" case, which does not have to read the
|
||||
// clock itself.
|
||||
//
|
||||
// The cursor only ever moves forward: the UPDATE applies SQLite's two-argument
|
||||
// scalar MAX, so a stale or out-of-order request (two ends racing, a retried
|
||||
// request) can never un-read messages. A negative before therefore leaves the stored
|
||||
// value untouched rather than moving it backwards.
|
||||
//
|
||||
// An unknown session id returns ErrSessionNotFound, matching RenameSession/SetPinned
|
||||
// — isolated (group/stranger) sessions have no chat_sessions row and are not
|
||||
// addressable here.
|
||||
func (s *Store) MarkSessionRead(ctx context.Context, sessionID string, before time.Time) (int64, error) {
|
||||
ms := before.UnixMilli()
|
||||
if before.IsZero() {
|
||||
ms = time.Now().UnixMilli()
|
||||
}
|
||||
res, err := s.db.ExecContext(ctx,
|
||||
`UPDATE chat_sessions SET read_at = MAX(read_at, ?) WHERE id = ?`, ms, sessionID)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("history: mark session read %s: %w", sessionID, err)
|
||||
}
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("history: mark session read %s rows: %w", sessionID, err)
|
||||
}
|
||||
if n == 0 {
|
||||
return 0, fmt.Errorf("history: mark session read %s: %w", sessionID, ErrSessionNotFound)
|
||||
}
|
||||
var readAt int64
|
||||
if err := s.db.QueryRowContext(ctx,
|
||||
`SELECT read_at FROM chat_sessions WHERE id = ?`, sessionID).Scan(&readAt); err != nil {
|
||||
return 0, fmt.Errorf("history: read session cursor %s: %w", sessionID, err)
|
||||
}
|
||||
return readAt, nil
|
||||
}
|
||||
@@ -0,0 +1,351 @@
|
||||
package history
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
||||
)
|
||||
|
||||
// These tests cover the D4 per-session read cursor (design/16 §4.2, Android
|
||||
// collaboration B3): the unread count derived in ListSessions, the forward-only
|
||||
// cursor semantics of MarkSessionRead, and the 0010 migration's upgrade backfill.
|
||||
|
||||
// TestListSessionsUnreadCountFromCursor pins the unread derivation: a message counts as
|
||||
// unread only when it is strictly newer than the session's cursor (created_at > read_at),
|
||||
// so the cursor is inclusive of the timestamp it names (a client that has rendered a
|
||||
// message sent at T marks T read). A fresh session starts at cursor 0 with everything
|
||||
// unread, and a cursor at the newest message clears the badge.
|
||||
func TestListSessionsUnreadCountFromCursor(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := newStore(t)
|
||||
ctx := context.Background()
|
||||
for _, ts := range []int64{1000, 2000, 3000} {
|
||||
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "m", CreatedAt: ts}); err != nil {
|
||||
t.Fatalf("seed %d: %v", ts, err)
|
||||
}
|
||||
}
|
||||
// Never read: all three messages are unread (a brand-new account/session).
|
||||
if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 3 {
|
||||
t.Fatalf("initial unreadCount = %d, want 3", got)
|
||||
}
|
||||
// Cursor on a message's own timestamp counts that message as read.
|
||||
readAt, err := st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(2000))
|
||||
if err != nil {
|
||||
t.Fatalf("mark read: %v", err)
|
||||
}
|
||||
if readAt != 2000 {
|
||||
t.Fatalf("readAt = %d, want 2000", readAt)
|
||||
}
|
||||
if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 1 {
|
||||
t.Fatalf("unreadCount after cursor 2000 = %d, want 1 (only 3000 newer)", got)
|
||||
}
|
||||
// A cursor between two messages reads the earlier one only.
|
||||
if _, err := st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(2500)); err != nil {
|
||||
t.Fatalf("mark read 2500: %v", err)
|
||||
}
|
||||
if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 1 {
|
||||
t.Fatalf("unreadCount after cursor 2500 = %d, want 1", got)
|
||||
}
|
||||
// A cursor at the newest message clears the badge.
|
||||
if _, err := st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(3000)); err != nil {
|
||||
t.Fatalf("mark read 3000: %v", err)
|
||||
}
|
||||
if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 0 {
|
||||
t.Fatalf("unreadCount after cursor 3000 = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestListSessionsUnreadCountSessionIsolation pins the per-session scope: each session
|
||||
// carries its own cursor, so reading the main session must not clear (or inflate) a sub
|
||||
// session's badge, and vice versa.
|
||||
func TestListSessionsUnreadCountSessionIsolation(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := newStore(t)
|
||||
ctx := context.Background()
|
||||
sub, err := st.NewSubSession(ctx, "owner")
|
||||
if err != nil {
|
||||
t.Fatalf("new sub: %v", err)
|
||||
}
|
||||
for _, m := range []Message{
|
||||
{SessionID: MainSessionID, Role: RoleUser, Content: "main-1", CreatedAt: 1000},
|
||||
{SessionID: MainSessionID, Role: RoleAssistant, Content: "main-2", CreatedAt: 2000},
|
||||
{SessionID: sub, Role: RoleUser, Content: "sub-1", CreatedAt: 1500},
|
||||
} {
|
||||
if err := st.Append(ctx, m); err != nil {
|
||||
t.Fatalf("seed %q: %v", m.Content, err)
|
||||
}
|
||||
}
|
||||
if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 2 {
|
||||
t.Fatalf("main unreadCount = %d, want 2", got)
|
||||
}
|
||||
if got := sessionOf(t, st, sub).UnreadCount; got != 1 {
|
||||
t.Fatalf("sub unreadCount = %d, want 1", got)
|
||||
}
|
||||
|
||||
// Reading the main session must leave the sub untouched.
|
||||
if _, err := st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(2000)); err != nil {
|
||||
t.Fatalf("mark main read: %v", err)
|
||||
}
|
||||
if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 0 {
|
||||
t.Fatalf("main unreadCount after read = %d, want 0", got)
|
||||
}
|
||||
if got := sessionOf(t, st, sub).UnreadCount; got != 1 {
|
||||
t.Fatalf("sub unreadCount after main read = %d, want 1 (cursors are per-session)", got)
|
||||
}
|
||||
|
||||
// A message arriving after the read re-opens the main badge.
|
||||
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "main-3", CreatedAt: 3000}); err != nil {
|
||||
t.Fatalf("append main-3: %v", err)
|
||||
}
|
||||
if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 1 {
|
||||
t.Fatalf("main unreadCount after new message = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarkSessionReadForwardOnly covers the racing/malformed-request side of the cursor
|
||||
// contract: the cursor never moves backwards, so a stale or repeated request is
|
||||
// idempotent and can never resurrect already-read messages.
|
||||
func TestMarkSessionReadForwardOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := newStore(t)
|
||||
ctx := context.Background()
|
||||
for _, ts := range []int64{1000, 2000, 3000} {
|
||||
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "m", CreatedAt: ts}); err != nil {
|
||||
t.Fatalf("seed %d: %v", ts, err)
|
||||
}
|
||||
}
|
||||
// Advance to the newest message.
|
||||
readAt, err := st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(3000))
|
||||
if err != nil || readAt != 3000 {
|
||||
t.Fatalf("mark 3000 = (%d, %v), want (3000, nil)", readAt, err)
|
||||
}
|
||||
// A stale request (an end that was behind, or a retry of an older read) must not
|
||||
// move the cursor back and re-open the badge.
|
||||
readAt, err = st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(1000))
|
||||
if err != nil {
|
||||
t.Fatalf("stale mark: %v", err)
|
||||
}
|
||||
if readAt != 3000 {
|
||||
t.Fatalf("stale mark readAt = %d, want the cursor to stay at 3000", readAt)
|
||||
}
|
||||
if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 0 {
|
||||
t.Fatalf("unreadCount after stale mark = %d, want 0 (never un-read)", got)
|
||||
}
|
||||
// Repeating the same cursor is a no-op, and a negative cursor is clamped by the
|
||||
// same MAX (it cannot move the stored value at all).
|
||||
if readAt, err = st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(3000)); err != nil || readAt != 3000 {
|
||||
t.Fatalf("repeat mark = (%d, %v), want (3000, nil)", readAt, err)
|
||||
}
|
||||
if readAt, err = st.MarkSessionRead(ctx, MainSessionID, time.UnixMilli(-5000)); err != nil || readAt != 3000 {
|
||||
t.Fatalf("negative mark = (%d, %v), want (3000, nil)", readAt, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarkSessionReadZeroTimeMeansNow covers the "mark everything I have seen" call the
|
||||
// HTTP layer makes when the body omits before: the zero time resolves to the wall clock,
|
||||
// which reads every message that already exists.
|
||||
func TestMarkSessionReadZeroTimeMeansNow(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := newStore(t)
|
||||
ctx := context.Background()
|
||||
seedAt := time.Now().UnixMilli()
|
||||
if err := st.Append(ctx, Message{SessionID: MainSessionID, Role: RoleUser, Content: "m", CreatedAt: seedAt}); err != nil {
|
||||
t.Fatalf("seed: %v", err)
|
||||
}
|
||||
if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 1 {
|
||||
t.Fatalf("unreadCount = %d, want 1", got)
|
||||
}
|
||||
readAt, err := st.MarkSessionRead(ctx, MainSessionID, time.Time{})
|
||||
if err != nil {
|
||||
t.Fatalf("mark zero time: %v", err)
|
||||
}
|
||||
if readAt < seedAt {
|
||||
t.Fatalf("readAt = %d, want >= the seeded message time %d", readAt, seedAt)
|
||||
}
|
||||
if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 0 {
|
||||
t.Fatalf("unreadCount after mark-now = %d, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMarkSessionReadUnknownSession pins the error contract: an id with no chat_sessions
|
||||
// row (including an isolated group/stranger session) is ErrSessionNotFound, so the HTTP
|
||||
// layer can answer 404 instead of silently reporting success for a session that does not
|
||||
// exist.
|
||||
func TestMarkSessionReadUnknownSession(t *testing.T) {
|
||||
t.Parallel()
|
||||
st := newStore(t)
|
||||
if _, err := st.MarkSessionRead(context.Background(), "web:missing", time.UnixMilli(1000)); !errors.Is(err, ErrSessionNotFound) {
|
||||
t.Fatalf("unknown session err = %v, want ErrSessionNotFound", err)
|
||||
}
|
||||
}
|
||||
|
||||
// migrationReadAtSQL reads one of the 0010 migration files from the source tree, so the
|
||||
// test applies the same SQL text the embedded migration runs rather than a copy.
|
||||
func migrationReadAtSQL(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(filepath.Join("..", "..", "db", "migrations", name))
|
||||
if err != nil {
|
||||
t.Fatalf("read migration %s: %v", name, err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// columnPresent reports whether table has a column named column.
|
||||
func columnPresent(t *testing.T, conn *sql.DB, table, column string) bool {
|
||||
t.Helper()
|
||||
var n int
|
||||
if err := conn.QueryRowContext(context.Background(),
|
||||
"SELECT count(*) FROM pragma_table_info(?) WHERE name = ?", table, column).Scan(&n); err != nil {
|
||||
t.Fatalf("pragma_table_info(%s): %v", table, err)
|
||||
}
|
||||
return n == 1
|
||||
}
|
||||
|
||||
// openLegacyDB opens an in-memory DB carrying the pre-0010 schema (chat_sessions with no
|
||||
// read_at column) plus two legacy sessions with prior history — the real fixed main
|
||||
// session "web" and a sub session — so the 0010 migration can be applied to a database
|
||||
// that really predates this feature.
|
||||
func openLegacyDB(t *testing.T) *sql.DB {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
conn, err := db.Open(ctx, ":memory:", testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("open memory db: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = conn.Close() })
|
||||
ddls := []string{
|
||||
`CREATE TABLE chat_sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
type TEXT NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL)`,
|
||||
`CREATE TABLE chat_messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
mode TEXT,
|
||||
mood TEXT,
|
||||
image TEXT,
|
||||
thinking TEXT,
|
||||
platform TEXT NOT NULL DEFAULT 'web',
|
||||
device_id TEXT NOT NULL DEFAULT '',
|
||||
sender_name TEXT NOT NULL DEFAULT '',
|
||||
adapter_id TEXT NOT NULL DEFAULT '',
|
||||
created_at INTEGER NOT NULL)`,
|
||||
`CREATE INDEX idx_chat_msgs_session_created ON chat_messages (session_id, created_at DESC)`,
|
||||
}
|
||||
for _, ddl := range ddls {
|
||||
if _, err := conn.ExecContext(ctx, ddl); err != nil {
|
||||
t.Fatalf("legacy ddl: %v", err)
|
||||
}
|
||||
}
|
||||
legacy := []struct {
|
||||
sessionID string
|
||||
sessionType string
|
||||
messageAt []int64
|
||||
}{
|
||||
{sessionID: MainSessionID, sessionType: SessionMain, messageAt: []int64{1000, 4000}},
|
||||
{sessionID: "web:legacy", sessionType: SessionSub, messageAt: []int64{1000, 2000}},
|
||||
}
|
||||
for _, l := range legacy {
|
||||
if _, err := conn.ExecContext(ctx,
|
||||
`INSERT INTO chat_sessions (id, type, title, pinned, created_at) VALUES (?, ?, '', 0, ?)`,
|
||||
l.sessionID, l.sessionType, int64(100)); err != nil {
|
||||
t.Fatalf("insert legacy session %s: %v", l.sessionID, err)
|
||||
}
|
||||
for i, ts := range l.messageAt {
|
||||
if _, err := conn.ExecContext(ctx,
|
||||
`INSERT INTO chat_messages (id, session_id, role, content, created_at) VALUES (?, ?, ?, ?, ?)`,
|
||||
fmt.Sprintf("%s-%d", l.sessionID, i), l.sessionID, RoleUser, "legacy message", ts); err != nil {
|
||||
t.Fatalf("insert legacy message: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return conn
|
||||
}
|
||||
|
||||
// TestMigration0010ReadAtBackfill exercises the real 0010 migration against a legacy
|
||||
// database: up adds the column and backfills each session's cursor to its newest message,
|
||||
// so upgrade day reports 0 unread for existing history — including the fixed main session,
|
||||
// whose existing history is the whole point of the upgrade — and the store (which
|
||||
// re-issues the canonical DDL idempotently) then works on top of it; a message that arrives
|
||||
// after the migration is unread until it is marked read; down drops the column again.
|
||||
func TestMigration0010ReadAtBackfill(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
conn := openLegacyDB(t)
|
||||
if columnPresent(t, conn, "chat_sessions", "read_at") {
|
||||
t.Fatal("legacy schema should not have read_at before the migration")
|
||||
}
|
||||
|
||||
if _, err := conn.ExecContext(ctx, migrationReadAtSQL(t, "0010_chat_session_read_at.up.sql")); err != nil {
|
||||
t.Fatalf("apply 0010 up: %v", err)
|
||||
}
|
||||
if !columnPresent(t, conn, "chat_sessions", "read_at") {
|
||||
t.Fatal("read_at should exist after the 0010 up migration")
|
||||
}
|
||||
// The backfill is each session's newest message timestamp, not a blanket 0 (which
|
||||
// would mark the whole existing history unread) and not the first message's.
|
||||
for _, tc := range []struct {
|
||||
sessionID string
|
||||
want int64
|
||||
}{
|
||||
{sessionID: MainSessionID, want: 4000},
|
||||
{sessionID: "web:legacy", want: 2000},
|
||||
} {
|
||||
var backfilled int64
|
||||
if err := conn.QueryRowContext(ctx,
|
||||
`SELECT read_at FROM chat_sessions WHERE id = ?`, tc.sessionID).Scan(&backfilled); err != nil {
|
||||
t.Fatalf("read backfilled cursor %s: %v", tc.sessionID, err)
|
||||
}
|
||||
if backfilled != tc.want {
|
||||
t.Fatalf("backfilled read_at for %s = %d, want its newest legacy message %d", tc.sessionID, backfilled, tc.want)
|
||||
}
|
||||
}
|
||||
|
||||
// The store comes up on the migrated schema and reports no unread for either
|
||||
// pre-existing session (upgrade day must not light up every row, main session
|
||||
// included); the store's INSERT OR IGNORE for the main session must not overwrite
|
||||
// the backfilled cursor.
|
||||
st, err := NewStore(ctx, conn, testLogger())
|
||||
if err != nil {
|
||||
t.Fatalf("new store on migrated db: %v", err)
|
||||
}
|
||||
for _, sessionID := range []string{MainSessionID, "web:legacy"} {
|
||||
if got := sessionOf(t, st, sessionID).UnreadCount; got != 0 {
|
||||
t.Fatalf("legacy session %s unreadCount = %d, want 0 after backfill", sessionID, got)
|
||||
}
|
||||
}
|
||||
// A message written after the migration is unread, and marking it read clears it.
|
||||
if err := st.Append(ctx, Message{SessionID: "web:legacy", Role: RoleAssistant, Content: "new", CreatedAt: 9000}); err != nil {
|
||||
t.Fatalf("append after migration: %v", err)
|
||||
}
|
||||
if got := sessionOf(t, st, "web:legacy").UnreadCount; got != 1 {
|
||||
t.Fatalf("unreadCount after post-migration message = %d, want 1", got)
|
||||
}
|
||||
if got := sessionOf(t, st, MainSessionID).UnreadCount; got != 0 {
|
||||
t.Fatalf("main unreadCount = %d, want 0 (untouched by the sub session's new message)", got)
|
||||
}
|
||||
readAt, err := st.MarkSessionRead(ctx, "web:legacy", time.UnixMilli(9000))
|
||||
if err != nil || readAt != 9000 {
|
||||
t.Fatalf("mark read = (%d, %v), want (9000, nil)", readAt, err)
|
||||
}
|
||||
if got := sessionOf(t, st, "web:legacy").UnreadCount; got != 0 {
|
||||
t.Fatalf("unreadCount after mark = %d, want 0", got)
|
||||
}
|
||||
|
||||
if _, err := conn.ExecContext(ctx, migrationReadAtSQL(t, "0010_chat_session_read_at.down.sql")); err != nil {
|
||||
t.Fatalf("apply 0010 down: %v", err)
|
||||
}
|
||||
if columnPresent(t, conn, "chat_sessions", "read_at") {
|
||||
t.Fatal("read_at should be dropped by the 0010 down migration")
|
||||
}
|
||||
}
|
||||
@@ -121,10 +121,20 @@ type SessionMeta struct {
|
||||
// A client uses it to render the conversation row without a per-session request;
|
||||
// identity and permissions never derive from it.
|
||||
LastMessage *MessageSummary
|
||||
// UnreadCount is the number of messages newer than the session's read cursor
|
||||
// (design/16 §4.2, D4), computed by ListSessions in the same query. The cursor
|
||||
// is session-scoped and single-owner: reading on any one end marks the session
|
||||
// read for every end of the account. Like LastMessage it is display-only —
|
||||
// identity and permissions never derive from it.
|
||||
UnreadCount int
|
||||
}
|
||||
|
||||
// HistoryStore abstracts the chat-history store so callers (the gateway) and tests can
|
||||
// hold an interface rather than a concrete *Store.
|
||||
// hold an interface rather than a concrete *Store. It is deliberately kept to the calls
|
||||
// the runtime-level consumers (channelbridge, backthink) actually make: the D4 read
|
||||
// cursor (MarkSessionRead) is declared on the consumer-side interface in
|
||||
// internal/services/gateway (contracts.go) instead, so a narrower store fake elsewhere
|
||||
// does not have to implement a cursor it never advances. *Store satisfies both.
|
||||
type HistoryStore interface {
|
||||
// Append persists msg, assigning an id and timestamp when the caller omitted them.
|
||||
Append(ctx context.Context, msg Message) error
|
||||
@@ -579,6 +589,7 @@ CREATE TABLE IF NOT EXISTS chat_sessions (
|
||||
type TEXT NOT NULL,
|
||||
title TEXT NOT NULL DEFAULT '',
|
||||
pinned INTEGER NOT NULL DEFAULT 0,
|
||||
read_at INTEGER NOT NULL DEFAULT 0,
|
||||
created_at INTEGER NOT NULL
|
||||
)`
|
||||
|
||||
|
||||
Reference in new issue
Block a user