phase-a: scaffold postgres backend (schema, queries, store stub, config)

- migrations/postgres/: consolidated pg schema with tsvector FTS, CITEXT
  usernames, native CHECK constraints, native BOOLEAN/TIMESTAMPTZ types
- db/queries/postgres/: 14 sqlc query files dialect-translated from sqlite
  ($N placeholders, NOW(), TRUE/FALSE, ON CONFLICT DO UPDATE, RETURNING id,
  :execrows for mutations needing row count)
- sqlc.yaml: second engine entry -> pgdbgen package under pgx/v5
- Makefile: sqlc-verify covers both dbgen + pgdbgen
- store/postgres.go: PostgresStore behind //go:build postgres, full Store
  interface (112 methods). Connection lifecycle real; query methods stub
  ErrPostgresNotImplemented awaiting pgdbgen wrappers
- config: DatabaseConfig.Type/Host/Port/User/Password/Name/SSLMode/MaxConns
- main.go: explicit dispatch on database.type; postgres errors with clear
  pointer at remaining work until pgdbgen + boundary refactor land
- phase-a-foundation.md: implementation status + actionable TODO checklist
  including forward-only sqlite->postgres data migration design
This commit is contained in:
Claude
2026-04-06 07:43:08 +00:00
parent 2a46b95111
commit dede2c61a7
22 changed files with 1688 additions and 6 deletions
+6 -5
View File
@@ -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 ; \
)
+38 -1
View File
@@ -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
+55
View File
@@ -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;
@@ -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;
+18
View File
@@ -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;
+69
View File
@@ -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;
+64
View File
@@ -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;
+23
View File
@@ -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;
+15
View File
@@ -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;
+79
View File
@@ -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;
+9
View File
@@ -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;
+12
View File
@@ -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;
+29
View File
@@ -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;
+49
View File
@@ -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;
+51
View File
@@ -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;
+88
View File
@@ -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;
+26
View File
@@ -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)
@@ -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);
+17
View File
@@ -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
+21
View File
@@ -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"
+579
View File
@@ -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)