diff --git a/Server/Makefile b/Server/Makefile index ff0253a6..0b408d4b 100644 --- a/Server/Makefile +++ b/Server/Makefile @@ -1,8 +1,9 @@ # OwnCord Server — developer convenience targets # -# sqlc-generate Regenerate type-safe Go from db/queries/sqlite/*.sql -# sqlc-verify Fail if committed dbgen output is stale (used by CI) -# sqlc-install Install the pinned sqlc version into $GOBIN +# sqlc-generate Regenerate type-safe Go for both the sqlite and postgres +# engines defined in sqlc.yaml (db/dbgen + db/pgdbgen). +# sqlc-verify Fail if either committed dbgen output is stale (used by CI). +# sqlc-install Install the pinned sqlc version into $GOBIN. SQLC_VERSION := $(shell cat sqlc.version) @@ -16,7 +17,7 @@ sqlc-generate: sqlc-verify: sqlc generate - @git diff --exit-code db/dbgen || ( \ - echo "ERROR: db/dbgen is stale. Run 'make sqlc-generate' and commit the result." ; \ + @git diff --exit-code db/dbgen db/pgdbgen || ( \ + echo "ERROR: generated sqlc output is stale. Run 'make sqlc-generate' and commit the result." ; \ exit 1 ; \ ) diff --git a/Server/config/config.go b/Server/config/config.go index e947eb20..e0f7835d 100644 --- a/Server/config/config.go +++ b/Server/config/config.go @@ -55,8 +55,31 @@ type ServerConfig struct { } // DatabaseConfig holds database settings. +// +// Type selects the backend: "sqlite" (default, zero-config) or "postgres" +// (community-hub scale, requires a running PostgreSQL server). The Path field +// is only used by sqlite. The remaining fields apply to postgres only. +// +// PostgreSQL support is currently scaffolding-only: the schema, config plumbing, +// and migrations are in place, but the store query layer is gated on the +// in-progress sqlc adoption (Phase A Step 2). Setting Type to "postgres" will +// cause the server to refuse to start with a clear error pointing at the +// follow-up work — see Server/main.go. type DatabaseConfig struct { + // Type is "sqlite" or "postgres". Empty defaults to "sqlite". + Type string `koanf:"type"` + + // Path is the SQLite database file path. Only used when Type == "sqlite". Path string `koanf:"path"` + + // PostgreSQL connection settings. Only used when Type == "postgres". + Host string `koanf:"host"` + Port int `koanf:"port"` + User string `koanf:"user"` + Password string `koanf:"password"` + Name string `koanf:"name"` + SSLMode string `koanf:"sslmode"` // disable | require | verify-ca | verify-full + MaxConns int `koanf:"max_conns"` // pgxpool max connections; 0 = pgxpool default } // TLSConfig holds TLS/certificate settings. @@ -93,7 +116,12 @@ func defaults() Config { }, }, Database: DatabaseConfig{ - Path: "data/chatserver.db", + Type: "sqlite", + Path: "data/chatserver.db", + Host: "localhost", + Port: 5432, + Name: "owncord", + SSLMode: "disable", }, TLS: TLSConfig{ Mode: "self_signed", @@ -129,7 +157,16 @@ server: # - "192.168.0.0/16" database: + type: "sqlite" # "sqlite" (default, zero-config) or "postgres" path: "data/chatserver.db" + # PostgreSQL settings (only used when type: "postgres"): + # host: "localhost" + # port: 5432 + # user: "owncord" + # password: "" + # name: "owncord" + # sslmode: "disable" # disable | require | verify-ca | verify-full + # max_conns: 0 # pgxpool connection cap (0 = pgx default) tls: mode: "self_signed" # self_signed, acme, manual, off diff --git a/Server/db/queries/postgres/admin.sql b/Server/db/queries/postgres/admin.sql new file mode 100644 index 00000000..614ed13e --- /dev/null +++ b/Server/db/queries/postgres/admin.sql @@ -0,0 +1,55 @@ +-- PostgreSQL variants of the sqlite admin queries. + +-- name: UserCount :one +SELECT COUNT(*) FROM users; + +-- name: CountActiveMessages :one +SELECT COUNT(*) FROM messages WHERE deleted = FALSE; + +-- name: CountChannels :one +SELECT COUNT(*) FROM channels; + +-- name: CountActiveInvites :one +SELECT COUNT(*) FROM invites WHERE revoked = FALSE; + +-- name: ListAllUsers :many +SELECT u.id, u.username, u.avatar, u.role_id, + u.status, u.created_at, u.last_seen, u.banned, u.ban_reason, u.ban_expires, + COALESCE(r.name, '') AS role_name +FROM users u +LEFT JOIN roles r ON r.id = u.role_id +ORDER BY u.id ASC +LIMIT $1 OFFSET $2; + +-- name: UpdateUserRole :exec +UPDATE users SET role_id = $1 WHERE id = $2; + +-- name: ForceLogoutUser :exec +DELETE FROM sessions WHERE user_id = $1; + +-- name: GetUserSessions :many +SELECT id, user_id, token, device, ip_address, created_at, last_used, expires_at +FROM sessions WHERE user_id = $1 +ORDER BY created_at DESC; + +-- name: LogAudit :exec +INSERT INTO audit_log (actor_id, action, target_type, target_id, detail) +VALUES ($1, $2, $3, $4, $5); + +-- name: GetAuditLog :many +SELECT a.id, a.actor_id, COALESCE(u.username, '') AS actor_name, a.action, + a.target_type, a.target_id, a.detail, a.created_at +FROM audit_log a +LEFT JOIN users u ON u.id = a.actor_id +ORDER BY a.id DESC +LIMIT $1 OFFSET $2; + +-- name: GetSetting :one +SELECT value FROM settings WHERE key = $1; + +-- name: SetSetting :exec +INSERT INTO settings (key, value) VALUES ($1, $2) +ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value; + +-- name: GetAllSettings :many +SELECT key, value FROM settings; diff --git a/Server/db/queries/postgres/attachments.sql b/Server/db/queries/postgres/attachments.sql new file mode 100644 index 00000000..cf3e0f06 --- /dev/null +++ b/Server/db/queries/postgres/attachments.sql @@ -0,0 +1,27 @@ +-- PostgreSQL variants of the sqlite attachments queries. + +-- name: CreateAttachment :exec +INSERT INTO attachments (id, uploader_id, filename, stored_as, mime_type, size, width, height) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8); + +-- name: GetAttachmentByID :one +SELECT id, message_id, filename, stored_as, mime_type, size, uploaded_at, uploader_id +FROM attachments WHERE id = $1; + +-- name: GetAttachmentWithChannel :one +SELECT a.id, a.message_id, a.filename, a.stored_as, a.mime_type, a.size, + a.uploaded_at, a.uploader_id, m.channel_id, c.type +FROM attachments a +LEFT JOIN messages m ON m.id = a.message_id +LEFT JOIN channels c ON c.id = m.channel_id +WHERE a.id = $1; + +-- name: LinkAttachmentToMessage :execrows +UPDATE attachments SET message_id = $1 WHERE id = $2 AND message_id IS NULL; + +-- Postgres timestamptz comparison — the caller passes a wall-clock time. +-- name: DeleteOrphanedAttachments :many +DELETE FROM attachments WHERE message_id IS NULL AND uploaded_at < $1 RETURNING stored_as; + +-- name: DeleteAttachment :exec +DELETE FROM attachments WHERE id = $1; diff --git a/Server/db/queries/postgres/blocks.sql b/Server/db/queries/postgres/blocks.sql new file mode 100644 index 00000000..23dda418 --- /dev/null +++ b/Server/db/queries/postgres/blocks.sql @@ -0,0 +1,18 @@ +-- PostgreSQL variants of the sqlite user block queries. +-- `INSERT OR IGNORE` becomes `INSERT ... ON CONFLICT DO NOTHING`. + +-- name: BlockUser :exec +INSERT INTO user_blocks (blocker_id, blocked_id) VALUES ($1, $2) +ON CONFLICT (blocker_id, blocked_id) DO NOTHING; + +-- name: UnblockUser :exec +DELETE FROM user_blocks WHERE blocker_id = $1 AND blocked_id = $2; + +-- name: IsBlocked :one +SELECT 1 FROM user_blocks WHERE blocker_id = $1 AND blocked_id = $2 LIMIT 1; + +-- name: IsEitherBlocked :one +SELECT 1 FROM user_blocks +WHERE (blocker_id = $1 AND blocked_id = $2) + OR (blocker_id = $3 AND blocked_id = $4) +LIMIT 1; diff --git a/Server/db/queries/postgres/channels.sql b/Server/db/queries/postgres/channels.sql new file mode 100644 index 00000000..0f3c2b6a --- /dev/null +++ b/Server/db/queries/postgres/channels.sql @@ -0,0 +1,69 @@ +-- PostgreSQL variants of the sqlite channels queries. + +-- name: ListChannels :many +SELECT id, name, type, COALESCE(category, '') AS category, COALESCE(topic, '') AS topic, + position, slow_mode, archived, created_at, + COALESCE(voice_max_users, 0) AS voice_max_users, + voice_quality, + mixing_threshold, + COALESCE(voice_max_video, 0) AS voice_max_video +FROM channels ORDER BY position ASC, id ASC; + +-- name: GetChannel :one +SELECT id, name, type, COALESCE(category, '') AS category, COALESCE(topic, '') AS topic, + position, slow_mode, archived, created_at, + COALESCE(voice_max_users, 0) AS voice_max_users, + voice_quality, + mixing_threshold, + COALESCE(voice_max_video, 0) AS voice_max_video +FROM channels WHERE id = $1; + +-- name: CreateChannel :one +INSERT INTO channels (name, type, category, topic, position) +VALUES ($1, $2, $3, $4, $5) +RETURNING id; + +-- name: UpdateChannel :exec +UPDATE channels SET name = $1, topic = $2, slow_mode = $3 WHERE id = $4; + +-- name: SetChannelSlowMode :exec +UPDATE channels SET slow_mode = $1 WHERE id = $2; + +-- name: SetChannelVoiceMaxUsers :exec +UPDATE channels SET voice_max_users = $1 WHERE id = $2; + +-- name: SetChannelVoiceMaxVideo :exec +UPDATE channels SET voice_max_video = $1 WHERE id = $2; + +-- name: SetChannelVoiceQuality :exec +UPDATE channels SET voice_quality = $1 WHERE id = $2; + +-- name: SetChannelMixingThreshold :exec +UPDATE channels SET mixing_threshold = $1 WHERE id = $2; + +-- name: ArchiveChannel :exec +UPDATE channels SET archived = $1 WHERE id = $2; + +-- name: DeleteChannel :exec +DELETE FROM channels WHERE id = $1; + +-- name: AdminUpdateChannel :exec +UPDATE channels +SET name = $1, topic = $2, slow_mode = $3, position = $4, archived = $5 +WHERE id = $6; + +-- name: UpsertChannelPermission :exec +INSERT INTO channel_overrides (channel_id, role_id, allow, deny) +VALUES ($1, $2, $3, $4) +ON CONFLICT (channel_id, role_id) DO UPDATE SET + allow = EXCLUDED.allow, + deny = EXCLUDED.deny; + +-- name: GetChannelPermission :one +SELECT allow, deny FROM channel_overrides WHERE channel_id = $1 AND role_id = $2; + +-- name: GetRoleChannelPermissions :many +SELECT channel_id, allow, deny FROM channel_overrides WHERE role_id = $1; + +-- name: DeleteChannelPermission :exec +DELETE FROM channel_overrides WHERE channel_id = $1 AND role_id = $2; diff --git a/Server/db/queries/postgres/dm.sql b/Server/db/queries/postgres/dm.sql new file mode 100644 index 00000000..93cfbedc --- /dev/null +++ b/Server/db/queries/postgres/dm.sql @@ -0,0 +1,64 @@ +-- PostgreSQL variants of the sqlite DM queries. +-- InsertDMChannel: sqlite uses :execresult (LastInsertId); postgres uses +-- :one with RETURNING id. +-- `INSERT OR IGNORE` becomes `INSERT ... ON CONFLICT DO NOTHING`. + +-- name: InsertDMChannel :one +INSERT INTO channels (name, type) VALUES ('', 'dm') RETURNING id; + +-- name: InsertDMParticipants :exec +INSERT INTO dm_participants (channel_id, user_id) VALUES ($1, $2), ($3, $4); + +-- name: InsertDMOpenState :exec +INSERT INTO dm_open_state (user_id, channel_id) VALUES ($1, $2), ($3, $4) +ON CONFLICT (user_id, channel_id) DO NOTHING; + +-- name: FindExistingDMChannel :one +SELECT dp1.channel_id +FROM dm_participants dp1 +JOIN dm_participants dp2 ON dp1.channel_id = dp2.channel_id +JOIN channels c ON c.id = dp1.channel_id +WHERE dp1.user_id = $1 AND dp2.user_id = $2 AND c.type = 'dm' +LIMIT 1; + +-- name: OpenDM :exec +INSERT INTO dm_open_state (user_id, channel_id) VALUES ($1, $2) +ON CONFLICT (user_id, channel_id) DO NOTHING; + +-- name: CloseDM :exec +DELETE FROM dm_open_state WHERE user_id = $1 AND channel_id = $2; + +-- name: IsDMParticipant :one +SELECT user_id FROM dm_participants WHERE user_id = $1 AND channel_id = $2; + +-- name: GetDMParticipantIDs :many +SELECT user_id FROM dm_participants WHERE channel_id = $1; + +-- For the "last message at" and "last message content" columns, sqlite +-- COALESCEs to '' (empty string). Postgres TIMESTAMPTZ cannot COALESCE to an +-- empty string, so we COALESCE to the dm_open_state.opened_at fallback and +-- leave conversion to the store wrapper. +-- name: GetUserDMChannels :many +SELECT + c.id AS channel_id, + u.id AS recipient_id, + u.username AS recipient_username, + COALESCE(u.avatar, '') AS recipient_avatar, + u.status AS recipient_status, + lm.id AS last_message_id, + COALESCE(lm.content, '') AS last_message, + COALESCE(lm.timestamp, dos.opened_at) AS last_message_at, + COUNT(CASE WHEN m_unread.id > COALESCE(rs.last_message_id, 0) + AND m_unread.deleted = FALSE THEN 1 END) AS unread_count +FROM dm_open_state dos +JOIN channels c ON c.id = dos.channel_id AND c.type = 'dm' +JOIN dm_participants dp ON dp.channel_id = c.id AND dp.user_id != $1 +JOIN users u ON u.id = dp.user_id +LEFT JOIN messages lm ON lm.id = ( + SELECT MAX(id) FROM messages WHERE channel_id = c.id AND deleted = FALSE +) +LEFT JOIN messages m_unread ON m_unread.channel_id = c.id +LEFT JOIN read_states rs ON rs.channel_id = c.id AND rs.user_id = $2 +WHERE dos.user_id = $3 +GROUP BY c.id, u.id, lm.id, lm.content, lm.timestamp, dos.opened_at +ORDER BY COALESCE(lm.timestamp, dos.opened_at) DESC; diff --git a/Server/db/queries/postgres/invites.sql b/Server/db/queries/postgres/invites.sql new file mode 100644 index 00000000..cd01e81f --- /dev/null +++ b/Server/db/queries/postgres/invites.sql @@ -0,0 +1,23 @@ +-- PostgreSQL variants of the sqlite invites queries. +-- The expiry check uses native timestamp comparison instead of sqlite's +-- strftime('%s', …) trick. + +-- name: CreateInvite :exec +INSERT INTO invites (code, created_by, max_uses, expires_at) VALUES ($1, $2, $3, $4); + +-- name: GetInvite :one +SELECT id, code, created_by, max_uses, use_count, expires_at, revoked, created_at +FROM invites WHERE code = $1; + +-- name: UseInviteAtomic :execrows +UPDATE invites SET use_count = use_count + 1 +WHERE code = $1 AND revoked = FALSE + AND (max_uses IS NULL OR use_count < max_uses) + AND (expires_at IS NULL OR expires_at > NOW()); + +-- name: RevokeInvite :exec +UPDATE invites SET revoked = TRUE WHERE code = $1; + +-- name: ListInvites :many +SELECT id, code, created_by, max_uses, use_count, expires_at, revoked, created_at +FROM invites ORDER BY created_at DESC LIMIT 200; diff --git a/Server/db/queries/postgres/lockouts.sql b/Server/db/queries/postgres/lockouts.sql new file mode 100644 index 00000000..3e607926 --- /dev/null +++ b/Server/db/queries/postgres/lockouts.sql @@ -0,0 +1,15 @@ +-- PostgreSQL variants of the sqlite rate-lockout queries. +-- `INSERT OR REPLACE` becomes `INSERT ... ON CONFLICT (key) DO UPDATE`. + +-- name: UpsertLockout :exec +INSERT INTO rate_lockouts (key, expires_at) VALUES ($1, $2) +ON CONFLICT (key) DO UPDATE SET expires_at = EXCLUDED.expires_at; + +-- name: LoadActiveLockouts :many +SELECT key, expires_at FROM rate_lockouts WHERE expires_at > $1; + +-- name: CleanupExpiredLockouts :exec +DELETE FROM rate_lockouts WHERE expires_at <= $1; + +-- name: DeleteLockout :exec +DELETE FROM rate_lockouts WHERE key = $1; diff --git a/Server/db/queries/postgres/messages.sql b/Server/db/queries/postgres/messages.sql new file mode 100644 index 00000000..6b73e0ee --- /dev/null +++ b/Server/db/queries/postgres/messages.sql @@ -0,0 +1,79 @@ +-- PostgreSQL variants of the sqlite messages queries. +-- `deleted = 0/1` and `pinned = 0/1` become FALSE/TRUE (columns are BOOLEAN). +-- The FTS search queries are NOT included here: on postgres, messages.fts is +-- a tsvector column with a GIN index (see migrations/postgres/001_initial_schema.sql) +-- and FTS queries are hand-written in the postgres-specific store dispatch, +-- mirroring how sqlite's FTS5 queries live in message_queries.go. + +-- name: CreateMessage :one +INSERT INTO messages (channel_id, user_id, content, reply_to) +VALUES ($1, $2, $3, $4) +RETURNING id; + +-- name: GetMessage :one +SELECT id, channel_id, user_id, content, reply_to, edited_at, deleted, pinned, timestamp +FROM messages WHERE id = $1; + +-- name: GetMessagesByChannelBeforeCursor :many +SELECT m.id, m.channel_id, m.user_id, m.content, m.reply_to, + m.edited_at, m.deleted, m.pinned, m.timestamp, + u.username, u.avatar +FROM messages m JOIN users u ON m.user_id = u.id +WHERE m.channel_id = $1 AND m.id < $2 AND m.deleted = FALSE +ORDER BY m.id DESC LIMIT $3; + +-- name: GetMessagesByChannel :many +SELECT m.id, m.channel_id, m.user_id, m.content, m.reply_to, + m.edited_at, m.deleted, m.pinned, m.timestamp, + u.username, u.avatar +FROM messages m JOIN users u ON m.user_id = u.id +WHERE m.channel_id = $1 AND m.deleted = FALSE +ORDER BY m.id DESC LIMIT $2; + +-- name: GetMessagesForAPIBeforeCursor :many +SELECT m.id, m.channel_id, m.user_id, u.username, u.avatar, + m.content, m.reply_to, m.edited_at, m.deleted, m.pinned, m.timestamp +FROM messages m JOIN users u ON m.user_id = u.id +WHERE m.channel_id = $1 AND m.id < $2 AND m.deleted = FALSE +ORDER BY m.id DESC LIMIT $3; + +-- name: GetMessagesForAPI :many +SELECT m.id, m.channel_id, m.user_id, u.username, u.avatar, + m.content, m.reply_to, m.edited_at, m.deleted, m.pinned, m.timestamp +FROM messages m JOIN users u ON m.user_id = u.id +WHERE m.channel_id = $1 AND m.deleted = FALSE +ORDER BY m.id DESC LIMIT $2; + +-- name: GetPinnedMessageRows :many +SELECT m.id, m.channel_id, m.user_id, u.username, u.avatar, + m.content, m.reply_to, m.edited_at, m.deleted, m.pinned, m.timestamp +FROM messages m JOIN users u ON m.user_id = u.id +WHERE m.channel_id = $1 AND m.pinned = TRUE AND m.deleted = FALSE +ORDER BY m.id DESC; + +-- name: EditMessageContent :exec +UPDATE messages SET content = $1, edited_at = NOW() WHERE id = $2; + +-- name: SoftDeleteMessage :exec +UPDATE messages SET deleted = TRUE WHERE id = $1; + +-- name: SetMessagePinned :execrows +UPDATE messages SET pinned = $1 WHERE id = $2 AND deleted = FALSE; + +-- name: GetLatestMessageID :one +SELECT COALESCE(MAX(id), 0)::BIGINT FROM messages WHERE channel_id = $1 AND deleted = FALSE; + +-- name: UpdateReadState :exec +INSERT INTO read_states (user_id, channel_id, last_message_id) +VALUES ($1, $2, $3) +ON CONFLICT (user_id, channel_id) DO UPDATE SET last_message_id = EXCLUDED.last_message_id; + +-- name: GetChannelUnreadCounts :many +SELECT c.id, + COALESCE(MAX(m.id), 0)::BIGINT AS last_msg_id, + COUNT(CASE WHEN m.id > COALESCE(rs.last_message_id, 0) AND m.deleted = FALSE THEN 1 END) AS unread +FROM channels c +LEFT JOIN messages m ON m.channel_id = c.id AND m.deleted = FALSE +LEFT JOIN read_states rs ON rs.channel_id = c.id AND rs.user_id = $1 +WHERE c.type = 'text' +GROUP BY c.id; diff --git a/Server/db/queries/postgres/profile.sql b/Server/db/queries/postgres/profile.sql new file mode 100644 index 00000000..d95a03a4 --- /dev/null +++ b/Server/db/queries/postgres/profile.sql @@ -0,0 +1,9 @@ +-- PostgreSQL variants of the sqlite profile queries. +-- UpdateUserProfile uses :execrows because postgres has no LastInsertId; +-- the caller checks rows-affected for existence. + +-- name: UpdateUserProfile :execrows +UPDATE users SET username = $1, avatar = $2 WHERE id = $3; + +-- name: UpdateUserPassword :exec +UPDATE users SET password = $1 WHERE id = $2; diff --git a/Server/db/queries/postgres/reactions.sql b/Server/db/queries/postgres/reactions.sql new file mode 100644 index 00000000..4980c16f --- /dev/null +++ b/Server/db/queries/postgres/reactions.sql @@ -0,0 +1,12 @@ +-- PostgreSQL variants of the sqlite reactions queries. + +-- name: AddReaction :exec +INSERT INTO reactions (message_id, user_id, emoji) VALUES ($1, $2, $3); + +-- name: RemoveReaction :execrows +DELETE FROM reactions WHERE message_id = $1 AND user_id = $2 AND emoji = $3; + +-- name: GetReactionCounts :many +SELECT emoji, COUNT(*) AS count +FROM reactions WHERE message_id = $1 +GROUP BY emoji; diff --git a/Server/db/queries/postgres/roles.sql b/Server/db/queries/postgres/roles.sql new file mode 100644 index 00000000..a52474d9 --- /dev/null +++ b/Server/db/queries/postgres/roles.sql @@ -0,0 +1,29 @@ +-- PostgreSQL variants of the sqlite roles queries. +-- `is_default = 1` becomes `is_default = TRUE` since the column is BOOLEAN. + +-- name: GetRoleByID :one +SELECT id, name, color, permissions, position, is_default +FROM roles WHERE id = $1; + +-- name: ListRoles :many +SELECT id, name, color, permissions, position, is_default +FROM roles ORDER BY position DESC; + +-- name: GetRoleForUser :one +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 = $1; + +-- name: GetUserWithRole :one +SELECT u.id, u.username, u.password, u.avatar, u.role_id, + u.totp_secret, u.status, u.created_at, u.last_seen, + u.banned, u.ban_reason, u.ban_expires, + r.id, r.name, r.color, r.permissions, r.position, r.is_default +FROM users u +JOIN roles r ON r.id = u.role_id +WHERE u.id = $1; + +-- name: GetDefaultRole :one +SELECT id, name, color, permissions, position, is_default +FROM roles WHERE is_default = TRUE LIMIT 1; diff --git a/Server/db/queries/postgres/sessions.sql b/Server/db/queries/postgres/sessions.sql new file mode 100644 index 00000000..33845cd7 --- /dev/null +++ b/Server/db/queries/postgres/sessions.sql @@ -0,0 +1,49 @@ +-- PostgreSQL variants of the sqlite sessions queries. + +-- name: InsertSession :one +INSERT INTO sessions (user_id, token, device, ip_address, expires_at) +VALUES ($1, $2, $3, $4, $5) +RETURNING id; + +-- Delete all but the N most recent sessions for a user. Postgres replaces +-- sqlite's `LIMIT -1 OFFSET ?` with `OFFSET $2`. +-- name: EvictOldestSessions :exec +DELETE FROM sessions WHERE id IN ( + SELECT s2.id FROM sessions AS s2 WHERE s2.user_id = $1 + ORDER BY s2.created_at DESC + OFFSET $2 +); + +-- name: GetSessionByTokenHash :one +SELECT id, user_id, token, device, ip_address, created_at, last_used, expires_at +FROM sessions WHERE token = $1; + +-- name: GetSessionWithBanStatus :one +SELECT s.id, s.user_id, s.token, s.device, s.ip_address, + s.created_at, s.last_used, s.expires_at, + u.banned, u.ban_reason, u.ban_expires +FROM sessions s +JOIN users u ON s.user_id = u.id +WHERE s.token = $1; + +-- name: DeleteSessionByToken :exec +DELETE FROM sessions WHERE token = $1; + +-- name: DeleteSessionByID :exec +DELETE FROM sessions WHERE id = $1 AND user_id = $2; + +-- name: DeleteOtherSessions :execrows +DELETE FROM sessions WHERE user_id = $1 AND id != $2; + +-- Use native timestamp comparison instead of sqlite's strftime trick. +-- name: DeleteExpiredSessions :exec +DELETE FROM sessions WHERE expires_at < NOW(); + +-- name: TouchSession :exec +UPDATE sessions SET last_used = NOW() WHERE token = $1; + +-- name: ListUserSessions :many +SELECT id, user_id, token, device, ip_address, created_at, last_used, expires_at +FROM sessions +WHERE user_id = $1 +ORDER BY created_at DESC; diff --git a/Server/db/queries/postgres/users.sql b/Server/db/queries/postgres/users.sql new file mode 100644 index 00000000..6a244152 --- /dev/null +++ b/Server/db/queries/postgres/users.sql @@ -0,0 +1,51 @@ +-- PostgreSQL variants of the sqlite users queries. +-- Differences from sqlite: +-- - `?` -> `$1`, `$2`, … +-- - `COLLATE NOCASE` -> removed; the `username` column is CITEXT. +-- - `datetime('now')` -> `NOW()` +-- - `banned = 0/1` -> `banned = FALSE/TRUE` (column is BOOLEAN) +-- - `:execresult INSERT` -> `:one ... RETURNING id` (pgx has no LastInsertId) + +-- name: GetUserByUsername :one +SELECT id, username, password, avatar, role_id, totp_secret, status, + created_at, last_seen, banned, ban_reason, ban_expires +FROM users WHERE username = $1; + +-- name: GetUserByID :one +SELECT id, username, password, avatar, role_id, totp_secret, status, + created_at, last_seen, banned, ban_reason, ban_expires +FROM users WHERE id = $1; + +-- name: CreateUser :one +INSERT INTO users (username, password, role_id) +VALUES ($1, $2, $3) +RETURNING id; + +-- name: UpdateUserStatus :exec +UPDATE users SET status = $1, last_seen = NOW() WHERE id = $2; + +-- name: UpdateUserTOTPSecret :exec +UPDATE users SET totp_secret = $1 WHERE id = $2; + +-- name: ResetAllUserStatuses :exec +UPDATE users SET status = 'offline' WHERE status != 'offline'; + +-- name: BanUser :exec +UPDATE users SET banned = TRUE, ban_reason = $1, ban_expires = $2 WHERE id = $3; + +-- name: UnbanUser :exec +UPDATE users SET banned = FALSE, ban_reason = NULL, ban_expires = NULL WHERE id = $1; + +-- name: ListMembers :many +SELECT u.id, u.username, u.avatar, u.status, LOWER(r.name) +FROM users u +JOIN roles r ON u.role_id = r.id +WHERE u.banned = FALSE +ORDER BY u.username ASC +LIMIT 1000; + +-- name: CountUsers :one +SELECT COUNT(*) FROM users; + +-- name: CountUsersWithoutTOTP :one +SELECT COUNT(*) FROM users WHERE banned = FALSE AND totp_secret IS NULL; diff --git a/Server/db/queries/postgres/voice.sql b/Server/db/queries/postgres/voice.sql new file mode 100644 index 00000000..c9db052a --- /dev/null +++ b/Server/db/queries/postgres/voice.sql @@ -0,0 +1,88 @@ +-- PostgreSQL variants of the sqlite voice queries. +-- voice_states boolean columns (muted, deafened, speaking, camera, +-- screenshare) use FALSE/TRUE instead of 0/1. + +-- name: JoinVoiceChannel :exec +INSERT INTO voice_states (user_id, channel_id, muted, deafened, speaking, camera, screenshare, joined_at) +VALUES ($1, $2, FALSE, FALSE, FALSE, FALSE, FALSE, $3) +ON CONFLICT (user_id) DO UPDATE SET + channel_id = EXCLUDED.channel_id, + muted = FALSE, + deafened = FALSE, + speaking = FALSE, + camera = FALSE, + screenshare = FALSE, + joined_at = EXCLUDED.joined_at; + +-- name: JoinVoiceChannelIfCapacity :execrows +INSERT INTO voice_states (user_id, channel_id, muted, deafened, speaking, camera, screenshare, joined_at) +SELECT $1, $2, FALSE, FALSE, FALSE, FALSE, FALSE, $3 +WHERE (SELECT COUNT(*) FROM voice_states AS vs2 WHERE vs2.channel_id = $4) < $5 +ON CONFLICT (user_id) DO UPDATE SET + channel_id = EXCLUDED.channel_id, + muted = FALSE, + deafened = FALSE, + speaking = FALSE, + camera = FALSE, + screenshare = FALSE, + joined_at = EXCLUDED.joined_at; + +-- name: LeaveVoiceChannel :exec +DELETE FROM voice_states WHERE user_id = $1; + +-- name: LeaveVoiceChannelIfMatch :execrows +DELETE FROM voice_states WHERE user_id = $1 AND channel_id = $2 AND joined_at = $3; + +-- name: GetUserVoiceState :one +SELECT vs.user_id, vs.channel_id, u.username, + vs.muted, vs.deafened, vs.speaking, + vs.camera, vs.screenshare, vs.joined_at +FROM voice_states vs +JOIN users u ON u.id = vs.user_id +WHERE vs.user_id = $1; + +-- name: GetChannelVoiceStates :many +SELECT vs.user_id, vs.channel_id, u.username, + vs.muted, vs.deafened, vs.speaking, + vs.camera, vs.screenshare, vs.joined_at +FROM voice_states vs +JOIN users u ON u.id = vs.user_id +WHERE vs.channel_id = $1 +ORDER BY vs.joined_at ASC; + +-- name: GetAllVoiceStates :many +SELECT vs.user_id, vs.channel_id, u.username, + vs.muted, vs.deafened, vs.speaking, + vs.camera, vs.screenshare, vs.joined_at +FROM voice_states vs +JOIN users u ON u.id = vs.user_id +ORDER BY vs.channel_id, vs.joined_at ASC; + +-- name: UpdateVoiceMute :exec +UPDATE voice_states SET muted = $1 WHERE user_id = $2; + +-- name: UpdateVoiceDeafen :exec +UPDATE voice_states SET deafened = $1 WHERE user_id = $2; + +-- name: UpdateVoiceSpeaking :exec +UPDATE voice_states SET speaking = $1 WHERE user_id = $2; + +-- name: UpdateVoiceCamera :exec +UPDATE voice_states SET camera = $1 WHERE user_id = $2; + +-- name: UpdateVoiceScreenshare :exec +UPDATE voice_states SET screenshare = $1 WHERE user_id = $2; + +-- name: EnableCameraIfUnderLimit :execrows +UPDATE voice_states SET camera = TRUE +WHERE voice_states.user_id = $1 AND voice_states.channel_id = $2 + AND (SELECT COUNT(*) FROM voice_states AS vs2 WHERE vs2.channel_id = $3 AND vs2.camera = TRUE) < $4; + +-- name: ClearVoiceState :exec +DELETE FROM voice_states WHERE user_id = $1; + +-- name: ClearAllVoiceStates :exec +DELETE FROM voice_states; + +-- name: CountActiveCameras :one +SELECT COUNT(*) FROM voice_states WHERE channel_id = $1 AND camera = TRUE; diff --git a/Server/main.go b/Server/main.go index 9458b728..75da7008 100644 --- a/Server/main.go +++ b/Server/main.go @@ -84,6 +84,32 @@ func run(log *slog.Logger, logBuf *admin.RingBuffer) error { printBanner(cfg, version, tlsCfg != nil) // ── 4. Open database + run migrations ───────────────────────────────── + // The Phase A plan calls for two backends (sqlite, postgres) selected via + // config. SQLite is the only backend currently wired through the *db.DB + // type. The PostgreSQL scaffolding is in place (schema under + // Server/migrations/postgres, sqlc query files under + // Server/db/queries/postgres, PostgresStore behind the `postgres` build + // tag in Server/store/postgres.go), but PostgresStore's query methods + // are still stubs and the handler boundary still passes *db.DB directly + // rather than store.Store. Until both of those land, selecting + // type: "postgres" refuses to start with a clear pointer at what's left. + switch dbType := cfg.Database.Type; dbType { + case "", "sqlite": + // fall through to the existing SQLite path + case "postgres": + return fmt.Errorf("database.type=postgres is configured, but the postgres " + + "backend is not yet wired into the runtime. PostgresStore exists at " + + "Server/store/postgres.go behind the `postgres` build tag, with connection " + + "lifecycle fully implemented but query methods stubbed. What's still " + + "pending: (1) run `make sqlc-generate` to produce Server/db/pgdbgen/, " + + "(2) replace the stub query methods in postgres.go with wrappers around " + + "pgdbgen, and (3) refactor api/router.go and this main.go to thread " + + "store.Store through the handler boundary instead of *db.DB. Until those " + + "land, set database.type to \"sqlite\" or omit it to start the server") + default: + return fmt.Errorf("database.type=%q is not recognised; expected \"sqlite\" or \"postgres\"", dbType) + } + database, err := db.Open(cfg.Database.Path) if err != nil { return fmt.Errorf("opening database: %w", err) diff --git a/Server/migrations/postgres/001_initial_schema.sql b/Server/migrations/postgres/001_initial_schema.sql new file mode 100644 index 00000000..b4ec48bc --- /dev/null +++ b/Server/migrations/postgres/001_initial_schema.sql @@ -0,0 +1,304 @@ +-- Migration 001 (PostgreSQL): Initial schema +-- +-- This is a consolidated PostgreSQL translation of the SQLite migrations +-- 001-013 found in Server/migrations/. PostgreSQL is a fresh-start backend, +-- so we collapse the SQLite migration history into a single canonical schema +-- file. Future PostgreSQL schema changes should land as 002_*.sql, 003_*.sql, +-- etc., mirroring the SQLite numbering convention. +-- +-- DIFFERENCES FROM SQLITE: +-- - INTEGER PRIMARY KEY AUTOINCREMENT -> BIGSERIAL PRIMARY KEY +-- - TEXT NOT NULL DEFAULT (datetime('now')) -> TIMESTAMPTZ NOT NULL DEFAULT NOW() +-- - INTEGER (used as bool) -> BOOLEAN NOT NULL DEFAULT FALSE +-- - INTEGER permission bitfield -> BIGINT NOT NULL DEFAULT 0 +-- - FTS5 virtual table -> tsvector column on messages + GIN index + +-- trigger to keep tsvector in sync (see "Full-text search" section). +-- - SQLite triggers using RAISE(ABORT) -> native CHECK constraints. +-- - COLLATE NOCASE -> CITEXT extension on the username column. +-- +-- The store.MessageStore.SearchMessages implementation must dispatch on +-- backend type because the query syntax differs (MATCH vs @@). + +CREATE EXTENSION IF NOT EXISTS citext; + +-- ── roles ─────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS roles ( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + color TEXT, + permissions BIGINT NOT NULL DEFAULT 0, + position INTEGER NOT NULL DEFAULT 0, + is_default BOOLEAN NOT NULL DEFAULT FALSE +); + +-- Default roles. Permission bitfields match the SQLite seed values. +-- Member final value (7779) reflects SQLite migrations 005 and 007 combined. +INSERT INTO roles (id, name, color, permissions, position, is_default) VALUES + (1, 'Owner', '#E74C3C', 2147483647, 100, FALSE), + (2, 'Admin', '#F39C12', 1073741823, 80, FALSE), + (3, 'Moderator', '#3498DB', 1048575, 60, FALSE), + (4, 'Member', NULL, 7779, 40, TRUE) +ON CONFLICT (id) DO NOTHING; + +-- Reset the sequence past the seeded rows so user-created roles get IDs >= 5. +SELECT setval('roles_id_seq', GREATEST((SELECT MAX(id) FROM roles), 1)); + +-- ── users ─────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS users ( + id BIGSERIAL PRIMARY KEY, + username CITEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + avatar TEXT, + role_id BIGINT NOT NULL DEFAULT 4 REFERENCES roles(id), + totp_secret TEXT, + status TEXT NOT NULL DEFAULT 'offline', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_seen TIMESTAMPTZ, + banned BOOLEAN NOT NULL DEFAULT FALSE, + ban_reason TEXT, + ban_expires TIMESTAMPTZ +); + +-- ── sessions ──────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS sessions ( + id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + device TEXT, + ip_address TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_used TIMESTAMPTZ NOT NULL DEFAULT NOW(), + expires_at TIMESTAMPTZ NOT NULL +); + +CREATE INDEX IF NOT EXISTS idx_sessions_token ON sessions(token); +CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id); + +-- ── channels ──────────────────────────────────────────────────────────────── +-- Includes columns from migrations 001 + 004 (voice columns). +-- The CHECK constraint replaces SQLite migration 013's trigger. +CREATE TABLE IF NOT EXISTS channels ( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL, + type TEXT NOT NULL DEFAULT 'text' + CHECK (type IN ('text', 'voice', 'dm')), + category TEXT, + topic TEXT, + position INTEGER NOT NULL DEFAULT 0, + slow_mode INTEGER NOT NULL DEFAULT 0, + archived BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + voice_max_users INTEGER NOT NULL DEFAULT 0, + voice_quality TEXT, + mixing_threshold INTEGER, + voice_max_video INTEGER NOT NULL DEFAULT 25 +); + +-- ── channel_overrides ─────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS channel_overrides ( + id BIGSERIAL PRIMARY KEY, + channel_id BIGINT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + role_id BIGINT NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + allow BIGINT NOT NULL DEFAULT 0, + deny BIGINT NOT NULL DEFAULT 0, + UNIQUE (channel_id, role_id) +); + +CREATE INDEX IF NOT EXISTS idx_channel_overrides_channel_role + ON channel_overrides(channel_id, role_id); + +-- ── messages + full-text search ───────────────────────────────────────────── +-- PostgreSQL uses a tsvector column with a GIN index instead of the SQLite +-- FTS5 virtual table. The fts column is maintained automatically by a +-- trigger so application code doesn't need to set it. +CREATE TABLE IF NOT EXISTS messages ( + id BIGSERIAL PRIMARY KEY, + channel_id BIGINT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES users(id), + content TEXT NOT NULL, + reply_to BIGINT REFERENCES messages(id) ON DELETE SET NULL, + edited_at TIMESTAMPTZ, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + pinned BOOLEAN NOT NULL DEFAULT FALSE, + timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW(), + fts tsvector +); + +CREATE INDEX IF NOT EXISTS idx_messages_channel ON messages(channel_id, id DESC); +CREATE INDEX IF NOT EXISTS idx_messages_user ON messages(user_id); +CREATE INDEX IF NOT EXISTS idx_messages_fts ON messages USING GIN (fts); + +CREATE OR REPLACE FUNCTION messages_fts_update() RETURNS trigger AS $$ +BEGIN + NEW.fts := to_tsvector('simple', COALESCE(NEW.content, '')); + RETURN NEW; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_messages_fts_update ON messages; +CREATE TRIGGER trg_messages_fts_update + BEFORE INSERT OR UPDATE OF content ON messages + FOR EACH ROW + EXECUTE FUNCTION messages_fts_update(); + +-- ── attachments ───────────────────────────────────────────────────────────── +-- Combines migrations 001 + 008 (width, height) + 010 (uploader_id). +CREATE TABLE IF NOT EXISTS attachments ( + id TEXT PRIMARY KEY, + message_id BIGINT REFERENCES messages(id) ON DELETE CASCADE, + filename TEXT NOT NULL, + stored_as TEXT NOT NULL, + mime_type TEXT NOT NULL, + size BIGINT NOT NULL, + uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + width INTEGER, + height INTEGER, + uploader_id BIGINT REFERENCES users(id) +); + +CREATE INDEX IF NOT EXISTS idx_attachments_uploader ON attachments(uploader_id); + +-- ── reactions ─────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS reactions ( + id BIGSERIAL PRIMARY KEY, + message_id BIGINT NOT NULL REFERENCES messages(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + emoji TEXT NOT NULL, + UNIQUE (message_id, user_id, emoji) +); + +-- ── invites ───────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS invites ( + id BIGSERIAL PRIMARY KEY, + code TEXT NOT NULL UNIQUE, + created_by BIGINT NOT NULL REFERENCES users(id), + redeemed_by BIGINT REFERENCES users(id), + max_uses INTEGER, + use_count INTEGER NOT NULL DEFAULT 0, + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + revoked BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE INDEX IF NOT EXISTS idx_invites_code ON invites(code); + +-- ── read_states ───────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS read_states ( + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + channel_id BIGINT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + last_message_id BIGINT NOT NULL DEFAULT 0, + mention_count INTEGER NOT NULL DEFAULT 0, + PRIMARY KEY (user_id, channel_id) +); + +-- ── audit_log ─────────────────────────────────────────────────────────────── +-- Phase-6 canonical column names (matches SQLite migration 003). +CREATE TABLE IF NOT EXISTS audit_log ( + id BIGSERIAL PRIMARY KEY, + actor_id BIGINT NOT NULL DEFAULT 0, + action TEXT NOT NULL, + target_type TEXT NOT NULL DEFAULT '', + target_id BIGINT NOT NULL DEFAULT 0, + detail TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit_log(created_at DESC); +CREATE INDEX IF NOT EXISTS idx_audit_log_actor ON audit_log(actor_id); + +-- ── login_attempts ────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS login_attempts ( + id BIGSERIAL PRIMARY KEY, + ip_address TEXT NOT NULL, + username TEXT, + success BOOLEAN NOT NULL DEFAULT FALSE, + timestamp TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_login_ip ON login_attempts(ip_address, timestamp); + +-- ── settings ──────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL +); + +INSERT INTO settings (key, value) VALUES + ('server_name', 'OwnCord Server'), + ('server_icon', ''), + ('motd', 'Welcome!'), + ('max_upload_bytes', '26214400'), + ('voice_quality', 'high'), + ('require_2fa', '0'), + ('registration_open', '0'), + ('backup_schedule', 'daily'), + ('backup_retention', '7'), + ('schema_version', '1') +ON CONFLICT (key) DO NOTHING; + +-- ── emoji ─────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS emoji ( + id BIGSERIAL PRIMARY KEY, + shortcode TEXT NOT NULL UNIQUE, + filename TEXT NOT NULL, + uploaded_by BIGINT NOT NULL REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ── sounds ────────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS sounds ( + id BIGSERIAL PRIMARY KEY, + name TEXT NOT NULL, + filename TEXT NOT NULL, + duration_ms INTEGER NOT NULL, + uploaded_by BIGINT NOT NULL REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ── voice_states ──────────────────────────────────────────────────────────── +-- Combines migrations 002 + 004 (camera, screenshare). +CREATE TABLE IF NOT EXISTS voice_states ( + user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + channel_id BIGINT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + muted BOOLEAN NOT NULL DEFAULT FALSE, + deafened BOOLEAN NOT NULL DEFAULT FALSE, + speaking BOOLEAN NOT NULL DEFAULT FALSE, + joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + camera BOOLEAN NOT NULL DEFAULT FALSE, + screenshare BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE INDEX IF NOT EXISTS idx_voice_states_channel ON voice_states(channel_id); + +-- ── direct messages ───────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS dm_participants ( + channel_id BIGINT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + PRIMARY KEY (channel_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_dm_participants_user ON dm_participants(user_id); + +CREATE TABLE IF NOT EXISTS dm_open_state ( + user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + channel_id BIGINT NOT NULL REFERENCES channels(id) ON DELETE CASCADE, + opened_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (user_id, channel_id) +); + +-- ── rate_lockouts ─────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS rate_lockouts ( + key TEXT PRIMARY KEY, + expires_at TIMESTAMPTZ NOT NULL +); + +-- ── user_blocks ───────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS user_blocks ( + blocker_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + blocked_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + PRIMARY KEY (blocker_id, blocked_id), + CHECK (blocker_id <> blocked_id) +); + +CREATE INDEX IF NOT EXISTS idx_user_blocks_blocked ON user_blocks(blocked_id, blocker_id); diff --git a/Server/migrations/postgres/migrations.go b/Server/migrations/postgres/migrations.go new file mode 100644 index 00000000..824b8c73 --- /dev/null +++ b/Server/migrations/postgres/migrations.go @@ -0,0 +1,17 @@ +// Package postgres holds embedded SQL migration files for the PostgreSQL +// backend of the OwnCord server. PostgreSQL is opt-in via the +// `database.type = "postgres"` setting in owncord.yaml. +// +// PostgreSQL migrations are numbered independently from the SQLite migration +// set in Server/migrations/. The two are NOT interchangeable: PostgreSQL +// uses native types (BIGSERIAL, TIMESTAMPTZ, BOOLEAN, tsvector) and a +// consolidated initial schema rather than the historical SQLite migration +// chain. +package postgres + +import "embed" + +// FS holds all PostgreSQL migration SQL files embedded at compile time. +// +//go:embed *.sql +var FS embed.FS diff --git a/Server/sqlc.yaml b/Server/sqlc.yaml index 651bf871..60042c41 100644 --- a/Server/sqlc.yaml +++ b/Server/sqlc.yaml @@ -1,5 +1,6 @@ version: "2" sql: + # ── SQLite backend ──────────────────────────────────────────────────────── - engine: "sqlite" queries: "db/queries/sqlite" schema: "migrations" @@ -15,3 +16,23 @@ sql: emit_empty_slices: true emit_json_tags: true json_tags_case_style: "camel" + + # ── PostgreSQL backend (Phase A Step 3) ─────────────────────────────────── + # Postgres is opt-in via database.type = "postgres" in owncord.yaml. + # The generated querier lives in a separate package (pgdbgen) so sqlite + # and postgres code can coexist without import collisions. + - engine: "postgresql" + queries: "db/queries/postgres" + schema: "migrations/postgres" + gen: + go: + package: "pgdbgen" + out: "db/pgdbgen" + sql_package: "pgx/v5" + emit_interface: true + emit_pointers_for_null_types: true + emit_prepared_queries: false + emit_exact_table_names: false + emit_empty_slices: true + emit_json_tags: true + json_tags_case_style: "camel" diff --git a/Server/store/postgres.go b/Server/store/postgres.go new file mode 100644 index 00000000..74ff78a4 --- /dev/null +++ b/Server/store/postgres.go @@ -0,0 +1,579 @@ +//go:build postgres + +// Package store — PostgreSQL backend. +// +// This file is compiled only when the `postgres` build tag is set. The +// default `go build ./...` produces a sqlite-only binary with zero postgres +// dependencies. To enable postgres support: +// +// go get github.com/jackc/pgx/v5 +// go build -tags postgres ./... +// +// The connection lifecycle methods (Open, Close, SQLDb, WithTx) are fully +// implemented against the pgx stdlib driver. Query methods are stubbed — +// they satisfy the Store interface so the type-assertion check at the +// bottom of this file passes, but each returns ErrPostgresNotImplemented at +// runtime. The stubs will be replaced incrementally as Server/db/pgdbgen/ +// is generated from the query files in Server/db/queries/postgres/ via +// `make sqlc-generate`, and PostgresStore methods migrate to wrap the +// generated querier. +// +// Until the store-everywhere refactor lands in main.go / router.go, this +// type is not yet wired into the runtime — see phase-a-foundation.md's +// "Pending" section. Constructing a PostgresStore in isolation works, but +// main.go will still refuse to start with type: "postgres" until the +// boundary refactor lands. + +package store + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + // pgx's stdlib driver exposes pgx as a database/sql driver, letting + // PostgresStore reuse the same *sql.DB patterns as SQLiteStore. Once the + // sqlc-generated pgdbgen package is wired in, this import shifts to the + // native pgxpool API for zero-overhead query execution. + _ "github.com/jackc/pgx/v5/stdlib" + + "github.com/owncord/server/config" + "github.com/owncord/server/db" +) + +// ErrPostgresNotImplemented is returned by every query method that is not +// yet backed by sqlc-generated code. It is a sentinel error so callers and +// tests can detect "postgres path reached but implementation pending". +var ErrPostgresNotImplemented = errors.New("postgres backend: query not yet implemented (awaiting sqlc-generated pgdbgen)") + +// PostgresStore implements store.Store against a PostgreSQL database. +// It wraps a *sql.DB opened with pgx's stdlib driver. The query methods are +// currently stubs; see the package-level comment for the migration path. +type PostgresStore struct { + sqlDB *sql.DB +} + +// NewPostgresStore creates a PostgresStore from an already-open *sql.DB. +// Callers that want a one-step constructor should use OpenPostgres. +func NewPostgresStore(sqlDB *sql.DB) *PostgresStore { + return &PostgresStore{sqlDB: sqlDB} +} + +// OpenPostgres dials a PostgreSQL server using the connection settings in +// cfg and returns a ready-to-use PostgresStore. The caller is responsible +// for calling Close when done. Connection pooling is handled by *sql.DB; +// cfg.MaxConns > 0 caps the pool at that size, otherwise the database/sql +// default is used. +func OpenPostgres(cfg *config.DatabaseConfig) (*PostgresStore, error) { + if cfg == nil { + return nil, errors.New("OpenPostgres: nil config") + } + dsn := fmt.Sprintf( + "host=%s port=%d user=%s password=%s dbname=%s sslmode=%s", + cfg.Host, cfg.Port, cfg.User, cfg.Password, cfg.Name, cfg.SSLMode, + ) + sqlDB, err := sql.Open("pgx", dsn) + if err != nil { + return nil, fmt.Errorf("OpenPostgres: open: %w", err) + } + if cfg.MaxConns > 0 { + sqlDB.SetMaxOpenConns(cfg.MaxConns) + sqlDB.SetMaxIdleConns(cfg.MaxConns) + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := sqlDB.PingContext(ctx); err != nil { + _ = sqlDB.Close() + return nil, fmt.Errorf("OpenPostgres: ping: %w", err) + } + return &PostgresStore{sqlDB: sqlDB}, nil +} + +// Close releases the underlying database connection pool. +func (s *PostgresStore) Close() error { return s.sqlDB.Close() } + +// SQLDb returns the underlying *sql.DB for callers that need raw access +// (backup, migration runners, ad-hoc queries). +func (s *PostgresStore) SQLDb() *sql.DB { return s.sqlDB } + +// WithTx runs fn inside a transaction. The transaction is committed if fn +// returns nil, otherwise rolled back. Postgres supports full transactional +// semantics (unlike SQLite's single-writer model), so concurrent callers +// are safe. +func (s *PostgresStore) WithTx(ctx context.Context, fn func(Store) error) error { + tx, err := s.sqlDB.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("PostgresStore.WithTx: begin: %w", err) + } + if txErr := fn(s); txErr != nil { + _ = tx.Rollback() + return txErr + } + return tx.Commit() +} + +// ── MessageStore (stubs) ──────────────────────────────────────────────────── + +func (s *PostgresStore) CreateMessage(channelID, userID int64, content string, replyTo *int64) (int64, error) { + return 0, ErrPostgresNotImplemented +} + +func (s *PostgresStore) GetMessage(id int64) (*db.Message, error) { + return nil, ErrPostgresNotImplemented +} + +func (s *PostgresStore) GetMessages(channelID, before int64, limit int) ([]db.MessageWithUser, error) { + return nil, ErrPostgresNotImplemented +} + +func (s *PostgresStore) GetMessagesForAPI(channelID, before int64, limit int, requestingUserID int64) ([]db.MessageAPIResponse, error) { + return nil, ErrPostgresNotImplemented +} + +func (s *PostgresStore) EditMessage(id, userID int64, content string) error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) DeleteMessage(id, userID int64, isMod bool) error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) SearchMessages(query string, channelID *int64, limit int) ([]db.MessageSearchResult, error) { + return nil, ErrPostgresNotImplemented +} + +func (s *PostgresStore) SearchMessagesInChannels(query string, channelIDs []int64, limit int) ([]db.MessageSearchResult, error) { + return nil, ErrPostgresNotImplemented +} + +func (s *PostgresStore) GetPinnedMessages(channelID int64, requestingUserID int64) ([]db.MessageAPIResponse, error) { + return nil, ErrPostgresNotImplemented +} + +func (s *PostgresStore) SetMessagePinned(id int64, pinned bool) error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) AddReaction(messageID, userID int64, emoji string) error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) RemoveReaction(messageID, userID int64, emoji string) error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) GetReactions(messageID int64) ([]db.ReactionCount, error) { + return nil, ErrPostgresNotImplemented +} + +func (s *PostgresStore) UpdateReadState(userID, channelID, lastReadMessageID int64) error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) GetChannelUnreadCounts(userID int64) (map[int64]db.ChannelUnread, error) { + return nil, ErrPostgresNotImplemented +} + +func (s *PostgresStore) GetLatestMessageID(channelID int64) (int64, error) { + return 0, ErrPostgresNotImplemented +} + +func (s *PostgresStore) LinkAttachmentsToMessage(messageID int64, attachmentIDs []string) (int64, error) { + return 0, ErrPostgresNotImplemented +} + +func (s *PostgresStore) GetAttachmentsByMessageIDs(msgIDs []int64) (map[int64][]db.AttachmentInfo, error) { + return nil, ErrPostgresNotImplemented +} + +// ── ChannelStore (stubs) ──────────────────────────────────────────────────── + +func (s *PostgresStore) ListChannels() ([]db.Channel, error) { + return nil, ErrPostgresNotImplemented +} + +func (s *PostgresStore) GetChannel(id int64) (*db.Channel, error) { + return nil, ErrPostgresNotImplemented +} + +func (s *PostgresStore) CreateChannel(name, chanType, category, topic string, position int) (int64, error) { + return 0, ErrPostgresNotImplemented +} + +func (s *PostgresStore) UpdateChannel(id int64, name, topic string, slowMode int) error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) DeleteChannel(id int64) error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) SetChannelSlowMode(id int64, slowMode int) error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) SetChannelVoiceMaxUsers(id int64, maxUsers int) error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) GetChannelPermissions(channelID, roleID int64) (int64, int64, error) { + return 0, 0, ErrPostgresNotImplemented +} + +func (s *PostgresStore) GetAllChannelPermissionsForRole(roleID int64) (map[int64]db.ChannelOverride, error) { + return nil, ErrPostgresNotImplemented +} + +func (s *PostgresStore) GetChannelTypes(ids []int64) (map[int64]string, error) { + return nil, ErrPostgresNotImplemented +} + +// ── UserStore (stubs) ─────────────────────────────────────────────────────── + +func (s *PostgresStore) GetUserByID(id int64) (*db.User, error) { + return nil, ErrPostgresNotImplemented +} + +func (s *PostgresStore) GetUserByUsername(username string) (*db.User, error) { + return nil, ErrPostgresNotImplemented +} + +func (s *PostgresStore) CreateUser(username, passwordHash string, roleID int) (int64, error) { + return 0, ErrPostgresNotImplemented +} + +func (s *PostgresStore) CreateOwnerIfEmpty(username, passwordHash string, roleID int) (int64, error) { + return 0, ErrPostgresNotImplemented +} + +func (s *PostgresStore) CreateUserWithInvite(username, passwordHash string, roleID int, inviteCode string) (int64, error) { + return 0, ErrPostgresNotImplemented +} + +func (s *PostgresStore) UpdateUserProfile(userID int64, username string, avatar *string) error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) UpdateUserPassword(userID int64, newPasswordHash string) error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) UpdateUserStatus(id int64, status string) error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) UpdateUserTOTPSecret(id int64, secret *string) error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) UpdateUserRole(userID, roleID int64) error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) ResetAllUserStatuses() error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) DeleteAccount(ctx context.Context, userID int64) error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) ListMembers() ([]db.MemberSummary, error) { + return nil, ErrPostgresNotImplemented +} + +// ── SessionStore (stubs) ──────────────────────────────────────────────────── + +func (s *PostgresStore) CreateSession(userID int64, tokenHash, device, ip string) (int64, error) { + return 0, ErrPostgresNotImplemented +} + +func (s *PostgresStore) GetSessionByTokenHash(tokenHash string) (*db.Session, error) { + return nil, ErrPostgresNotImplemented +} + +func (s *PostgresStore) GetSessionWithBanStatus(tokenHash string) (*db.SessionWithBanStatus, error) { + return nil, ErrPostgresNotImplemented +} + +func (s *PostgresStore) DeleteSession(tokenHash string) error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) DeleteOtherSessions(userID, keepSessionID int64) (int64, error) { + return 0, ErrPostgresNotImplemented +} + +func (s *PostgresStore) DeleteExpiredSessions() error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) DeleteSessionByID(sessionID, userID int64) error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) TouchSession(tokenHash string) error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) ListUserSessions(userID int64) ([]db.Session, error) { + return nil, ErrPostgresNotImplemented +} + +func (s *PostgresStore) ForceLogoutUser(userID int64) error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) GetUserSessions(userID int64) ([]db.Session, error) { + return nil, ErrPostgresNotImplemented +} + +// ── RoleStore (stubs) ─────────────────────────────────────────────────────── + +func (s *PostgresStore) GetRoleByID(id int64) (*db.Role, error) { + return nil, ErrPostgresNotImplemented +} + +func (s *PostgresStore) GetRoleForUser(userID int64) (*db.Role, error) { + return nil, ErrPostgresNotImplemented +} + +func (s *PostgresStore) GetUserWithRole(userID int64) (*db.User, *db.Role, error) { + return nil, nil, ErrPostgresNotImplemented +} + +func (s *PostgresStore) ListRoles() ([]*db.Role, error) { + return nil, ErrPostgresNotImplemented +} + +// ── InviteStore (stubs) ───────────────────────────────────────────────────── + +func (s *PostgresStore) CreateInvite(createdBy int64, maxUses int, expiresAt *time.Time) (string, error) { + return "", ErrPostgresNotImplemented +} + +func (s *PostgresStore) GetInvite(code string) (*db.Invite, error) { + return nil, ErrPostgresNotImplemented +} + +func (s *PostgresStore) ListInvites() ([]*db.Invite, error) { + return nil, ErrPostgresNotImplemented +} + +func (s *PostgresStore) UseInviteAtomic(code string) error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) RevokeInvite(code string) error { + return ErrPostgresNotImplemented +} + +// ── VoiceStore (stubs) ────────────────────────────────────────────────────── + +func (s *PostgresStore) JoinVoiceChannel(userID, channelID int64) error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) JoinVoiceChannelIfCapacity(userID, channelID int64, maxUsers int) error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) LeaveVoiceChannel(userID int64) error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) LeaveVoiceChannelIfMatch(userID, expectedChannelID int64, expectedJoinedAt string) (bool, error) { + return false, ErrPostgresNotImplemented +} + +func (s *PostgresStore) GetVoiceState(userID int64) (*db.VoiceState, error) { + return nil, ErrPostgresNotImplemented +} + +func (s *PostgresStore) GetChannelVoiceStates(channelID int64) ([]db.VoiceState, error) { + return nil, ErrPostgresNotImplemented +} + +func (s *PostgresStore) GetAllVoiceStates() ([]db.VoiceState, error) { + return nil, ErrPostgresNotImplemented +} + +func (s *PostgresStore) UpdateVoiceMute(userID int64, muted bool) error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) UpdateVoiceDeafen(userID int64, deafened bool) error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) ClearVoiceState(userID int64) error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) ClearAllVoiceStates() error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) CountActiveCameras(channelID int64) (int, error) { + return 0, ErrPostgresNotImplemented +} + +func (s *PostgresStore) UpdateVoiceCamera(userID int64, camera bool) error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) EnableCameraIfUnderLimit(userID, channelID int64, maxVideo int) (bool, error) { + return false, ErrPostgresNotImplemented +} + +func (s *PostgresStore) UpdateVoiceScreenshare(userID int64, screenshare bool) error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) CountChannelVoiceUsers(channelID int64) (int, error) { + return 0, ErrPostgresNotImplemented +} + +// ── DMStore (stubs) ───────────────────────────────────────────────────────── + +func (s *PostgresStore) GetOrCreateDMChannel(user1ID, user2ID int64) (*db.Channel, bool, error) { + return nil, false, ErrPostgresNotImplemented +} + +func (s *PostgresStore) GetUserDMChannels(userID int64) ([]db.DMChannelInfo, error) { + return nil, ErrPostgresNotImplemented +} + +func (s *PostgresStore) OpenDM(userID, channelID int64) error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) CloseDM(userID, channelID int64) error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) IsDMParticipant(userID, channelID int64) (bool, error) { + return false, ErrPostgresNotImplemented +} + +func (s *PostgresStore) GetDMParticipantIDs(channelID int64) ([]int64, error) { + return nil, ErrPostgresNotImplemented +} + +func (s *PostgresStore) GetDMRecipient(channelID, requestingUserID int64) (*db.User, error) { + return nil, ErrPostgresNotImplemented +} + +// ── BlockStore (stubs) ────────────────────────────────────────────────────── + +func (s *PostgresStore) BlockUser(blockerID, blockedID int64) error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) UnblockUser(blockerID, blockedID int64) error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) IsBlocked(blockerID, blockedID int64) (bool, error) { + return false, ErrPostgresNotImplemented +} + +func (s *PostgresStore) IsEitherBlocked(userA, userB int64) (bool, error) { + return false, ErrPostgresNotImplemented +} + +func (s *PostgresStore) ListBlockedUsers(blockerID int64) ([]int64, error) { + return nil, ErrPostgresNotImplemented +} + +// ── AttachmentStore (stubs) ───────────────────────────────────────────────── + +func (s *PostgresStore) CreateAttachment(id string, uploaderID int64, filename, storedAs, mimeType string, size int64, width, height *int) error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) GetAttachmentByID(id string) (*db.Attachment, error) { + return nil, ErrPostgresNotImplemented +} + +func (s *PostgresStore) GetAttachmentWithChannel(id string) (*db.AttachmentAccess, error) { + return nil, ErrPostgresNotImplemented +} + +func (s *PostgresStore) DeleteOrphanedAttachments(cutoff string) ([]string, error) { + return nil, ErrPostgresNotImplemented +} + +// ── AdminStore (stubs) ────────────────────────────────────────────────────── + +func (s *PostgresStore) UserCount() (int64, error) { + return 0, ErrPostgresNotImplemented +} + +func (s *PostgresStore) GetServerStats() (*db.ServerStats, error) { + return nil, ErrPostgresNotImplemented +} + +func (s *PostgresStore) ListAllUsers(limit, offset int) ([]db.UserWithRole, error) { + return nil, ErrPostgresNotImplemented +} + +func (s *PostgresStore) BanUser(id int64, reason string, expires *time.Time) error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) UnbanUser(id int64) error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) LogAudit(actorID int64, action, targetType string, targetID int64, detail string) error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) GetAuditLog(limit, offset int) ([]db.AuditEntry, error) { + return nil, ErrPostgresNotImplemented +} + +func (s *PostgresStore) AdminCreateChannel(name, chanType, category, topic string, position int) (int64, error) { + return 0, ErrPostgresNotImplemented +} + +func (s *PostgresStore) AdminUpdateChannel(id int64, name, topic string, slowMode, position int, archived bool) error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) AdminDeleteChannel(id int64) error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) BackupTo(path string) error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) BackupToSafe(path, safeRoot string) error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) CountUsersWithoutTOTP() (int, error) { + return 0, ErrPostgresNotImplemented +} + +// ── SettingsStore (stubs) ─────────────────────────────────────────────────── + +func (s *PostgresStore) GetSetting(key string) (string, error) { + return "", ErrPostgresNotImplemented +} + +func (s *PostgresStore) SetSetting(key, value string) error { + return ErrPostgresNotImplemented +} + +func (s *PostgresStore) GetAllSettings() (map[string]string, error) { + return nil, ErrPostgresNotImplemented +} + +// Compile-time interface check — fails to compile if any Store method is +// missing a PostgresStore receiver. +var _ Store = (*PostgresStore)(nil) diff --git a/phase-a-foundation.md b/phase-a-foundation.md index 85c17387..17633a7a 100644 --- a/phase-a-foundation.md +++ b/phase-a-foundation.md @@ -192,3 +192,112 @@ Refactor to an internal pub/sub model. Each chat channel maps to a topic. Connec | Cleanly separates connection management from message routing | Priority queue implementation adds complexity | | Enables priority tiers and backpressure handling | Testing pub/sub is more involved than testing a simple loop | | Global rate limiting becomes straightforward per-topic | | + +--- + +## Phase A Implementation Status + +**Branch:** `claude/phase-a-foundation-plan-eUys1` + +This section tracks what shipped on this branch versus what is still pending. + +### Done + +- **Step 1 — Service layer + permission cache.** `Server/service/` contains 12 service files covering message, channel, permission, moderation, user, dm, block, invite, voice. Services depend on `store.Store`, not `*db.DB`. PermissionService maintains a per-user cache. REST and WS handlers (auth, channel, dm, profile, upload, chat, reaction, presence, voice) are migrated. Service tests live in `service/message_test.go` and `service/permission_test.go`. +- **Step 2 — sqlc adoption.** `Server/sqlc.yaml` configures both the SQLite engine (queries in `Server/db/queries/sqlite/`, generated output in `Server/db/dbgen/`) and the PostgreSQL engine (queries in `Server/db/queries/postgres/`, output in `Server/db/pgdbgen/`). sqlc v1.30.0 is pinned in `Server/sqlc.version`. `Server/Makefile` exposes `sqlc-install`, `sqlc-generate`, `sqlc-verify` targets covering both engines. 14 SQL query files per engine cover all DB domains; the SQLite `dbgen` package is committed, the PostgreSQL `pgdbgen` package will be generated on the next `make sqlc-generate` run. FTS search queries remain hand-written; transactional multi-step operations are unchanged. +- **Step 3 (partial) — Store interface + SQLiteStore + MemStore.** `Server/store/store.go` defines the full Store interface (12 sub-interfaces). `Server/store/sqlite.go` wraps `*db.DB`. `Server/store/memstore.go` provides an in-memory implementation used by service tests. +- **Step 4 — Logging consolidation.** OwnCord code uses `log/slog` exclusively. `go.uber.org/zap` and `rs/zerolog` appear only as transitive dependencies of livekit and are not imported by any OwnCord `.go` file. The plan's "pick one and find-and-replace" item is satisfied. +- **Step 5 — Pub/sub broadcast model.** `Server/ws/pubsub.go`, `topic_rate_limiter.go`, `ringbuffer.go`, and the three-tier priority queue (commit `7ccc93f`) implement the topic-based broadcast model with global rate limits, backpressure, and priority tiers. + +### Pending + +- **Step 3 — PostgreSQL backend (final wiring).** Scaffolding has landed on this branch: + - `Server/migrations/postgres/001_initial_schema.sql` — consolidated postgres schema with `tsvector` + GIN full-text search, `CITEXT` usernames, native CHECK constraints replacing SQLite triggers, and seeded role/setting rows. + - `Server/migrations/postgres/migrations.go` — embed FS for the postgres migration set. + - `Server/db/queries/postgres/*.sql` — 14 query files (users, sessions, roles, invites, channels, messages, reactions, voice, attachments, admin, dm, blocks, lockouts, profile) translated to postgres dialect: `$N` placeholders, `NOW()`/native `TIMESTAMPTZ`, `TRUE`/`FALSE` for BOOLEAN columns, `ON CONFLICT ... DO UPDATE` for upserts, `RETURNING id` for creates (postgres has no `LastInsertId`), `:execrows` for mutations that need rows-affected. + - `Server/sqlc.yaml` — second engine entry (`engine: "postgresql"`, `sql_package: "pgx/v5"`) generating into the `pgdbgen` package under `Server/db/pgdbgen/`. + - `Server/Makefile` — `sqlc-generate` and `sqlc-verify` cover both engines. + - `Server/store/postgres.go` — `PostgresStore` type behind the `//go:build postgres` tag, implementing the full `store.Store` interface. Connection lifecycle (`OpenPostgres`, `Close`, `SQLDb`, `WithTx`) is fully implemented using `database/sql` + the pgx stdlib driver. Query methods are stubs that return `ErrPostgresNotImplemented`, waiting for the `pgdbgen` querier to land so they can be replaced with wrappers around generated code. The build tag keeps pgx out of the default build; operators who want to enable postgres run `go get github.com/jackc/pgx/v5 && go build -tags postgres ./...`. + - `Server/config/config.go` — `DatabaseConfig` extended with `Type`, `Host`, `Port`, `User`, `Password`, `Name`, `SSLMode`, `MaxConns`. Defaults set to `type: "sqlite"` so existing operators are unaffected. + - `Server/main.go` — explicit dispatch on `database.type`. Selecting `postgres` produces a clear startup error pointing at the remaining work, instead of silently falling through to sqlite. + + What still needs to land for postgres to be runnable: + 1. Add `github.com/jackc/pgx/v5` to `go.mod` and run `go mod tidy`. This happens naturally the first time an operator runs `go get github.com/jackc/pgx/v5` — the package only needs to be in the module graph when building with `-tags postgres`. + 2. Run `make sqlc-generate` to produce `Server/db/pgdbgen/` from the committed query files. This requires either network access to download sqlc or a pre-installed `sqlc` binary at v1.30.0. + 3. Replace the stub query methods in `Server/store/postgres.go` with real implementations that wrap the generated `pgdbgen` querier. The connection lifecycle and interface assertion are already in place; each stub carries the same method signature as the sqlite version, so the migration is mechanical. Where the schema produces different Go types than sqlite (e.g. `bool` vs `int64` for boolean columns, `time.Time` vs `string` for timestamps), the store wrapper performs the conversion so services see a uniform API. + 4. Refactor `main.go` and `Server/api/router.go` to thread `store.Store` through the boundary instead of `*db.DB`. Today the router constructs `dbstore.NewSQLiteStore(database)` inline, so services are store-aware but everything else still consumes `*db.DB` directly. The store-everywhere migration is the gating step before either backend can be swapped at runtime. + 5. FTS dispatch in `MessageStore.SearchMessages` / `SearchMessagesInChannels` — sqlite uses `MATCH` against the FTS5 virtual table, postgres uses `@@ to_tsquery(...)` against the `messages.fts` tsvector column. Both remain hand-written (outside the sqlc-generated set) for their respective backends. + 6. CI matrix to run the test suite against both backends. + 7. **One-way sqlite → postgres data migration.** Operators who start a community on the default sqlite backend and later outgrow it must be able to carry their history over. The migration must be forward-only (once postgres is selected, the server stays on postgres unless the operator deliberately wipes the postgres database and re-initialises), both to keep the contract simple and to avoid the support burden of "I reverted to sqlite yesterday and now my data is gone". + + Proposed design: + - A one-shot CLI flag, e.g. `chatserver --migrate-to-postgres`, that exits after completion. No continuous sync. + - Pre-flight checks: `database.type` in config is already `postgres`; postgres connection succeeds; the target postgres database contains no rows in `users` (or equivalent sentinel) — if it does, refuse to run, printing the exact row count so the operator can confirm they meant to target this database. + - Open sqlite read-only alongside postgres. Begin one postgres transaction for the entire migration so partial failures roll back cleanly. + - Copy rows in foreign-key order (`roles` → `users` → `channels` → `channel_overrides` → `messages` → `reactions` → `attachments` → `invites` → `sessions` → `voice_states` → `dm_participants` → `dm_open_state` → `read_states` → `audit_log` → `user_blocks` → `rate_lockouts` → `settings` → `emoji` → `sounds`). Disable the `trg_messages_fts_update` trigger for the bulk copy and re-populate `messages.fts` in a single `UPDATE messages SET fts = to_tsvector('simple', content)` at the end, so FTS doesn't fire per row. + - Convert types at the boundary: sqlite RFC3339 strings → postgres `TIMESTAMPTZ` via `time.Parse`, sqlite `INTEGER` boolean (0/1) → postgres `BOOLEAN`. Drop the preserved `id` values straight through since both schemas are `BIGINT`-compatible. + - After copying, reset every postgres sequence with `SELECT setval('