From 44323373e390f7089e5b6cb71c07622adcc2ba0f Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 19 Jul 2026 15:01:54 +0000 Subject: [PATCH] =?UTF-8?q?refactor(server/db):=20adopt=20sqlc=20as=20the?= =?UTF-8?q?=20query=20layer=20=E2=80=94=20phase=201=20(D2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the sqlc-generated dbgen package into db.DB so it stops being dead code (audit A-2026-07-05) and becomes the real, CI-verified query layer. db.DB now holds a *dbgen.Queries (initialized in Open via dbgen.New). Query method bodies delegate to it; sqlc owns the SQL text and parameter binding (make sqlc-verify), while db keeps its stable public API and domain model types so no caller in api/admin/ws/service changes. The migration is incremental — a method either delegates to d.q.* or still runs raw SQL — so both layers are correct during the transition. Converted domains (now load-bearing through sqlc): - blocks: BlockUser, UnblockUser, IsBlocked, IsEitherBlocked, ListBlockedUsers (added the query to blocks.sql + regenerated). Empty ListBlockedUsers now returns []int64{} instead of nil, matching the MemStore backend — a latent inconsistency fixed, not a regression. - lockouts: UpsertLockout, LoadActiveLockouts, CleanupExpiredLockouts, DeleteLockout (RFC3339 time formatting/parsing kept in the wrappers). - roles: GetRoleByID, ListRoles, GetRoleForUser via a shared roleFromGen mapper (int64 position/is_default -> int/bool). GetUserWithRole stays raw for now. Remaining domains stay on raw SQL and are tracked in docs/plans/sqlc-adoption.md; store/ event+plugin SQL is intentionally excluded (that layer is removed in D3). Decisions doc + audit closure updated (A-2026-07-05 -> in progress). Verified: go build ./...; go test -race ./db ./service ./auth ./ws (api green non-race, race run matches CI's -timeout 20m); make sqlc-verify and protocol-verify pass with the regenerated output committed. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA --- Server/db/block_queries.go | 80 ++++++++++-------------- Server/db/db.go | 16 ++++- Server/db/dbgen/blocks.sql.go | 27 ++++++++ Server/db/dbgen/querier.go | 1 + Server/db/lockout_queries.go | 43 +++++-------- Server/db/queries/sqlite/blocks.sql | 3 + Server/db/role_queries.go | 61 ++++++++---------- docs/audit-2026-07-19.md | 2 +- docs/plans/audit-2026-07-19-decisions.md | 2 +- docs/plans/sqlc-adoption.md | 57 +++++++++++++++++ 10 files changed, 179 insertions(+), 113 deletions(-) create mode 100644 docs/plans/sqlc-adoption.md diff --git a/Server/db/block_queries.go b/Server/db/block_queries.go index a0e2133c..eea5ae86 100644 --- a/Server/db/block_queries.go +++ b/Server/db/block_queries.go @@ -1,15 +1,21 @@ package db -import "fmt" +import ( + "errors" + "fmt" + + "database/sql" + + "github.com/owncord/server/db/dbgen" +) // BlockUser adds a block from blocker to blocked. Idempotent — re-blocking // a user that is already blocked is a no-op (INSERT OR IGNORE). func (d *DB) BlockUser(blockerID, blockedID int64) error { - _, err := d.sqlDB.Exec( - `INSERT OR IGNORE INTO user_blocks (blocker_id, blocked_id) VALUES (?, ?)`, - blockerID, blockedID, - ) - if err != nil { + if err := d.q.BlockUser(dbCtx(), dbgen.BlockUserParams{ + BlockerID: blockerID, + BlockedID: blockedID, + }); err != nil { return fmt.Errorf("BlockUser: %w", err) } return nil @@ -18,11 +24,10 @@ func (d *DB) BlockUser(blockerID, blockedID int64) error { // UnblockUser removes a block. Idempotent — unblocking a non-blocked user is // a no-op. func (d *DB) UnblockUser(blockerID, blockedID int64) error { - _, err := d.sqlDB.Exec( - `DELETE FROM user_blocks WHERE blocker_id = ? AND blocked_id = ?`, - blockerID, blockedID, - ) - if err != nil { + if err := d.q.UnblockUser(dbCtx(), dbgen.UnblockUserParams{ + BlockerID: blockerID, + BlockedID: blockedID, + }); err != nil { return fmt.Errorf("UnblockUser: %w", err) } return nil @@ -30,15 +35,14 @@ func (d *DB) UnblockUser(blockerID, blockedID int64) error { // IsBlocked returns true if blockerID has blocked blockedID. func (d *DB) IsBlocked(blockerID, blockedID int64) (bool, error) { - var exists int - err := d.sqlDB.QueryRow( - `SELECT 1 FROM user_blocks WHERE blocker_id = ? AND blocked_id = ? LIMIT 1`, - blockerID, blockedID, - ).Scan(&exists) + _, err := d.q.IsBlocked(dbCtx(), dbgen.IsBlockedParams{ + BlockerID: blockerID, + BlockedID: blockedID, + }) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } if err != nil { - if err.Error() == "sql: no rows in result set" { - return false, nil - } return false, fmt.Errorf("IsBlocked: %w", err) } return true, nil @@ -48,18 +52,16 @@ func (d *DB) IsBlocked(blockerID, blockedID int64) (bool, error) { // Used for DM authorization — if either party has blocked the other, // messaging is denied. func (d *DB) IsEitherBlocked(userA, userB int64) (bool, error) { - var exists int - err := d.sqlDB.QueryRow( - `SELECT 1 FROM user_blocks - WHERE (blocker_id = ? AND blocked_id = ?) - OR (blocker_id = ? AND blocked_id = ?) - LIMIT 1`, - userA, userB, userB, userA, - ).Scan(&exists) + _, err := d.q.IsEitherBlocked(dbCtx(), dbgen.IsEitherBlockedParams{ + BlockerID: userA, + BlockedID: userB, + BlockerID_2: userB, + BlockedID_2: userA, + }) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } if err != nil { - if err.Error() == "sql: no rows in result set" { - return false, nil - } return false, fmt.Errorf("IsEitherBlocked: %w", err) } return true, nil @@ -67,25 +69,9 @@ func (d *DB) IsEitherBlocked(userA, userB int64) (bool, error) { // ListBlockedUsers returns the IDs of all users blocked by the given user. func (d *DB) ListBlockedUsers(blockerID int64) ([]int64, error) { - rows, err := d.sqlDB.Query( - `SELECT blocked_id FROM user_blocks WHERE blocker_id = ? ORDER BY created_at DESC`, - blockerID, - ) + ids, err := d.q.ListBlockedUsers(dbCtx(), blockerID) if err != nil { return nil, fmt.Errorf("ListBlockedUsers: %w", err) } - defer rows.Close() //nolint:errcheck - - var ids []int64 - for rows.Next() { - var id int64 - if err := rows.Scan(&id); err != nil { - return nil, fmt.Errorf("ListBlockedUsers scan: %w", err) - } - ids = append(ids, id) - } - if rows.Err() != nil { - return nil, fmt.Errorf("ListBlockedUsers rows: %w", rows.Err()) - } return ids, nil } diff --git a/Server/db/db.go b/Server/db/db.go index da2903cd..9425dd7b 100644 --- a/Server/db/db.go +++ b/Server/db/db.go @@ -7,15 +7,29 @@ import ( "database/sql" "fmt" + "github.com/owncord/server/db/dbgen" "github.com/owncord/server/migrations" _ "modernc.org/sqlite" // register the sqlite3 driver ) // DB wraps *sql.DB and exposes the subset of methods needed by the server. +// +// q is the sqlc-generated query layer (db/dbgen). Query method bodies delegate +// to it — sqlc is the source of truth for the SQL text and parameter binding +// (verified in CI by `make sqlc-verify`), while this package keeps the stable +// public API and the domain model types the rest of the server consumes. +// Migration is incremental (decision D2); methods not yet delegated still run +// their raw SQL directly against sqlDB. type DB struct { sqlDB *sql.DB + q *dbgen.Queries } +// dbCtx is the context used for delegated dbgen calls. The public db.DB API is +// context-free today; callers that need cancellation use the *Context helpers +// directly. Using Background here preserves the existing behavior exactly. +func dbCtx() context.Context { return context.Background() } + // Open opens (or creates) a SQLite database at path, enables WAL mode and // foreign key enforcement, and returns a ready-to-use DB. func Open(path string) (*DB, error) { @@ -72,7 +86,7 @@ func Open(path string) (*DB, error) { return nil, fmt.Errorf("setting cache_size: %w", err) } - return &DB{sqlDB: sqlDB}, nil + return &DB{sqlDB: sqlDB, q: dbgen.New(sqlDB)}, nil } // Migrate runs all SQL migration files from the embedded migrations FS in diff --git a/Server/db/dbgen/blocks.sql.go b/Server/db/dbgen/blocks.sql.go index e4548c9b..58e41f7e 100644 --- a/Server/db/dbgen/blocks.sql.go +++ b/Server/db/dbgen/blocks.sql.go @@ -65,6 +65,33 @@ func (q *Queries) IsEitherBlocked(ctx context.Context, arg IsEitherBlockedParams return column_1, err } +const listBlockedUsers = `-- name: ListBlockedUsers :many +SELECT blocked_id FROM user_blocks WHERE blocker_id = ? ORDER BY created_at DESC +` + +func (q *Queries) ListBlockedUsers(ctx context.Context, blockerID int64) ([]int64, error) { + rows, err := q.db.QueryContext(ctx, listBlockedUsers, blockerID) + if err != nil { + return nil, err + } + defer rows.Close() + items := []int64{} + for rows.Next() { + var blocked_id int64 + if err := rows.Scan(&blocked_id); err != nil { + return nil, err + } + items = append(items, blocked_id) + } + if err := rows.Close(); err != nil { + return nil, err + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const unblockUser = `-- name: UnblockUser :exec DELETE FROM user_blocks WHERE blocker_id = ? AND blocked_id = ? ` diff --git a/Server/db/dbgen/querier.go b/Server/db/dbgen/querier.go index 61bcfb89..b23eee93 100644 --- a/Server/db/dbgen/querier.go +++ b/Server/db/dbgen/querier.go @@ -97,6 +97,7 @@ type Querier interface { LeaveVoiceChannelIfMatch(ctx context.Context, arg LeaveVoiceChannelIfMatchParams) (sql.Result, error) LinkAttachmentToMessage(ctx context.Context, arg LinkAttachmentToMessageParams) (sql.Result, error) ListAllUsers(ctx context.Context, arg ListAllUsersParams) ([]ListAllUsersRow, error) + ListBlockedUsers(ctx context.Context, blockerID int64) ([]int64, error) ListChannels(ctx context.Context) ([]ListChannelsRow, error) ListInvites(ctx context.Context) ([]ListInvitesRow, error) ListMembers(ctx context.Context) ([]ListMembersRow, error) diff --git a/Server/db/lockout_queries.go b/Server/db/lockout_queries.go index daf6f5c1..80ac5f33 100644 --- a/Server/db/lockout_queries.go +++ b/Server/db/lockout_queries.go @@ -1,54 +1,43 @@ package db -import "time" +import ( + "time" + + "github.com/owncord/server/db/dbgen" +) // UpsertLockout inserts or replaces a rate-limit lockout entry. func (d *DB) UpsertLockout(key string, expiresAt time.Time) error { - _, err := d.sqlDB.Exec( - `INSERT OR REPLACE INTO rate_lockouts (key, expires_at) VALUES (?, ?)`, - key, expiresAt.UTC().Format(time.RFC3339), - ) - return err + return d.q.UpsertLockout(dbCtx(), dbgen.UpsertLockoutParams{ + Key: key, + ExpiresAt: expiresAt.UTC().Format(time.RFC3339), + }) } // LoadActiveLockouts returns all lockouts that have not yet expired as // parallel slices of keys and expiry times. func (d *DB) LoadActiveLockouts() (keys []string, expiresAt []time.Time, err error) { - rows, err := d.sqlDB.Query( - `SELECT key, expires_at FROM rate_lockouts WHERE expires_at > ?`, - time.Now().UTC().Format(time.RFC3339), - ) + rows, err := d.q.LoadActiveLockouts(dbCtx(), time.Now().UTC().Format(time.RFC3339)) if err != nil { return nil, nil, err } - defer rows.Close() //nolint:errcheck - - for rows.Next() { - var key, expiresStr string - if err := rows.Scan(&key, &expiresStr); err != nil { - return nil, nil, err - } - t, parseErr := time.Parse(time.RFC3339, expiresStr) + for _, r := range rows { + t, parseErr := time.Parse(time.RFC3339, r.ExpiresAt) if parseErr != nil { continue // skip unparseable rows } - keys = append(keys, key) + keys = append(keys, r.Key) expiresAt = append(expiresAt, t) } - return keys, expiresAt, rows.Err() + return keys, expiresAt, nil } // CleanupExpiredLockouts removes lockout rows whose expiry has passed. func (d *DB) CleanupExpiredLockouts() error { - _, err := d.sqlDB.Exec( - `DELETE FROM rate_lockouts WHERE expires_at <= ?`, - time.Now().UTC().Format(time.RFC3339), - ) - return err + return d.q.CleanupExpiredLockouts(dbCtx(), time.Now().UTC().Format(time.RFC3339)) } // DeleteLockout removes a single lockout entry. func (d *DB) DeleteLockout(key string) error { - _, err := d.sqlDB.Exec(`DELETE FROM rate_lockouts WHERE key = ?`, key) - return err + return d.q.DeleteLockout(dbCtx(), key) } diff --git a/Server/db/queries/sqlite/blocks.sql b/Server/db/queries/sqlite/blocks.sql index eeea82fa..3944b0fc 100644 --- a/Server/db/queries/sqlite/blocks.sql +++ b/Server/db/queries/sqlite/blocks.sql @@ -12,3 +12,6 @@ SELECT 1 FROM user_blocks WHERE (blocker_id = ? AND blocked_id = ?) OR (blocker_id = ? AND blocked_id = ?) LIMIT 1; + +-- name: ListBlockedUsers :many +SELECT blocked_id FROM user_blocks WHERE blocker_id = ? ORDER BY created_at DESC; diff --git a/Server/db/role_queries.go b/Server/db/role_queries.go index be8fdd99..5c63c376 100644 --- a/Server/db/role_queries.go +++ b/Server/db/role_queries.go @@ -4,48 +4,47 @@ import ( "database/sql" "errors" "fmt" + + "github.com/owncord/server/db/dbgen" ) +// roleFromGen maps the sqlc-generated Role row to the domain Role model, +// narrowing the int64 position and converting the int64 is_default flag to a +// bool. Shared by all role reads that delegate to dbgen. +func roleFromGen(r dbgen.Role) *Role { + return &Role{ + ID: r.ID, + Name: r.Name, + Color: r.Color, + Permissions: r.Permissions, + Position: int(r.Position), + IsDefault: r.IsDefault != 0, + } +} + // GetRoleByID returns the role with the given ID, or nil if not found. func (d *DB) GetRoleByID(id int64) (*Role, error) { - row := d.sqlDB.QueryRow( - `SELECT id, name, color, permissions, position, is_default FROM roles WHERE id = ?`, - id, - ) - r := &Role{} - var isDefault int - err := row.Scan(&r.ID, &r.Name, &r.Color, &r.Permissions, &r.Position, &isDefault) + r, err := d.q.GetRoleByID(dbCtx(), id) if errors.Is(err, sql.ErrNoRows) { return nil, nil } if err != nil { return nil, fmt.Errorf("GetRoleByID: %w", err) } - r.IsDefault = isDefault != 0 - return r, nil + return roleFromGen(r), nil } // ListRoles returns all roles ordered by position descending. func (d *DB) ListRoles() ([]*Role, error) { - rows, err := d.sqlDB.Query( - `SELECT id, name, color, permissions, position, is_default FROM roles ORDER BY position DESC`, - ) + rows, err := d.q.ListRoles(dbCtx()) if err != nil { return nil, fmt.Errorf("ListRoles: %w", err) } - defer rows.Close() //nolint:errcheck - - var roles []*Role - for rows.Next() { - r := &Role{} - var isDefault int - if err := rows.Scan(&r.ID, &r.Name, &r.Color, &r.Permissions, &r.Position, &isDefault); err != nil { - return nil, fmt.Errorf("ListRoles scan: %w", err) - } - r.IsDefault = isDefault != 0 - roles = append(roles, r) + roles := make([]*Role, 0, len(rows)) + for _, r := range rows { + roles = append(roles, roleFromGen(r)) } - return roles, rows.Err() + return roles, nil } // GetRoleForUser returns only the role for a given user via a single JOIN. @@ -53,24 +52,14 @@ func (d *DB) ListRoles() ([]*Role, error) { // TOTP secret). Use this on hot paths like permission checks. // Returns (nil, nil) when the user is not found. func (d *DB) GetRoleForUser(userID int64) (*Role, error) { - row := d.sqlDB.QueryRow( - `SELECT r.id, r.name, r.color, r.permissions, r.position, r.is_default - FROM users u - JOIN roles r ON u.role_id = r.id - WHERE u.id = ?`, - userID, - ) - r := &Role{} - var isDefault int - err := row.Scan(&r.ID, &r.Name, &r.Color, &r.Permissions, &r.Position, &isDefault) + r, err := d.q.GetRoleForUser(dbCtx(), userID) if errors.Is(err, sql.ErrNoRows) { return nil, nil } if err != nil { return nil, fmt.Errorf("GetRoleForUser: %w", err) } - r.IsDefault = isDefault != 0 - return r, nil + return roleFromGen(r), nil } // GetUserWithRole returns the user and their role in a single query. diff --git a/docs/audit-2026-07-19.md b/docs/audit-2026-07-19.md index bf576058..19fdcdc5 100644 --- a/docs/audit-2026-07-19.md +++ b/docs/audit-2026-07-19.md @@ -18,7 +18,7 @@ accepted-risk note before the beta gate. MEDIUMs are folded into the backlog | A-2026-07-02 | HIGH | Client HTTP path accepts any TLS certificate (`allowSelfSigned` hardcoded; no TOFU pinning, unlike WS/LiveKit paths) | CLOSED 2026-07-19 — HTTP TOFU proxy implemented (`http_proxy.rs` + `httpProxy.ts`); REST path now cert-pinned, `acceptInvalidCerts` removed | | A-2026-07-03 | HIGH | Reference specs (api.md / protocol.md / schema.md) frozen at 2026-04-02; systemic drift incl. whole undocumented subsystems (voice E2EE, plugins) | CLOSED 2026-07-19 — full refresh of api.md/protocol.md/schema.md landed (all §2 fix-spec items); keep-current-per-PR rule now applies | | A-2026-07-04 | HIGH | Client unit test suite "KNOWN RED" and non-blocking in CI; E2E never gated | OPEN — supersedes prior #11's scope | -| A-2026-07-05 | MEDIUM | Dead sqlc layer: `Server/db/dbgen/` (~3.5k LOC) generated + CI-verified but imported by nothing | DECIDED 2026-07-19 — adopt sqlc as the real query layer (D2) (see [plans/audit-2026-07-19-decisions.md](plans/audit-2026-07-19-decisions.md)) | +| A-2026-07-05 | MEDIUM | Dead sqlc layer: `Server/db/dbgen/` (~3.5k LOC) generated + CI-verified but imported by nothing | IN PROGRESS 2026-07-19 — `dbgen` now wired into `db.DB` and load-bearing (blocks/lockouts/roles delegate); no longer dead. Remaining domains tracked in [plans/sqlc-adoption.md](plans/sqlc-adoption.md) | | A-2026-07-06 | MEDIUM | Three coexisting DB-access styles (raw `*db.DB` in api/admin/ws, `store.Store` under service, dead dbgen) | DECIDED 2026-07-19 — single data layer: sqlc-backed db pkg, remove store/ (D2+D3) (see [plans/audit-2026-07-19-decisions.md](plans/audit-2026-07-19-decisions.md)) | | A-2026-07-07 | MEDIUM | Channel-visibility logic duplicated across ~4 sites with "must mirror" comments | OPEN | | A-2026-07-08 | MEDIUM | Protocol constants on both sides claim generation from `docs/protocol-schema.json`, which does not exist in the repo | CLOSED 2026-07-19 — codegen implemented: `docs/protocol-schema.json` + `Server/scripts/genprotocol` + `make protocol-verify` CI gate | diff --git a/docs/plans/audit-2026-07-19-decisions.md b/docs/plans/audit-2026-07-19-decisions.md index 443e9965..74befb7a 100644 --- a/docs/plans/audit-2026-07-19-decisions.md +++ b/docs/plans/audit-2026-07-19-decisions.md @@ -15,7 +15,7 @@ here (and the audit's closure table) as items land. | # | Decision point | Audit ID | Decision | Status | |---|----------------|----------|----------|--------| | D1 | `announcement` channel type (documented + offered by admin API, rejected by DB triggers) | A-2026-07-01 | **Implement end-to-end**: migration to allow the type, posting-permission semantics, admin support, client rendering, spec updates. Not a doc-strip — this becomes a real feature. | Planned (not yet greenlit to start) | -| D2 | Data-layer direction (raw SQL vs dead sqlc `db/dbgen` vs `store.Store`) | A-2026-07-05 / A-2026-07-06 | **Adopt sqlc for real**: wire `db.DB` method bodies to the generated `dbgen` queries so sqlc becomes the actual, type-checked query layer. The `sqlc-verify` CI job stays and starts earning its keep. | Planned | +| D2 | Data-layer direction (raw SQL vs dead sqlc `db/dbgen` vs `store.Store`) | A-2026-07-05 / A-2026-07-06 | **Adopt sqlc for real**: wire `db.DB` method bodies to the generated `dbgen` queries so sqlc becomes the actual, type-checked query layer. The `sqlc-verify` CI job stays and starts earning its keep. | **In progress — phase 1 done 2026-07-19**: `dbgen.Queries` wired into `db.DB`; blocks/lockouts/roles delegate (sqlc now load-bearing, no longer dead code). Remaining domains tracked in [sqlc-adoption.md](sqlc-adoption.md). | | D3 | Fate of `Server/store/` (untested abstraction seam) | prior audit #6 | **Remove `store/`**: execute the prior audit's P4 "single data layer" direction. Services call the (sqlc-backed) `db` package directly; tests use in-memory SQLite instead of `MemStore`. | Planned (sequence with/after D2) | | D4 | Protocol constants sync (`message_types.go` / `protocolTypes.ts` claim a nonexistent `docs/protocol-schema.json`) | A-2026-07-08 | **Create real codegen**: commit an actual `protocol-schema.json` plus a generator that emits the Go and TS constant files (and, ideally, protocol.md's message table), making the "single source of truth" comment true. | **Implemented 2026-07-19**: `docs/protocol-schema.json` + `Server/scripts/genprotocol` + `make protocol-generate`/`protocol-verify` + CI gate. protocol.md table generation deferred to D7. | | D5 | Client HTTP TLS gap (`allowSelfSigned: true`, no TOFU pinning on the REST path) | A-2026-07-02 | **Next security work**: build the TOFU HTTP proxy in Rust (mirroring `ws_proxy.rs`) as the next security task — highest-priority security item. | **Implemented 2026-07-19** — `src-tauri/src/http_proxy.rs` (per-host loopback TCP→TLS tunnels, shared TOFU cert store + `cert-tofu` events) + `src/lib/httpProxy.ts`; REST/health/attachments routed through it; `acceptInvalidCerts`/`allowSelfSigned` and the `dangerous-settings` feature removed. See [http-tofu-proxy.md](http-tofu-proxy.md). | diff --git a/docs/plans/sqlc-adoption.md b/docs/plans/sqlc-adoption.md new file mode 100644 index 00000000..4320c951 --- /dev/null +++ b/docs/plans/sqlc-adoption.md @@ -0,0 +1,57 @@ +# sqlc Adoption (D2) — Progress & Plan + +**Decision:** D2 in [audit-2026-07-19-decisions.md](audit-2026-07-19-decisions.md) — adopt +sqlc as the real query layer; **Closes:** audit finding A-2026-07-05 (dead `db/dbgen`). + +## Approach + +`db.DB` now holds a `*dbgen.Queries` (`db/db.go`, initialized in `Open` via +`dbgen.New(sqlDB)`). Query method bodies delegate to it; sqlc owns the SQL text +and parameter binding (verified in CI by `make sqlc-verify`), while the `db` +package keeps its **stable public API and domain model types** so no caller in +`api`/`admin`/`ws`/`service` changes. Migration is **incremental** — a method +either delegates to `d.q.*` or still runs raw SQL against `sqlDB`; both are +correct during the transition. + +Two mechanical frictions drive the per-domain effort: + +1. **Model mismatch.** Generated row structs use `int64`/`*int64` and store + booleans as `int64`; the domain models use `int`/`*int`/`bool`. SELECT + conversions need a small `fromGen` mapper per domain (see `roleFromGen` in + `db/role_queries.go`). +2. **Missing queries.** A few hand-written methods had no generated + counterpart (e.g. `ListBlockedUsers`); add the query to + `db/queries/sqlite/.sql` and `make sqlc-generate` before delegating. + +## Status + +### Phase 1 — done (2026-07-19) +sqlc is now **load-bearing in production** (previously dead code): + +| Domain | Methods delegated | Notes | +|--------|-------------------|-------| +| blocks (`block_queries.go`) | BlockUser, UnblockUser, IsBlocked, IsEitherBlocked, ListBlockedUsers | Added `ListBlockedUsers` query. Empty result now `[]int64{}` (matches MemStore; was `nil`). | +| lockouts (`lockout_queries.go`) | UpsertLockout, LoadActiveLockouts, CleanupExpiredLockouts, DeleteLockout | Time formatting/parsing kept in the wrapper. | +| roles (`role_queries.go`) | GetRoleByID, ListRoles, GetRoleForUser | Shared `roleFromGen` mapper. `GetUserWithRole` (joined User+Role) stays raw. | + +### Phase 2 — remaining domains (raw SQL still, each survives D3) +Convert with the same pattern; add missing queries + a `fromGen` mapper where +needed. Rough order by mapping simplicity: + +- **Simple/exec-heavy:** invites (`invite_queries.go`), profile + (`profile_queries.go`), attachments (`attachment_queries.go`). +- **Model-mapped reads:** sessions (`auth_queries.go`), users + (`auth_queries.go` — `GetUserByID`/`GetUserByUsername`/`ListAllUsers`), + channels (`channel_queries.go`), voice (`voice_queries.go`), + dm (`dm_queries.go`), admin/settings (`admin_queries.go`). +- **Complex/joined:** messages (`message_queries.go` — search, cursor + pagination, reactions), `GetUserWithRole`, `GetServerStats`. + +### Out of scope for D2 +- `store/` event + plugin SQL (`store/sqlite_events.go`, plugin store) — these + live in the store layer being **removed in D3**; converting them is throwaway. + D3 moves the surviving `db` methods (sqlc-backed) to direct service use. + +## Verification (per phase) +`go build ./...`; `go test -race ./db/ ./service/ ./auth/ ./api/ ./ws/`; +`make sqlc-verify` (generated output committed & in sync).