feat: release v0.2.0 profiles, chat, freeze, and bots

Ship the product pass on the v0.1.3 schema with additive migrations, a replica migration lock, and bot accounts kept out of people lists.
This commit is contained in:
ahp
2026-10-10 16:42:35 +03:30
parent 8586bbafcc
commit 22d92c133d
85 changed files with 10510 additions and 1341 deletions
+17
View File
@@ -11,9 +11,26 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
)
// migrateLockID is held for the whole migration pass so two replicas cannot
// apply the same file at once. The lock lives on one pooled connection and
// is released when that session ends.
const migrateLockID int64 = 764727013
// Migrate runs all *.sql files in the migrations fs in lexicographic order.
// Tracks applied migrations in schema_migrations.
func Migrate(ctx context.Context, pool *pgxpool.Pool, migrations fs.FS) error {
conn, err := pool.Acquire(ctx)
if err != nil {
return fmt.Errorf("migrate lock: %w", err)
}
defer conn.Release()
if _, err := conn.Exec(ctx, `SELECT pg_advisory_lock($1)`, migrateLockID); err != nil {
return fmt.Errorf("migrate lock: %w", err)
}
defer func() {
_, _ = conn.Exec(context.Background(), `SELECT pg_advisory_unlock($1)`, migrateLockID)
}()
if _, err := pool.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations (version TEXT PRIMARY KEY, applied_at TIMESTAMPTZ NOT NULL DEFAULT now())`); err != nil {
return fmt.Errorf("create migrations table: %w", err)
}
+192
View File
@@ -0,0 +1,192 @@
package db
import (
"context"
"fmt"
"io/fs"
"os"
"strings"
"sync"
"testing"
"testing/fstest"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
func TestMigrateFromV013Schema(t *testing.T) {
user := os.Getenv("POSTGRES_USER")
password := os.Getenv("POSTGRES_PASSWORD")
if user == "" || password == "" {
t.Skip("postgres env not set")
}
host := "127.0.0.1"
port := "5432"
adminURL := fmt.Sprintf("postgres://%s:%s@%s:%s/postgres?sslmode=disable", user, password, host, port)
ctx := context.Background()
admin, err := pgxpool.New(ctx, adminURL)
if err != nil {
t.Skipf("postgres unavailable: %v", err)
}
defer admin.Close()
if err := admin.Ping(ctx); err != nil {
t.Skipf("postgres unavailable: %v", err)
}
const name = "pargar_upgrade_test"
_, _ = admin.Exec(ctx, `DROP DATABASE IF EXISTS `+name+` WITH (FORCE)`)
if _, err := admin.Exec(ctx, `CREATE DATABASE `+name); err != nil {
t.Fatalf("create database: %v", err)
}
t.Cleanup(func() {
_, _ = admin.Exec(context.Background(), `DROP DATABASE IF EXISTS `+name+` WITH (FORCE)`)
})
url := fmt.Sprintf("postgres://%s:%s@%s:%s/%s?sslmode=disable", user, password, host, port, name)
pool, err := Connect(ctx, url)
if err != nil {
t.Fatalf("connect: %v", err)
}
defer pool.Close()
sub, err := fs.Sub(MigrationFiles, "migrations")
if err != nil {
t.Fatal(err)
}
legacy := fstest.MapFS{}
entries, err := fs.ReadDir(sub, ".")
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") || entry.Name() >= "0023" {
continue
}
body, err := fs.ReadFile(sub, entry.Name())
if err != nil {
t.Fatal(err)
}
legacy[entry.Name()] = &fstest.MapFile{Data: body}
}
if len(legacy) == 0 {
t.Fatal("no v0.1.3 migrations")
}
if err := Migrate(ctx, pool, legacy); err != nil {
t.Fatalf("migrate through 0022: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO users (name, email, username, password_hash, role, hearts)
VALUES ('legacy', 'legacy@example.com', 'legacy', 'x', 'student', 3)`); err != nil {
t.Fatalf("seed hearts=3: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO users (name, email, username, password_hash, role, hearts)
VALUES ('over', 'over@example.com', 'over', 'x', 'student', 5)`); err == nil {
t.Fatal("hearts=5 was accepted on the v0.1.3 constraint")
}
if err := Migrate(ctx, pool, sub); err != nil {
t.Fatalf("upgrade 0023-0028: %v", err)
}
if _, err := pool.Exec(ctx, `
INSERT INTO users (name, email, username, password_hash, role, hearts)
VALUES ('over', 'over@example.com', 'over', 'x', 'student', 5)`); err != nil {
t.Fatalf("hearts=5 after upgrade: %v", err)
}
var hearts int
var locked bool
if err := pool.QueryRow(ctx, `SELECT hearts, is_locked FROM users WHERE email='legacy@example.com'`).Scan(&hearts, &locked); err != nil {
t.Fatal(err)
}
if hearts != 3 || locked {
t.Fatalf("legacy account changed: hearts=%d locked=%v", hearts, locked)
}
var constraint string
if err := pool.QueryRow(ctx, `SELECT pg_get_constraintdef(oid) FROM pg_constraint WHERE conname='users_hearts_max'`).Scan(&constraint); err != nil {
t.Fatal(err)
}
if !strings.Contains(constraint, "5") {
t.Fatalf("hearts constraint: %s", constraint)
}
var labels string
if err := pool.QueryRow(ctx, `
SELECT string_agg(e.enumlabel, ',' ORDER BY e.enumsortorder)
FROM pg_enum e
JOIN pg_type t ON t.oid = e.enumtypid
WHERE t.typname = 'notif_category'`).Scan(&labels); err != nil {
t.Fatal(err)
}
for _, want := range []string{"urgent", "curriculum", "community", "system"} {
if !strings.Contains(labels, want) {
t.Fatalf("enum %s missing from %s", want, labels)
}
}
var cols int
if err := pool.QueryRow(ctx, `
SELECT count(*) FROM information_schema.columns
WHERE table_name='users' AND column_name IN ('last_seen_at','username_change_count','frozen_at','closed_at')`).Scan(&cols); err != nil {
t.Fatal(err)
}
if cols != 4 {
t.Fatalf("user columns: %d", cols)
}
var attachments bool
if err := pool.QueryRow(ctx, `
SELECT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name='chat_messages' AND column_name='attachments'
)`).Scan(&attachments); err != nil {
t.Fatal(err)
}
if !attachments {
t.Fatal("chat attachments column missing")
}
var botCols int
if err := pool.QueryRow(ctx, `
SELECT count(*) FROM information_schema.columns
WHERE table_name='bot_tokens' AND column_name IN ('request_count','revoked_at')`).Scan(&botCols); err != nil {
t.Fatal(err)
}
if botCols != 2 {
t.Fatalf("bot token columns: %d", botCols)
}
var before int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM schema_migrations`).Scan(&before); err != nil {
t.Fatal(err)
}
if err := Migrate(ctx, pool, sub); err != nil {
t.Fatalf("second migrate: %v", err)
}
var after int
if err := pool.QueryRow(ctx, `SELECT count(*) FROM schema_migrations`).Scan(&after); err != nil {
t.Fatal(err)
}
if before != after {
t.Fatalf("repeat migrate wrote rows: %d -> %d", before, after)
}
lockCtx, cancel := context.WithTimeout(ctx, 15*time.Second)
defer cancel()
var wg sync.WaitGroup
errCh := make(chan error, 2)
for range 2 {
wg.Add(1)
go func() {
defer wg.Done()
errCh <- Migrate(lockCtx, pool, sub)
}()
}
wg.Wait()
close(errCh)
for err := range errCh {
if err != nil {
t.Fatalf("parallel migrate: %v", err)
}
}
}
@@ -0,0 +1,71 @@
ALTER TYPE notif_category ADD VALUE IF NOT EXISTS 'urgent';
ALTER TYPE notif_category ADD VALUE IF NOT EXISTS 'curriculum';
ALTER TYPE notif_category ADD VALUE IF NOT EXISTS 'community';
ALTER TYPE notif_category ADD VALUE IF NOT EXISTS 'system';
CREATE TABLE IF NOT EXISTS announcements (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
body TEXT NOT NULL,
category TEXT NOT NULL DEFAULT 'event',
route TEXT NOT NULL DEFAULT '',
pinned BOOLEAN NOT NULL DEFAULT false,
created_by BIGINT REFERENCES users(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE notifications ADD COLUMN IF NOT EXISTS announcement_id BIGINT REFERENCES announcements(id) ON DELETE CASCADE;
CREATE TABLE IF NOT EXISTS chat_prefs (
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
partner_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
pinned_rank INT,
muted BOOLEAN NOT NULL DEFAULT false,
archived BOOLEAN NOT NULL DEFAULT false,
PRIMARY KEY (user_id, partner_id)
);
ALTER TABLE users ADD COLUMN IF NOT EXISTS chat_muted_all BOOLEAN NOT NULL DEFAULT false;
CREATE TABLE IF NOT EXISTS saved_messages (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
body TEXT NOT NULL DEFAULT '',
attachment_url TEXT NOT NULL DEFAULT '',
attachment_type TEXT NOT NULL DEFAULT '',
attachment_name TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS user_profiles (
user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
banner_photo TEXT NOT NULL DEFAULT '',
profile_public BOOLEAN NOT NULL DEFAULT false,
show_activity BOOLEAN NOT NULL DEFAULT true,
show_stats BOOLEAN NOT NULL DEFAULT true,
show_path BOOLEAN NOT NULL DEFAULT true,
show_badges BOOLEAN NOT NULL DEFAULT true,
social_links JSONB NOT NULL DEFAULT '[]',
theme TEXT NOT NULL DEFAULT 'system'
);
CREATE TABLE IF NOT EXISTS bot_tokens (
id BIGSERIAL PRIMARY KEY,
bot_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
owner_id BIGINT NOT NULL REFERENCES users(id),
token_hash TEXT NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE IF NOT EXISTS bot_updates (
id BIGSERIAL PRIMARY KEY,
bot_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
payload JSONB NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
ALTER TABLE chat_messages ADD COLUMN IF NOT EXISTS buttons JSONB NOT NULL DEFAULT '[]';
ALTER TABLE users ALTER COLUMN hearts SET DEFAULT 5;
ALTER TABLE users DROP CONSTRAINT IF EXISTS users_hearts_max;
ALTER TABLE users ADD CONSTRAINT users_hearts_max CHECK (hearts BETWEEN 0 AND 5);
@@ -0,0 +1 @@
ALTER TABLE users ADD COLUMN IF NOT EXISTS last_seen_at TIMESTAMPTZ;
@@ -0,0 +1,2 @@
ALTER TABLE chat_messages
ADD COLUMN IF NOT EXISTS attachments JSONB;
@@ -0,0 +1,2 @@
ALTER TABLE users ADD COLUMN IF NOT EXISTS username_change_count INTEGER NOT NULL DEFAULT 0;
ALTER TABLE users ADD COLUMN IF NOT EXISTS username_cooldown_until TIMESTAMPTZ;
@@ -0,0 +1,2 @@
ALTER TABLE users ADD COLUMN IF NOT EXISTS frozen_at TIMESTAMPTZ;
ALTER TABLE users ADD COLUMN IF NOT EXISTS closed_at TIMESTAMPTZ;
@@ -0,0 +1,2 @@
ALTER TABLE bot_tokens ADD COLUMN IF NOT EXISTS request_count BIGINT NOT NULL DEFAULT 0;
ALTER TABLE bot_tokens ADD COLUMN IF NOT EXISTS revoked_at TIMESTAMPTZ;