mirror of
https://github.com/J3vb/OwnCord.git
synced 2026-09-03 03:50:00 +03:00
refactor(server/db): adopt sqlc as the query layer — phase 1 (D2)
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA
This commit is contained in:
+33
-47
@@ -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
|
||||
}
|
||||
|
||||
+15
-1
@@ -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
|
||||
|
||||
@@ -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 = ?
|
||||
`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
+25
-36
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user