Make boolean column reads and writes work on both storage tiers
ci / vet-and-test (pull_request) Successful in 46s

Why:
The dev runtime switched to the PostgreSQL tier and every conversation
endpoint started returning 500. Migrations define
conversations.pinned/archived/allow_cross_read/cross_private and
users.must_change_password/is_admin as BOOLEAN on PostgreSQL but
INTEGER on SQLite, while the store layer compared them with 0/1
literals, scanned them into int, and wrote them via boolInt().
Under pgx these fail with 42883 (operator does not exist: boolean =
integer), bool encode errors, or 42804 (column is of type boolean but
expression is of type integer).

Evidence:
- parent-repo main 4fd03ee with AGENT_PG_TEST_DSN:
  TestConversationFlagsAndTaskList and TestAdminAccountsAndSessions
  fail with the errors above; live GET /api/conversations returned
  "operator does not exist: boolean = integer (SQLSTATE 42883)".
- this branch: full ./internal/store suite green on the PostgreSQL
  tier (AGENT_PG_TEST_DSN against a scratch database), pgbus green,
  make test-backend green on the SQLite tier.
- scan into *bool and Go bool parameters are cross-dialect: pgx encodes
  bool natively; database/sql converts bool<->0/1 for SQLite.
- pre-existing and out of scope: ./internal/runmgr PG integration tests
  fail on main too (gin_trgm_ops invisible inside the search_path
  isolation schema, 42704).

Impact:
Conversations list/detail/flags and user auth-state reads/writes now
work identically on both storage tiers; SQLite behavior is unchanged.

Refs: Dev task .task/fix-pg-boolean-columns.md
This commit is contained in:
2026-10-04 14:51:48 +00:00
parent fd17011047
commit 685bd0d171
2 changed files with 20 additions and 31 deletions
+8 -13
View File
@@ -226,15 +226,15 @@ func (s *Store) CreateAccount(owner, name, passwordHash string, isAdmin bool) er
if err := s.EnsureUser(owner, name); err != nil {
return err
}
_, err := s.db.Exec(s.q(`UPDATE users SET name=?, password_hash=?, password_set_at=?, must_change_password=1, is_admin=? WHERE owner=?`),
name, passwordHash, nowISO(), boolInt(isAdmin), owner)
_, err := s.db.Exec(s.q(`UPDATE users SET name=?, password_hash=?, password_set_at=?, must_change_password=?, is_admin=? WHERE owner=?`),
name, passwordHash, nowISO(), true, isAdmin, owner)
return err
}
// SetUserPassword 设置/重置密码:must_change=1(首登强制改密)。
func (s *Store) SetUserPassword(owner, passwordHash string) error {
res, err := s.db.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=1 WHERE owner=?`),
passwordHash, nowISO(), owner)
res, err := s.db.Exec(s.q(`UPDATE users SET password_hash=?, password_set_at=?, must_change_password=? WHERE owner=?`),
passwordHash, nowISO(), true, owner)
if err != nil {
return err
}
@@ -246,7 +246,7 @@ func (s *Store) SetUserPassword(owner, passwordHash string) error {
// ClearMustChangePassword 改密完成后清标志(改密者会话保留,其余吊销由调用方处理)。
func (s *Store) ClearMustChangePassword(owner string) error {
_, err := s.db.Exec(s.q(`UPDATE users SET must_change_password=0 WHERE owner=?`), owner)
_, err := s.db.Exec(s.q(`UPDATE users SET must_change_password=? WHERE owner=?`), false, owner)
return err
}
@@ -263,17 +263,15 @@ type UserAuthState struct {
func (s *Store) GetUserAuthState(owner string) (UserAuthState, error) {
var st UserAuthState
var hash, ca string
var mustChange, isAdmin int
// must_change_password/is_admin 列 SQLite 为 INTEGER、PG 为 BOOLEAN:直接扫进 bool。
err := s.db.QueryRow(s.q(`SELECT owner,COALESCE(name,''),password_hash,must_change_password,is_admin,created_at FROM users WHERE owner=?`), owner).
Scan(&st.Owner, &st.Name, &hash, &mustChange, &isAdmin, &ca)
Scan(&st.Owner, &st.Name, &hash, &st.MustChangePassword, &st.IsAdmin, &ca)
if errors.Is(err, sql.ErrNoRows) {
return st, ErrNotFound
}
if err != nil {
return st, err
}
st.MustChangePassword = mustChange == 1
st.IsAdmin = isAdmin == 1
st.HasPassword = hash != ""
return st, nil
}
@@ -350,12 +348,9 @@ func (s *Store) ListUsersAdmin() ([]AdminUser, error) {
for rows.Next() {
var u AdminUser
var hash, psa, ca string
var mustChange, isAdmin int
if err := rows.Scan(&u.Owner, &u.Name, &hash, &mustChange, &isAdmin, &psa, &ca); err != nil {
if err := rows.Scan(&u.Owner, &u.Name, &hash, &u.MustChangePassword, &u.IsAdmin, &psa, &ca); err != nil {
return nil, err
}
u.MustChangePassword = mustChange == 1
u.IsAdmin = isAdmin == 1
u.HasPassword = hash != ""
if psa != "" {
t, _ := time.Parse(time.RFC3339Nano, psa)
+12 -18
View File
@@ -158,11 +158,14 @@ func (s *Store) ListConversations(owner, projectID string) ([]core.Conversation,
// ListTasks 跨项目任务列表(方案 A 侧栏数据源):默认仅未归档;includeArchived 含归档。
func (s *Store) ListTasks(owner string, includeArchived bool) ([]core.Conversation, error) {
q := `SELECT id,project_id,title,profile_id,pinned,archived,allow_cross_read,cross_private,permission_mode,created_at,updated_at FROM conversations WHERE owner=?`
args := []any{owner}
if !includeArchived {
q += ` AND archived=0`
// archived 列 SQLite 为 INTEGER、PG 为 BOOLEAN:走参数而非 0/1 字面量,两档通用。
q += ` AND archived=?`
args = append(args, false)
}
q += ` ORDER BY pinned DESC, updated_at DESC`
rows, err := s.db.Query(s.q(q), owner)
rows, err := s.db.Query(s.q(q), args...)
if err != nil {
return nil, err
}
@@ -181,14 +184,10 @@ func (s *Store) ListTasks(owner string, includeArchived bool) ([]core.Conversati
func scanConversation(rows interface{ Scan(dest ...any) error }, c *core.Conversation) error {
var ca, ua string
var pinned, archived, allowCrossRead, crossPrivate int
if err := rows.Scan(&c.ID, &c.ProjectID, &c.Title, &c.ProfileID, &pinned, &archived, &allowCrossRead, &crossPrivate, &c.PermissionMode, &ca, &ua); err != nil {
// pinned/archived 等列为 SQLite INTEGER、PG BOOLEAN;直接扫进 bool,两档驱动都能转换。
if err := rows.Scan(&c.ID, &c.ProjectID, &c.Title, &c.ProfileID, &c.Pinned, &c.Archived, &c.AllowCrossRead, &c.CrossPrivate, &c.PermissionMode, &ca, &ua); err != nil {
return err
}
c.Pinned = pinned == 1
c.Archived = archived == 1
c.AllowCrossRead = allowCrossRead == 1
c.CrossPrivate = crossPrivate == 1
c.CreatedAt, _ = time.Parse(time.RFC3339Nano, ca)
c.UpdatedAt, _ = time.Parse(time.RFC3339Nano, ua)
return nil
@@ -197,9 +196,8 @@ func scanConversation(rows interface{ Scan(dest ...any) error }, c *core.Convers
func (s *Store) GetConversation(owner, id string) (core.Conversation, error) {
var c core.Conversation
var ca, ua string
var pinned, archived, allowCrossRead, crossPrivate int
err := s.db.QueryRow(s.q(`SELECT id,project_id,title,profile_id,pinned,archived,allow_cross_read,cross_private,permission_mode,created_at,updated_at FROM conversations WHERE id=? AND owner=?`), id, owner).
Scan(&c.ID, &c.ProjectID, &c.Title, &c.ProfileID, &pinned, &archived, &allowCrossRead, &crossPrivate, &c.PermissionMode, &ca, &ua)
Scan(&c.ID, &c.ProjectID, &c.Title, &c.ProfileID, &c.Pinned, &c.Archived, &c.AllowCrossRead, &c.CrossPrivate, &c.PermissionMode, &ca, &ua)
if errors.Is(err, sql.ErrNoRows) {
return c, ErrNotFound
}
@@ -207,10 +205,6 @@ func (s *Store) GetConversation(owner, id string) (core.Conversation, error) {
return c, err
}
c.Owner = owner
c.Pinned = pinned == 1
c.Archived = archived == 1
c.AllowCrossRead = allowCrossRead == 1
c.CrossPrivate = crossPrivate == 1
c.CreatedAt, _ = time.Parse(time.RFC3339Nano, ca)
c.UpdatedAt, _ = time.Parse(time.RFC3339Nano, ua)
return c, nil
@@ -247,19 +241,19 @@ func (s *Store) SetConversationFlags(owner, id string, pinned, archived, allowCr
var args []any
if pinned != nil {
sets = append(sets, "pinned=?")
args = append(args, boolInt(*pinned))
args = append(args, *pinned)
}
if archived != nil {
sets = append(sets, "archived=?")
args = append(args, boolInt(*archived))
args = append(args, *archived)
}
if allowCrossRead != nil {
sets = append(sets, "allow_cross_read=?")
args = append(args, boolInt(*allowCrossRead))
args = append(args, *allowCrossRead)
}
if crossPrivate != nil {
sets = append(sets, "cross_private=?")
args = append(args, boolInt(*crossPrivate))
args = append(args, *crossPrivate)
}
if len(sets) == 0 {
return nil