diff --git a/Server/CLAUDE.md b/Server/CLAUDE.md index d259d576..5448de2a 100644 --- a/Server/CLAUDE.md +++ b/Server/CLAUDE.md @@ -11,7 +11,10 @@ prometheus. - `db/` hand-written query wrappers; `db/dbgen/` is generated (see `db-change`) - `cmd/` executable tooling, one `package main` per subdirectory — `cmd/genprotocol/` regenerates the protocol constants from `protocol/schema.json`, - `cmd/seed/` fills a dev database (`go run ./cmd/seed -confirm-dev`), + `cmd/seed/` fills a dev database (`go run ./cmd/seed -confirm-dev`; add + `-profile alpha` for the deterministic B3-7 dataset behind + `Server/testdata/snapshots/v1.2.0-alpha.4.sqlite` — regenerate that snapshot + only deliberately, per its README), `cmd/dbinventory/` prints the `db`-importer table for `docs/architecture/server-boundaries.md` (exits 1 on an unlisted importer). `cmd/gendocs/` rewrites the route, table and config-key index blocks in diff --git a/Server/cmd/seed/main.go b/Server/cmd/seed/main.go index de5980e7..9615cdb5 100644 --- a/Server/cmd/seed/main.go +++ b/Server/cmd/seed/main.go @@ -129,6 +129,9 @@ var seedDMMessages = []struct { func main() { dbPath := flag.String("db", "data/chatserver.db", "path to the SQLite database file") confirmDev := flag.Bool("confirm-dev", false, "confirm this is a development database (required)") + profile := flag.String("profile", "dev", `seed profile: "dev" (small, idempotent) or "alpha" (deterministic alpha-shaped dataset; empty database only)`) + snapshotPath := flag.String("snapshot", "", "alpha profile only: scrub and VACUUM INTO this path after seeding") + scrubPath := flag.String("scrub", "", "alpha profile only: SQL scrub script applied before -snapshot (testdata/snapshots/scrub.sql)") flag.Parse() if !*confirmDev { @@ -153,7 +156,16 @@ func main() { log.Fatalf("failed to open database at %s: %v", *dbPath, err) } - exitCode := run(database) + var exitCode int + switch *profile { + case "dev": + exitCode = run(database) + case "alpha": + exitCode = runAlpha(database, *snapshotPath, *scrubPath) + default: + log.Printf("unknown -profile %q (want dev or alpha)", *profile) + exitCode = 2 + } _ = database.Close() os.Exit(exitCode) } diff --git a/Server/cmd/seed/profile_alpha.go b/Server/cmd/seed/profile_alpha.go new file mode 100644 index 00000000..d18e6a28 --- /dev/null +++ b/Server/cmd/seed/profile_alpha.go @@ -0,0 +1,769 @@ +// The alpha-shaped profile (B3-7, roadmap workstream 12). +// +// `go run ./cmd/seed -confirm-dev -profile alpha` fills an EMPTY database with +// a deterministic dataset shaped like a real 1.2.0-alpha.4 server after a +// month of use. The same profile, scrubbed and vacuumed, is the committed +// snapshot at Server/testdata/snapshots/v1.2.0-alpha.4.sqlite that B4's HP-4 +// drills, B6's upgrade rehearsal and B10's in-place upgrade consume — the +// schema has not changed since the v1.2.0-alpha.4 tag (zero migrations added), +// so a database seeded on today's migrations IS an alpha.4-schema database. +// +// Determinism is the contract: fixed rng seed, fixed clock, a constant bcrypt +// hash (live hashing salts every run), explicit ids and timestamps on every +// row, one insert order, and `VACUUM INTO` as the byte-canonical output. +// TestAlphaProfileByteIdentical holds it: two runs, two files, bytes.Equal. +// +// The numbers below are the plan's (b3-server-architecture-guardrails +// §B3-7). A number found unrepresentative is changed HERE with the reason in +// the B3-7 evidence block — never silently. Two constants deliberately leave +// no rows, recorded rather than hidden: +// +// - alphaVoiceSessions: voice is LiveKit-ephemeral. A finished session +// leaves no at-rest row in this schema (voice_states is live presence and +// empties on disconnect), so a cold snapshot of a quiescent server +// carries none. The constant stays because the load harness and any +// future voice-history feature consume the same shape. +// - events: the replay log is empty, as on any server restarted for an +// upgrade — exactly the state the snapshot's consumers rehearse. +// Fabricated frames would be replayed verbatim to real clients. +// +// The seeded accounts all use the password "alpha-dev-password" (constant +// hash below), which is why -confirm-dev stays mandatory. +package main + +import ( + "context" + "database/sql" + "fmt" + "log" + "math/rand" + "os" + "sort" + "strings" + "time" + + "github.com/J3vb/OwnCord/Server/db" + "github.com/J3vb/OwnCord/Server/permissions" +) + +// ─── The profile, as constants ────────────────────────────────────────────── + +const ( + alphaSeed = 20260831 // fixed rng seed; changing it is changing the dataset + + alphaUsers = 100 // 1 owner + 2 admins + 5 moderators + 92 members + alphaOwners = 1 + alphaAdmins = 2 + alphaModerators = 5 + + alphaChannels = 12 // 10 text + 2 voice; 3 role-override, 2 user-override, 1 archived + alphaMessages = 20000 + alphaWindowDays = 30 + alphaDMShare = 0.15 // exactly 3,000 of the 20,000 land in DMs + alphaDMPairs = 40 + alphaAttachments = 300 // image/audio/video/other 60/10/10/20 % + alphaReactions = 500 + alphaInvites = 30 // 10 of them revoked + alphaInvitesRevkd = 10 + alphaPluginRows = 1 // disabled + + // alphaVoiceSessions is a shape parameter with no at-rest rows — see the + // package comment. 1–45 min, 2–6 participants when a live harness uses it. + alphaVoiceSessions = 200 + + // alphaPasswordHash is bcrypt("alpha-dev-password"), fixed so two seed + // runs produce identical bytes. Weak and public by design: -confirm-dev. + //nolint:gosec // G101: a deliberately public dev-profile hash ("alpha-dev-password"); -confirm-dev gates every use + alphaPasswordHash = "$2a$12$EBrRXmplT1ryU0o/HzELSePreo.gK5.z5Tjo4ec/ISchy5gKwxtQq" +) + +// alphaWindowEnd is the fixed clock: the last instant of the simulated month. +var alphaWindowEnd = time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC) + +// alphaHourWeights shapes the diurnal curve — quiet nights, an evening peak. +// Relative weights per UTC hour; largest-remainder allocation makes the +// per-bucket counts sum exactly to the day's total. +var alphaHourWeights = [24]int{ + 1, 1, 1, 1, 1, 2, // 00–05 + 3, 5, 7, 8, 8, 8, // 06–11 + 9, 9, 8, 8, 9, 10, // 12–17 + 11, 12, 11, 8, 5, 2, // 18–23 +} + +// alphaLexicon feeds the message generator. Plain words only: the content is +// synthetic on purpose and never pretends to be a real person's text. +var alphaLexicon = []string{ + "the", "a", "we", "you", "it", "that", "this", "just", "really", "pretty", + "server", "channel", "voice", "call", "message", "update", "build", "bug", + "fix", "release", "game", "night", "stream", "clip", "map", "round", + "team", "later", "today", "tomorrow", "tonight", "morning", "again", + "works", "broke", "fixed", "joined", "left", "restarted", "uploaded", + "good", "great", "weird", "slow", "fast", "new", "old", "same", + "anyone", "someone", "everyone", "nobody", "who", "what", "when", "why", + "up", "down", "in", "out", "on", "off", "for", "with", "about", "after", + "lol", "nice", "thanks", "ok", "sure", "maybe", "yes", "no", "brb", "gg", +} + +// ─── Channel definitions ──────────────────────────────────────────────────── + +type alphaChannel struct { + name, ctype, category, topic string + position int + archived bool +} + +// Ten text and two voice channels. Indices are ids-1; the overrides below +// refer to them by id. Channel 10 is the archived one and only carries +// early-window history. +var alphaChannelDefs = []alphaChannel{ + {"welcome", "text", "Information", "Start here — rules and invites", 0, false}, + {"announcements", "text", "Information", "Server news; staff post, everyone reads", 1, false}, + {"general", "text", "Text Channels", "General chat", 2, false}, + {"random", "text", "Text Channels", "Off-topic", 3, false}, + {"gaming", "text", "Text Channels", "What are we playing", 4, false}, + {"media", "text", "Text Channels", "Clips, screenshots, links", 5, false}, + {"dev", "text", "Text Channels", "Server tinkering and self-hosting", 6, false}, + {"support", "text", "Text Channels", "Ask for help here", 7, false}, + {"staff", "text", "Staff", "Staff-only coordination", 8, false}, + {"archive-2026-06", "text", "Archive", "June history, read-only", 9, true}, + {"Lounge", "voice", "Voice", "", 10, false}, + {"Game Night", "voice", "Voice", "", 11, false}, +} + +// ─── Entry point ──────────────────────────────────────────────────────────── + +// runAlpha is the -profile alpha path: migrate, seed, optionally scrub and +// write the byte-canonical snapshot. +func runAlpha(database *db.DB, snapshotPath, scrubPath string) int { + if err := db.Migrate(database); err != nil { + log.Printf("failed to run migrations: %v", err) + return 1 + } + if err := seedAlpha(database); err != nil { + log.Printf("%v", err) + return 1 + } + fmt.Println("--- Alpha profile seeded ---") + fmt.Printf(" Users: %d\n", alphaUsers) + fmt.Printf(" Channels: %d (+%d DM channels)\n", alphaChannels, alphaDMPairs) + fmt.Printf(" Messages: %d over %d days (%.0f%% in DMs)\n", alphaMessages, alphaWindowDays, alphaDMShare*100) + fmt.Printf(" Attachments: %d · Reactions: %d · Invites: %d (%d revoked) · Plugins: %d\n", + alphaAttachments, alphaReactions, alphaInvites, alphaInvitesRevkd, alphaPluginRows) + fmt.Printf(" Voice sessions: %d — a shape parameter; a finished session leaves no at-rest row (package comment)\n", alphaVoiceSessions) + fmt.Println(" Password for every account: alpha-dev-password") + if snapshotPath != "" { + if scrubPath == "" { + log.Printf("-snapshot requires -scrub (the committed scrub script)") + return 1 + } + if err := writeSnapshot(database, scrubPath, snapshotPath); err != nil { + log.Printf("%v", err) + return 1 + } + fmt.Printf(" Snapshot: %s\n", snapshotPath) + } + return 0 +} + +// seedAlpha fills an empty database with the profile. It refuses a non-empty +// one: determinism is per-database-lifetime, and "empty" is the only state +// two runs can share. +func seedAlpha(database *db.DB) error { + var users int + if err := database.QueryRowContext(context.Background(), + `SELECT COUNT(*) FROM users`).Scan(&users); err != nil { + return fmt.Errorf("alpha: counting users: %w", err) + } + if users > 0 { + return fmt.Errorf("alpha: the profile seeds only an empty database (found %d users); point -db at a fresh file", users) + } + + //nolint:gosec // G404: byte-for-byte determinism is this profile's contract; nothing here is cryptographic + rng := rand.New(rand.NewSource(alphaSeed)) + windowStart := alphaWindowEnd.AddDate(0, 0, -alphaWindowDays) + joined := alphaJoinTimes(rng, windowStart) + + tx, err := database.BeginTx(context.Background(), &sql.TxOptions{}) + if err != nil { + return fmt.Errorf("alpha: begin: %w", err) + } + defer func() { _ = tx.Rollback() }() + + if err := alphaInsertUsers(tx, rng, joined); err != nil { + return err + } + if err := alphaInsertChannels(tx, windowStart); err != nil { + return err + } + pairs, err := alphaInsertDMs(tx, rng, joined) + if err != nil { + return err + } + messageTimes, messageAuthors, err := alphaInsertMessages(tx, rng, windowStart, joined, pairs) + if err != nil { + return err + } + if err := alphaInsertAttachments(tx, rng, messageTimes, messageAuthors); err != nil { + return err + } + if err := alphaInsertReactions(tx, rng); err != nil { + return err + } + if err := alphaInsertInvites(tx, rng, windowStart); err != nil { + return err + } + if _, err := tx.Exec( + `INSERT INTO plugins (id, name, version, enabled, manifest_json, installed_at) VALUES (1, ?, ?, 0, ?, ?)`, + "example-echo", "0.1.0", + `{"name":"example-echo","version":"0.1.0","description":"seed profile placeholder; never enabled"}`, + ts(windowStart.Add(24*time.Hour)), + ); err != nil { + return fmt.Errorf("alpha: plugin row: %w", err) + } + + if err := tx.Commit(); err != nil { + return fmt.Errorf("alpha: commit: %w", err) + } + return nil +} + +// writeSnapshot applies the scrub script and writes the byte-canonical +// snapshot via VACUUM INTO (which refuses to overwrite; remove a stale +// target first so regeneration is one command). +func writeSnapshot(database *db.DB, scrubPath, outPath string) error { + scrub, err := os.ReadFile(scrubPath) + if err != nil { + return fmt.Errorf("alpha: reading scrub script: %w", err) + } + // Strip comment lines first, then split on ";" — a ";" inside a comment + // must not end a statement. The scrub script holds no string literal + // containing ";", which is the simplicity this parser is allowed. + var sqlOnly []string + for line := range strings.SplitSeq(string(scrub), "\n") { + if strings.HasPrefix(strings.TrimSpace(line), "--") { + continue + } + sqlOnly = append(sqlOnly, line) + } + for stmt := range strings.SplitSeq(strings.Join(sqlOnly, "\n"), ";") { + stmt = strings.TrimSpace(stmt) + if stmt == "" { + continue + } + if _, err := database.ExecContext(context.Background(), stmt); err != nil { + return fmt.Errorf("alpha: scrub statement %q: %w", stmt[:min(40, len(stmt))], err) + } + } + if err := os.Remove(outPath); err != nil && !os.IsNotExist(err) { + return fmt.Errorf("alpha: removing stale snapshot: %w", err) + } + quoted := strings.ReplaceAll(outPath, "'", "''") + if _, err := database.ExecContext(context.Background(), "VACUUM INTO '"+quoted+"'"); err != nil { + return fmt.Errorf("alpha: VACUUM INTO %s: %w", outPath, err) + } + return nil +} + +// ─── Users ────────────────────────────────────────────────────────────────── + +// alphaJoinTimes computes every user's created_at once — the message and DM +// generators consult it so nothing is authored before its author joined (a +// fixture with time-travelling rows would poison the retention and +// account-age rehearsals that consume it; Codex on #1469, P2). Monotonic in +// the user id — the first forty accounts predate the window (an established +// server; all staff are among them), the remaining sixty join during it in +// id order — so "who exists at time t" is always the prefix 1..N. +func alphaJoinTimes(rng *rand.Rand, windowStart time.Time) []time.Time { + joined := make([]time.Time, alphaUsers+1) + for i := 1; i <= alphaUsers; i++ { + if i <= 40 { + joined[i] = windowStart.AddDate(0, 0, -60).Add(time.Duration(i) * 26 * time.Hour) + continue + } + // Spacing is 12h, jitter under 1h: monotonicity holds by construction. + into := time.Duration(i-41) * (time.Duration(alphaWindowDays) * 24 * time.Hour) / 60 + joined[i] = windowStart.Add(into).Add(time.Duration(rng.Intn(3599)) * time.Second) + } + return joined +} + +func alphaInsertUsers(tx *sql.Tx, rng *rand.Rand, joined []time.Time) error { + roleOf := func(i int) int { // i is 1-based user number + switch { + case i <= alphaOwners: + return 1 + case i <= alphaOwners+alphaAdmins: + return 2 + case i <= alphaOwners+alphaAdmins+alphaModerators: + return 3 + default: + return 4 + } + } + + const cols = 7 + var b strings.Builder + args := make([]any, 0, alphaUsers*cols) + for i := 1; i <= alphaUsers; i++ { + if i > 1 { + b.WriteString(",") + } + b.WriteString("(?,?,?,?,?,?,?)") + created := joined[i] + lastSeen := alphaWindowEnd.Add(-time.Duration(rng.Intn(72*3600)) * time.Second) + if lastSeen.Before(created) { + // A user who joined in the window's final days was last seen at + // the join, not before it. + lastSeen = created + } + args = append(args, + i, fmt.Sprintf("user%03d", i), alphaPasswordHash, roleOf(i), + "offline", ts(created), ts(lastSeen), + ) + } + _, err := tx.Exec( + //nolint:gosec // G202: the concatenation is a builder of "(?,…)" placeholder groups; every value is bound + `INSERT INTO users (id, username, password, role_id, status, created_at, last_seen) VALUES `+b.String(), + args..., + ) + if err != nil { + return fmt.Errorf("alpha: users: %w", err) + } + return nil +} + +// ─── Channels and overrides ───────────────────────────────────────────────── + +func alphaInsertChannels(tx *sql.Tx, windowStart time.Time) error { + created := windowStart.AddDate(0, 0, -60) + for i, c := range alphaChannelDefs { + archived := 0 + if c.archived { + archived = 1 + } + if _, err := tx.Exec( + `INSERT INTO channels (id, name, type, category, topic, position, archived, is_group, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, 0, ?)`, + i+1, c.name, c.ctype, c.category, c.topic, c.position, archived, ts(created.Add(time.Duration(i)*time.Minute)), + ); err != nil { + return fmt.Errorf("alpha: channel %q: %w", c.name, err) + } + } + + // Three channels carry role overrides (ids 1, 2, 9), two carry user + // overrides (ids 9 and 4) — the plan's 3/2 split, with the layered case + // (channel 9 has both) included deliberately. + roleOverrides := []struct { + channel, role int64 + allow, deny int64 + }{ + {1, 4, 0, permissions.SendMessages}, // #welcome: members read-only + {2, 4, 0, permissions.SendMessages}, // #announcements: members read-only + {2, 3, permissions.MentionEveryone, 0}, // …and moderators may @everyone there + {9, 4, 0, permissions.ReadMessages | permissions.SendMessages}, // #staff hidden from members + } + for _, o := range roleOverrides { + if _, err := tx.Exec( + `INSERT INTO channel_overrides (channel_id, role_id, allow, deny) VALUES (?, ?, ?, ?)`, + o.channel, o.role, o.allow, o.deny, + ); err != nil { + return fmt.Errorf("alpha: role override ch=%d role=%d: %w", o.channel, o.role, err) + } + } + userOverrides := []struct { + channel, user int64 + allow, deny int64 + }{ + {9, 9, permissions.ReadMessages | permissions.SendMessages, 0}, // a trusted member sees #staff + {4, 42, 0, permissions.SendMessages}, // user042 is channel-muted in #random + } + for _, o := range userOverrides { + if _, err := tx.Exec( + `INSERT INTO channel_user_overrides (channel_id, user_id, allow, deny) VALUES (?, ?, ?, ?)`, + o.channel, o.user, o.allow, o.deny, + ); err != nil { + return fmt.Errorf("alpha: user override ch=%d user=%d: %w", o.channel, o.user, err) + } + } + return nil +} + +// ─── DMs ──────────────────────────────────────────────────────────────────── + +type dmPair struct { + channelID int64 + a, b int64 + // ready is when both members exist — no message in the pair may precede + // it, and it is the channel's created_at / opened_at, matching + // GetOrCreateDMChannel's create-on-first-contact shape. + ready time.Time +} + +// alphaInsertDMs creates the 40 DM channels exactly as GetOrCreateDMChannel +// would (type 'dm', empty name, both participants, both open). The first +// twelve pairs are drawn from the pre-window accounts so DM traffic exists +// from the window's first hour; the rest may involve joiners and only carry +// messages once both members exist. +func alphaInsertDMs(tx *sql.Tx, rng *rand.Rand, joined []time.Time) ([]dmPair, error) { + seen := map[[2]int64]bool{} + pairs := make([]dmPair, 0, alphaDMPairs) + for len(pairs) < alphaDMPairs { + pool := alphaUsers + if len(pairs) < 12 { + pool = 40 // both members pre-window + } + a := int64(rng.Intn(pool) + 1) + b := int64(rng.Intn(pool) + 1) + if a == b { + continue + } + if a > b { + a, b = b, a + } + if seen[[2]int64{a, b}] { + continue + } + seen[[2]int64{a, b}] = true + id := int64(alphaChannels + len(pairs) + 1) // 13..52 + ready := joined[a] + if joined[b].After(ready) { + ready = joined[b] + } + if _, err := tx.Exec( + `INSERT INTO channels (id, name, type, is_group, created_at) VALUES (?, '', 'dm', 0, ?)`, + id, ts(ready), + ); err != nil { + return nil, fmt.Errorf("alpha: dm channel %d: %w", id, err) + } + for _, u := range []int64{a, b} { + if _, err := tx.Exec( + `INSERT INTO dm_participants (channel_id, user_id) VALUES (?, ?)`, id, u, + ); err != nil { + return nil, fmt.Errorf("alpha: dm participant: %w", err) + } + if _, err := tx.Exec( + `INSERT INTO dm_open_state (user_id, channel_id, opened_at) VALUES (?, ?, ?)`, u, id, ts(ready), + ); err != nil { + return nil, fmt.Errorf("alpha: dm open state: %w", err) + } + } + pairs = append(pairs, dmPair{channelID: id, a: a, b: b, ready: ready}) + } + return pairs, nil +} + +// ─── Messages ─────────────────────────────────────────────────────────────── + +// alphaChannelWeights is the relative traffic per text channel (ids 1..10): +// #announcements and #welcome are quiet, #general and #gaming busy, #staff +// small, the archive early-only. +var alphaChannelWeights = []int{2, 1, 30, 18, 22, 10, 8, 6, 3, 8} + +// alphaChannelFor picks a text channel for one message on the given day; the +// archived channel's history ends on day 10. +func alphaChannelFor(rng *rand.Rand, day int) int64 { + w := make([]int, len(alphaChannelWeights)) + copy(w, alphaChannelWeights) + if day >= 10 { + w[9] = 0 + } + total := 0 + for _, x := range w { + total += x + } + n := rng.Intn(total) + for i, x := range w { + if n < x { + return int64(i + 1) + } + } + return 3 +} + +// alphaAuthorFor picks a message author. Staff channels (#welcome, +// #announcements) are staff-posted; #staff adds the override-listed user009; +// everywhere else, anyone who has joined by the message's time — maxUser is +// the eligibility prefix (staff and user009 are all pre-window). +func alphaAuthorFor(rng *rand.Rand, ch int64, maxUser int) int64 { + switch ch { + case 1, 2: + return int64(rng.Intn(alphaOwners+alphaAdmins+alphaModerators) + 1) + case 9: + staff := []int64{1, 2, 3, 4, 5, 6, 7, 8, 9} + return staff[rng.Intn(len(staff))] + default: + return int64(rng.Intn(maxUser) + 1) + } +} + +// sqlBatch accumulates multi-row VALUES groups and flushes them in chunks — +// one statement per chunk instead of one per row, inside the caller's +// transaction. +type sqlBatch struct { + b strings.Builder + args []any +} + +func (sb *sqlBatch) add(row string, vals ...any) { + if len(sb.args) > 0 { + sb.b.WriteString(",") + } + sb.b.WriteString(row) + sb.args = append(sb.args, vals...) +} + +func (sb *sqlBatch) flush(tx *sql.Tx, prefix string) error { + if sb.b.Len() == 0 { + return nil + } + //nolint:gosec // G202: the concatenation is a builder of "(?,…)" placeholder groups; every value is bound + _, err := tx.Exec(prefix+sb.b.String(), sb.args...) + sb.b.Reset() + sb.args = sb.args[:0] + return err +} + +// alphaInsertMessages writes all 20,000 messages in chronological order — +// per-bucket second offsets are sorted before ids are assigned, so ascending +// ids follow ascending timestamps exactly as insertion order does on a live +// server (Codex on #1469, P2) — and returns each message's timestamp and +// author (indexed by id-1) for the attachment rows. Authors are drawn only +// from users who exist at the message's time: the eligible set is always the +// prefix 1..maxUser because alphaJoinTimes is monotonic, and a DM pair +// carries traffic only from its ready time. +func alphaInsertMessages(tx *sql.Tx, rng *rand.Rand, windowStart time.Time, joined []time.Time, pairs []dmPair) ([]time.Time, []int64, error) { + // Exactly 15% of message ids are DM messages. + dmCount := int(float64(alphaMessages) * alphaDMShare) + isDM := make([]bool, alphaMessages) + for _, idx := range rng.Perm(alphaMessages)[:dmCount] { + isDM[idx] = true + } + + perDay := largestRemainder(onesSlice(alphaWindowDays), alphaMessages) + + // Pairs sorted by readiness, so the eligible set at any time is a prefix; + // skewedIndex over that prefix keeps the earliest (all-pre-window) pairs + // the chattiest, which is also the realistic shape. + byReady := make([]dmPair, len(pairs)) + copy(byReady, pairs) + sort.Slice(byReady, func(i, j int) bool { return byReady[i].ready.Before(byReady[j].ready) }) + + times := make([]time.Time, 0, alphaMessages) + authors := make([]int64, 0, alphaMessages) + maxUser := 40 + readyPairs := 0 + + const chunkArgs = 500 * 5 + var batch sqlBatch + const insertPrefix = `INSERT INTO messages (id, channel_id, user_id, content, timestamp) VALUES ` + + id := 0 + for day := range alphaWindowDays { + perHour := largestRemainder(alphaHourWeights[:], perDay[day]) + for hour := range 24 { + offsets := make([]int, perHour[hour]) + for k := range offsets { + offsets[k] = rng.Intn(3600) + } + sort.Ints(offsets) + base := windowStart.AddDate(0, 0, day).Add(time.Duration(hour) * time.Hour) + for _, off := range offsets { + at := base.Add(time.Duration(off) * time.Second) + for maxUser < alphaUsers && !joined[maxUser+1].After(at) { + maxUser++ + } + for readyPairs < len(byReady) && !byReady[readyPairs].ready.After(at) { + readyPairs++ + } + var ch, author int64 + if isDM[id] && readyPairs > 0 { + p := byReady[skewedIndex(rng, readyPairs)] + ch = p.channelID + if rng.Intn(2) == 0 { + author = p.a + } else { + author = p.b + } + } else { + ch = alphaChannelFor(rng, day) + author = alphaAuthorFor(rng, ch, maxUser) + } + id++ + times = append(times, at) + authors = append(authors, author) + batch.add("(?,?,?,?,?)", id, ch, author, sentence(rng), ts(at)) + if len(batch.args) >= chunkArgs { + if err := batch.flush(tx, insertPrefix); err != nil { + return nil, nil, fmt.Errorf("alpha: messages: %w", err) + } + } + } + } + } + if err := batch.flush(tx, insertPrefix); err != nil { + return nil, nil, fmt.Errorf("alpha: messages: %w", err) + } + return times, authors, nil +} + +// ─── Attachments, reactions, invites ──────────────────────────────────────── + +func alphaInsertAttachments(tx *sql.Tx, rng *rand.Rand, messageTimes []time.Time, messageAuthors []int64) error { + type class struct { + count int + mime, ext string + minKB, maxKB int + namePrefix string + } + classes := []class{ + {alphaAttachments * 60 / 100, "image/png", "png", 10, 900, "screenshot"}, + {alphaAttachments * 10 / 100, "audio/ogg", "ogg", 50, 2000, "voice-note"}, + {alphaAttachments * 10 / 100, "video/mp4", "mp4", 500, 5000, "clip"}, + {alphaAttachments * 20 / 100, "application/pdf", "pdf", 10, 1500, "doc"}, + } + n := 0 + for _, c := range classes { + for k := 0; k < c.count; k++ { + n++ + msgID := rng.Intn(alphaMessages) + 1 + id := fmt.Sprintf("%08x%08x%08x%08x", rng.Uint32(), rng.Uint32(), rng.Uint32(), rng.Uint32()) + size := (c.minKB + rng.Intn(c.maxKB-c.minKB+1)) * 1024 + // The uploader is the message's author — CreateAttachment always + // records one on the current schema (Codex on #1469, P2). + if _, err := tx.Exec( + `INSERT INTO attachments (id, message_id, filename, stored_as, mime_type, size, uploaded_at, uploader_id) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + id, msgID, + fmt.Sprintf("%s-%03d.%s", c.namePrefix, n, c.ext), + id+"."+c.ext, c.mime, size, ts(messageTimes[msgID-1]), messageAuthors[msgID-1], + ); err != nil { + return fmt.Errorf("alpha: attachment %d: %w", n, err) + } + } + } + return nil +} + +func alphaInsertReactions(tx *sql.Tx, rng *rand.Rand) error { + emojis := []string{"👍", "😂", "🎉", "❤️", "🔥", "😮"} + type key struct { + m, u int64 + e string + } + seen := map[key]bool{} + id := 0 + for id < alphaReactions { + k := key{ + m: int64(rng.Intn(alphaMessages) + 1), + u: int64(rng.Intn(alphaUsers) + 1), + e: emojis[rng.Intn(len(emojis))], + } + if seen[k] { + continue + } + seen[k] = true + id++ + if _, err := tx.Exec( + `INSERT INTO reactions (id, message_id, user_id, emoji) VALUES (?, ?, ?, ?)`, + id, k.m, k.u, k.e, + ); err != nil { + return fmt.Errorf("alpha: reaction %d: %w", id, err) + } + } + return nil +} + +func alphaInsertInvites(tx *sql.Tx, rng *rand.Rand, windowStart time.Time) error { + staffCount := alphaOwners + alphaAdmins + alphaModerators + for i := 1; i <= alphaInvites; i++ { + revoked := 0 + if i <= alphaInvitesRevkd { + revoked = 1 + } + var maxUses any + if i%3 == 0 { + maxUses = 10 + } + var expires any + if i%4 == 0 { + expires = ts(alphaWindowEnd.AddDate(0, 0, 7)) + } + if _, err := tx.Exec( + `INSERT INTO invites (id, code, created_by, max_uses, use_count, expires_at, created_at, revoked) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + i, fmt.Sprintf("ALPHA-INV-%02d", i), rng.Intn(staffCount)+1, + maxUses, rng.Intn(8), expires, + ts(windowStart.Add(time.Duration(i)*13*time.Hour)), revoked, + ); err != nil { + return fmt.Errorf("alpha: invite %d: %w", i, err) + } + } + return nil +} + +// ─── Small helpers ────────────────────────────────────────────────────────── + +// ts renders a time the way the schema's datetime('now') defaults do. +func ts(t time.Time) string { return t.UTC().Format("2006-01-02 15:04:05") } + +// sentence builds 3–14 lexicon words; median chat-message length. +func sentence(rng *rand.Rand) string { + n := 3 + rng.Intn(12) + words := make([]string, n) + for i := range words { + words[i] = alphaLexicon[rng.Intn(len(alphaLexicon))] + } + return strings.Join(words, " ") +} + +// skewedIndex prefers low indexes (min of two uniforms), so a few DM pairs +// are chatty and the tail is sparse — the shape real DM traffic has. +func skewedIndex(rng *rand.Rand, n int) int { + a, b := rng.Intn(n), rng.Intn(n) + if b < a { + return b + } + return a +} + +// largestRemainder allocates total across weights, summing exactly to total. +func largestRemainder(weights []int, total int) []int { + sum := 0 + for _, w := range weights { + sum += w + } + out := make([]int, len(weights)) + type rem struct { + i int + frac float64 + } + rems := make([]rem, len(weights)) + assigned := 0 + for i, w := range weights { + exact := float64(total) * float64(w) / float64(sum) + out[i] = int(exact) + assigned += out[i] + rems[i] = rem{i, exact - float64(out[i])} + } + // Stable selection sort by remainder — deterministic, n is tiny. + for assigned < total { + best := 0 + for j := 1; j < len(rems); j++ { + if rems[j].frac > rems[best].frac { + best = j + } + } + out[rems[best].i]++ + rems[best].frac = -1 + assigned++ + } + return out +} + +func onesSlice(n int) []int { + s := make([]int, n) + for i := range s { + s[i] = 1 + } + return s +} diff --git a/Server/cmd/seed/profile_alpha_test.go b/Server/cmd/seed/profile_alpha_test.go new file mode 100644 index 00000000..ccaf6155 --- /dev/null +++ b/Server/cmd/seed/profile_alpha_test.go @@ -0,0 +1,72 @@ +package main + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/J3vb/OwnCord/Server/db" +) + +// generateSnapshot runs the full alpha pipeline — migrate, seed, scrub, +// VACUUM INTO — exactly as `-profile alpha -snapshot … -scrub …` does, and +// returns the snapshot's bytes. +func generateSnapshot(t *testing.T, name string) []byte { + t.Helper() + dir := t.TempDir() + database, err := db.Open(filepath.Join(dir, name+".db")) + if err != nil { + t.Fatalf("db.Open: %v", err) + } + defer func() { _ = database.Close() }() + if err := db.Migrate(database); err != nil { + t.Fatalf("db.Migrate: %v", err) + } + if err := seedAlpha(database); err != nil { + t.Fatalf("seedAlpha: %v", err) + } + out := filepath.Join(dir, name+".sqlite") + if err := writeSnapshot(database, filepath.Join("..", "..", "testdata", "snapshots", "scrub.sql"), out); err != nil { + t.Fatalf("writeSnapshot: %v", err) + } + raw, err := os.ReadFile(out) + if err != nil { + t.Fatalf("reading snapshot: %v", err) + } + return raw +} + +// TestAlphaProfileByteIdentical is the B3-7 exit condition: the profile is a +// pure function — two runs, two files, identical bytes. Anything wall-clock, +// salted, map-ordered or pool-raced in the pipeline fails this test. +func TestAlphaProfileByteIdentical(t *testing.T) { + first := generateSnapshot(t, "first") + second := generateSnapshot(t, "second") + if !bytes.Equal(first, second) { + i := 0 + for i < len(first) && i < len(second) && first[i] == second[i] { + i++ + } + t.Fatalf("two alpha seed runs differ (len %d vs %d, first divergence at byte %d) — the profile has a nondeterminism leak", len(first), len(second), i) + } +} + +// TestAlphaProfileRefusesNonEmptyDatabase pins the guard: determinism is +// per-database-lifetime, so the profile only ever seeds an empty file. +func TestAlphaProfileRefusesNonEmptyDatabase(t *testing.T) { + database, err := db.Open(filepath.Join(t.TempDir(), "occupied.db")) + if err != nil { + t.Fatalf("db.Open: %v", err) + } + defer func() { _ = database.Close() }() + if err := db.Migrate(database); err != nil { + t.Fatalf("db.Migrate: %v", err) + } + if err := seedAlpha(database); err != nil { + t.Fatalf("first seed: %v", err) + } + if err := seedAlpha(database); err == nil { + t.Fatal("second seed on a populated database succeeded; want a refusal") + } +} diff --git a/Server/db/alpha_snapshot_test.go b/Server/db/alpha_snapshot_test.go new file mode 100644 index 00000000..63e8182e --- /dev/null +++ b/Server/db/alpha_snapshot_test.go @@ -0,0 +1,135 @@ +package db + +import ( + "context" + "io" + "os" + "path/filepath" + "testing" +) + +// The committed alpha snapshot (B3-7 item 3) — the "upgrade still applies" +// canary. It opens Server/testdata/snapshots/v1.2.0-alpha.4.sqlite on a +// throwaway copy, asserts its recorded provenance, runs HEAD's migrations +// over it, and checks the row counts the profile promises. The moment a +// post-alpha.4 migration lands, Migrate below is a real upgrade rehearsal: +// it must apply the delta cleanly or this test is the first thing to break. +// +// alphaSnapshotMigrations is the schema_versions count baked into the +// committed snapshot — 31, the migration set at the v1.2.0-alpha.4 tag +// (unchanged through B3). It only moves when the snapshot is REGENERATED on +// a newer schema, which is a deliberate act recorded in the B3-7 evidence +// block, never a drive-by. +const alphaSnapshotMigrations = 31 + +func openSnapshotCopy(t *testing.T) *DB { + t.Helper() + src, err := os.Open(filepath.Join("..", "testdata", "snapshots", "v1.2.0-alpha.4.sqlite")) + if err != nil { + t.Fatalf("opening committed snapshot: %v", err) + } + defer func() { _ = src.Close() }() + dstPath := filepath.Join(t.TempDir(), "alpha-copy.sqlite") + dst, err := os.Create(dstPath) + if err != nil { + t.Fatalf("creating copy: %v", err) + } + if _, err := io.Copy(dst, src); err != nil { + t.Fatalf("copying snapshot: %v", err) + } + if err := dst.Close(); err != nil { + t.Fatalf("closing copy: %v", err) + } + database, err := Open(dstPath) + if err != nil { + t.Fatalf("db.Open on snapshot copy: %v", err) + } + t.Cleanup(func() { _ = database.Close() }) + return database +} + +func TestAlphaSnapshotMigratesOnHead(t *testing.T) { + database := openSnapshotCopy(t) + + count := func(q string) int { + t.Helper() + var n int + if err := database.QueryRowContext(context.Background(), q).Scan(&n); err != nil { + t.Fatalf("%s: %v", q, err) + } + return n + } + + // Provenance: the snapshot was created at the alpha.4 migration set. + if got := count(`SELECT COUNT(*) FROM schema_versions`); got != alphaSnapshotMigrations { + t.Fatalf("snapshot carries %d applied migrations, want %d — regenerate it deliberately or fix the constant with the B3-7 evidence block", got, alphaSnapshotMigrations) + } + + // The upgrade: HEAD's migrations must apply to an alpha.4 database. + if err := Migrate(database); err != nil { + t.Fatalf("HEAD migrations failed on the alpha.4 snapshot: %v", err) + } + if got := count(`SELECT COUNT(*) FROM schema_versions`); got < alphaSnapshotMigrations { + t.Fatalf("schema_versions shrank to %d after Migrate", got) + } + + // The row-count checksum: what cmd/seed's alpha profile promises. + for _, tc := range []struct { + q string + want int + }{ + {`SELECT COUNT(*) FROM users`, 100}, + {`SELECT COUNT(*) FROM channels`, 52}, // 12 server + 40 DM + {`SELECT COUNT(*) FROM channels WHERE type = 'dm'`, 40}, + {`SELECT COUNT(*) FROM channels WHERE archived = 1`, 1}, + {`SELECT COUNT(*) FROM channel_overrides`, 4}, // 3 channels carry them + {`SELECT COUNT(*) FROM channel_user_overrides`, 2}, // 2 channels carry them + {`SELECT COUNT(*) FROM messages`, 20000}, + {`SELECT COUNT(*) FROM messages m JOIN channels c ON c.id = m.channel_id AND c.type = 'dm'`, 3000}, + {`SELECT COUNT(*) FROM dm_participants`, 80}, + {`SELECT COUNT(*) FROM attachments`, 300}, + {`SELECT COUNT(*) FROM reactions`, 500}, + {`SELECT COUNT(*) FROM invites`, 30}, + {`SELECT COUNT(*) FROM invites WHERE revoked = 1`, 10}, + {`SELECT COUNT(*) FROM plugins`, 1}, + {`SELECT COUNT(*) FROM plugins WHERE enabled = 0`, 1}, + // A quiescent, scrubbed server carries none of these — see the + // profile's package comment (voice is ephemeral; the replay log is + // empty on a restarted server; sessions and tokens are scrubbed). + {`SELECT COUNT(*) FROM sessions`, 0}, + {`SELECT COUNT(*) FROM api_tokens`, 0}, + {`SELECT COUNT(*) FROM events`, 0}, + {`SELECT COUNT(*) FROM voice_states`, 0}, + } { + if got := count(tc.q); got != tc.want { + t.Errorf("%s = %d, want %d", tc.q, got, tc.want) + } + } + + // FTS came along: the virtual table indexes every message. + if got := count(`SELECT COUNT(*) FROM messages_fts`); got != 20000 { + t.Errorf("messages_fts holds %d rows, want 20000", got) + } + + // Realism invariants a live database cannot violate (Codex on #1469): + // nothing is authored before its author joined, ids follow timestamps as + // insertion order does, and every attachment records its uploader. + for _, tc := range []struct { + what, q string + }{ + {"messages authored before their author joined", + `SELECT COUNT(*) FROM messages m JOIN users u ON u.id = m.user_id WHERE m.timestamp < u.created_at`}, + {"adjacent message ids with inverted timestamps", + `SELECT COUNT(*) FROM messages a JOIN messages b ON b.id = a.id + 1 WHERE b.timestamp < a.timestamp`}, + {"attachments without an uploader", + `SELECT COUNT(*) FROM attachments WHERE uploader_id IS NULL`}, + {"DM messages before both members existed", + `SELECT COUNT(*) FROM messages m JOIN channels c ON c.id = m.channel_id AND c.type = 'dm' + JOIN dm_participants dp ON dp.channel_id = c.id JOIN users u ON u.id = dp.user_id + WHERE m.timestamp < u.created_at`}, + } { + if got := count(tc.q); got != 0 { + t.Errorf("%s: %d, want 0", tc.what, got) + } + } +} diff --git a/Server/invariants/db_import_boundary.go b/Server/invariants/db_import_boundary.go index 725d6bf7..d51fe4d3 100644 --- a/Server/invariants/db_import_boundary.go +++ b/Server/invariants/db_import_boundary.go @@ -80,6 +80,7 @@ var DBImportAllow = map[string]DBImportEntry{ "internal/app/plugins.go": {"boundary", "", "passes the handle to the plugin registry as its store; no calls"}, "token_cli.go": {"move", "auth", "API-token CLI duplicates admin/handlers_tokens.go"}, "cmd/seed/main.go": {"boundary", "", "developer seeding tool owns its handle"}, + "cmd/seed/profile_alpha.go": {"boundary", "", "the alpha profile writes through the handle main.go owns"}, "cmd/gendocs/main.go": {"boundary", "", "docs generator migrates its own in-memory catalog"}, "plugin/pluginstore.go": {"adapter", "", "PluginRow type only; the store is injected"}, // ── ws ──────────────────────────────────────────────────────────────── diff --git a/Server/testdata/snapshots/README.md b/Server/testdata/snapshots/README.md new file mode 100644 index 00000000..688b467a --- /dev/null +++ b/Server/testdata/snapshots/README.md @@ -0,0 +1,60 @@ +# Alpha snapshots + +`v1.2.0-alpha.4.sqlite` is the anonymised, deterministic database snapshot the +B3-7 plan item promises: a server shaped like a real `v1.2.0-alpha.4` install +after a month of use, at rest between sessions. The schema is the alpha.4 +migration set (31 applied migrations — unchanged since the tag), so opening it +with a newer server is a genuine in-place upgrade. + +## Consumers + +- **B4 — HP-4 drills**: identity/recovery/deletion rehearsals against + realistic data. +- **B6 — upgrade rehearsal**: the deployment qualification's alpha→beta + in-place upgrade input. +- **B10 — in-place upgrade**: the release candidate's required upgrade test + (roadmap B10 item 4). +- `Server/db/alpha_snapshot_test.go` is the standing canary: every `go test` + run opens a copy, applies HEAD's migrations, and checks the row counts. + +## Shape + +100 users (1 owner / 2 admins / 5 moderators / 92 members), 12 channels +(10 text + 2 voice; 3 with role overrides, 2 with user overrides, 1 archived), +20,000 messages over 30 simulated days on a diurnal curve (15% in DMs across +40 DM pairs), 300 attachment rows (60/10/10/20% image/audio/video/other, +10 KB–5 MB), 500 reactions, 30 invites (10 revoked), 1 disabled plugin row. +The numbers live as constants in `Server/cmd/seed/profile_alpha.go`; a number +found unrepresentative is changed there with the reason recorded in the B3-7 +evidence block — never silently. Two constants deliberately leave no rows +(voice sessions are LiveKit-ephemeral; the replay log is empty as on any +server restarted for an upgrade) — the profile's package comment records why. + +Every account's password is `alpha-dev-password`. Attachment rows are +metadata only — the files themselves are not part of a database snapshot, so +attachment downloads 404 until B6's rehearsal seeds an uploads directory to +match. + +## Regenerating + +```bash +cd Server +go run ./cmd/seed -confirm-dev -profile alpha \ + -db /tmp/alpha-fresh.db \ + -snapshot testdata/snapshots/v1.2.0-alpha.4.sqlite \ + -scrub testdata/snapshots/scrub.sql +``` + +The output is byte-for-byte reproducible (`TestAlphaProfileByteIdentical` +holds the property); regenerate only deliberately — on a newer migration set +the snapshot stops being an alpha.4 artifact, `alphaSnapshotMigrations` in +the canary must move with it, and the change belongs in the B3-7 evidence +block. + +`scrub.sql` is the identity and credential scrub: usernames, profiles, +secrets, sessions, tokens, invite codes, audit detail, filenames and +wall-clock migration timestamps. It deliberately does not touch message +content or channel names — synthetic here, but a judgement call on a real +database — so a donated production database is **not** shareable after this +script alone: clear or rewrite its content (and rebuild FTS) first, or +refuse the donation. diff --git a/Server/testdata/snapshots/scrub.sql b/Server/testdata/snapshots/scrub.sql new file mode 100644 index 00000000..0b586195 --- /dev/null +++ b/Server/testdata/snapshots/scrub.sql @@ -0,0 +1,58 @@ +-- Identity and credential scrub for alpha snapshots (B3-7 item 2). +-- +-- Applied by `go run ./cmd/seed -profile alpha -snapshot … -scrub this-file` +-- before VACUUM INTO writes the committed snapshot. Every statement is +-- idempotent. Scope, stated precisely (Codex on #1469): this script removes +-- account identities, credentials, session/token material, invite codes, +-- audit detail, user-chosen filenames and wall-clock apply times. It +-- DELIBERATELY DOES NOT touch message content, channel names/topics, or the +-- FTS index built from them — the committed snapshot's content is synthetic +-- lexicon text with nothing to hide, and content anonymisation of a REAL +-- database is a judgement call no blanket UPDATE can make. A donated +-- production database is NOT made shareable by this script alone: its +-- messages and channel names must be cleared or rewritten first (and the +-- FTS index rebuilt), or the donation refused. Statements are split on ";" +-- with comment lines stripped, so keep one statement per block and comments +-- on their own lines. + +UPDATE users +SET username = 'user' || printf('%03d', id), + display_name = NULL, + about = NULL, + custom_status = NULL, + avatar = NULL, + totp_secret = NULL, + identity_public_key = NULL, + ban_reason = CASE WHEN ban_reason IS NULL THEN NULL ELSE 'scrubbed' END, + password = '$2a$12$EBrRXmplT1ryU0o/HzELSePreo.gK5.z5Tjo4ec/ISchy5gKwxtQq'; + +-- Sessions, tokens and rate-limit state authenticate or profile people — +-- a snapshot carries none of them. +DELETE FROM sessions; + +DELETE FROM api_tokens; + +DELETE FROM login_attempts; + +DELETE FROM rate_lockouts; + +-- Invite codes are shared secrets; keep the rows, rotate the codes. +UPDATE invites SET code = 'ALPHA-INV-' || printf('%02d', id); + +-- Audit detail can quote user-entered text; the action skeleton is enough. +UPDATE audit_log SET detail = ''; + +-- Migration tracking records wall-clock apply times; the snapshot's +-- canonical form pins them to the window end so two generation runs produce +-- identical bytes (and a real donated database stops dating its operator). +UPDATE schema_versions SET applied_at = '2026-08-01 00:00:00'; + +-- Attachment filenames are user-chosen text. +UPDATE attachments +SET filename = 'file-' || substr(id, 1, 8) || + CASE + WHEN mime_type LIKE 'image/%' THEN '.png' + WHEN mime_type LIKE 'audio/%' THEN '.ogg' + WHEN mime_type LIKE 'video/%' THEN '.mp4' + ELSE '.bin' + END; diff --git a/Server/testdata/snapshots/v1.2.0-alpha.4.sqlite b/Server/testdata/snapshots/v1.2.0-alpha.4.sqlite new file mode 100644 index 00000000..0eb0fe66 Binary files /dev/null and b/Server/testdata/snapshots/v1.2.0-alpha.4.sqlite differ diff --git a/docs/architecture/server-boundaries.md b/docs/architecture/server-boundaries.md index cd29f0f2..26d6ff41 100644 --- a/docs/architecture/server-boundaries.md +++ b/docs/architecture/server-boundaries.md @@ -91,6 +91,7 @@ which is a row worth reading, and none exists today. | `auth/resolve.go` | `APIToken` `Role×2` `Session×2` `User×2` | — | — | type-only | adapter | — | Session/APIToken/Role/User types; resolution is injected | | `cmd/gendocs/main.go` | `DB×3` | `Migrate()` `Open()` | `Close×2` `QueryContext×2` | calls | boundary | — | docs generator migrates its own in-memory catalog | | `cmd/seed/main.go` | `DB×6` | `Migrate()` `Open()` | `Close` `CreateChannel` `CreateMessage×2` `CreateUser` `GetOrCreateDMChannel` `GetUserByUsername` `ListChannels` `QueryRowContext` | calls | boundary | — | developer seeding tool owns its handle | +| `cmd/seed/profile_alpha.go` | `DB×3` | `Migrate()` | `BeginTx` `ExecContext×2` `QueryRowContext` | calls | boundary | — | the alpha profile writes through the handle main.go owns | | `internal/app/app.go` | `AuditWriter` `DB` | — | — | type-only | boundary | — | the App holds the handle for its lifetime; no calls | | `internal/app/database.go` | `DB×2` | `Migrate()` `OpenWithMaxReaders()` | `ClearAllVoiceStates` `ResetAllUserStatuses` | calls | boundary | — | opens the handle, migrates, clears stale state at boot | | `internal/app/hub.go` | `DB` | — | — | type-only | boundary | — | hands the handle to the hub and the service layer it builds | @@ -117,8 +118,8 @@ which is a row worth reading, and none exists today. | `ws/voice_join.go` | `Channel×3` `ChannelOverride` `VoiceState×5` | `ErrChannelFull` | `GetChannel×2` `GetChannelOverridesFor` `GetChannelVoiceStates` `GetRoleForUser` `GetVoiceState×6` `JoinVoiceChannel` `JoinVoiceChannelIfCapacity` `LeaveVoiceChannelIfMatch` `SetVoiceServerDeafen` `SetVoiceServerMute` | calls | move | voice | voice state reads and writes | | `ws/voice_moderation.go` | `Role` `VoiceState×3` | `WriteAudit()` | `CountChannelVoiceUsers` `GetChannel×2` `GetRoleForUser` `GetVoiceState×2` `SetVoiceServerDeafen×2` `SetVoiceServerMute×2` | calls | move | voice | mute/deafen/move persist voice state | -55 files import `db` outside `db/` and `service/` (. 1, admin 16, api 10, auth 2, cmd/gendocs 1, cmd/seed 1, internal/app 6, plugin 1, ws 17); 17 are type-only; 0 unlisted. -Dispositions: adapter 17, boundary 12, move 26. Move targets: auth 7, channel 6, connection 1, role 1, settings-ops 4, upload 2, user 2, voice 3. +56 files import `db` outside `db/` and `service/` (. 1, admin 16, api 10, auth 2, cmd/gendocs 1, cmd/seed 2, internal/app 6, plugin 1, ws 17); 17 are type-only; 0 unlisted. +Dispositions: adapter 17, boundary 13, move 26. Move targets: auth 7, channel 6, connection 1, role 1, settings-ops 4, upload 2, user 2, voice 3. diff --git a/docs/deployment.md b/docs/deployment.md index d286c540..59d550f4 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -99,6 +99,12 @@ docker compose up -d The named volume is preserved — no data loss. +Upgrade rehearsals do not need a donated production database: +`Server/testdata/snapshots/v1.2.0-alpha.4.sqlite` is a committed, anonymised, +alpha.4-schema dataset shaped like a month-old server (see the README beside +it), and `Server/db/alpha_snapshot_test.go` proves on every test run that the +current migrations still apply to it cleanly. + **Upgrade the server before the clients.** Desktop clients fetch updates from the server they connect to, and the server only offers releases whose protocol epoch it can speak itself (`docs/protocol.md`, Compatibility). A release that