Author SHA1 Message Date
Claude 2f83d13297 chore(b3-7): satisfy the lint gate — split the message generator, reasoned nolints, modernize idioms
golangci flagged eleven issues, all in profile_alpha.go. funlen/cyclop:
alphaChannelFor, alphaAuthorFor and the sqlBatch chunk machinery move out
of alphaInsertMessages — pure code motion, proven by the regenerated
snapshot being byte-identical to the committed one. gosec gets reasons,
not exemptions by silence: the public dev-profile hash and math/rand are
this profile's documented contract, and both concatenations build only
"(?,…)" placeholder groups. strings.SplitSeq and range-over-int adopted;
alphaVoiceSessions now surfaces in the summary output, where its
no-at-rest-rows interpretation is worth stating at runtime anyway.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4
2026-08-31 05:59:12 +00:00
Claude a88d1554be fix(b3-7): inventory row for the profile's db import; join-aware, chronological, uploader-attributed dataset
The Server Build & Test failure was the db-import-boundary rule:
cmd/seed/profile_alpha.go had no DBImportAllow row. Added (boundary — the
profile writes through the handle main.go owns) and the dbinventory table
in server-boundaries.md repasted from the tool.

Codex findings on the dataset, all verified and fixed with the snapshot
regenerated (byte-identity holds; size unchanged at 3.2MB):
- authors before join: alphaJoinTimes is computed once and monotonic in
  the user id, so eligibility at time t is the prefix 1..N; channel
  authors draw from it and DM pairs carry traffic only from their ready
  time (first twelve pairs are all-pre-window so DM traffic exists from
  the first hour; DM channels are created at ready, the
  create-on-first-contact shape).
- non-chronological ids: per-bucket second offsets are sorted before ids
  are assigned; ascending ids now follow ascending timestamps.
- NULL uploader: attachments record uploader_id = the message's author.
- scrub over-claim (P1): scrub.sql and the README now state precisely
  that message content and channel names are deliberately out of scope —
  synthetic here, a judgement call on a real database — so a donated
  production database is not shareable after this script alone.

The canary pins all four as invariants a live database cannot violate:
zero authored-before-join rows, zero adjacent-id timestamp inversions,
zero uploaderless attachments, zero DM messages predating a member.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4
2026-08-31 05:41:13 +00:00
J3vb 057cb19eda Merge branch 'dev' into feat/b3-7-alpha-dataset 2026-08-31 07:26:12 +02:00
Claude 0dd3e86bc0 feat(b3-7): alpha-shaped dataset — deterministic seed profile and the v1.2.0-alpha.4 snapshot
go run ./cmd/seed -confirm-dev -profile alpha fills an empty database with
the plan's dataset: 100 users (1/2/5/92 across the four roles), 12 channels
(10 text + 2 voice; 3 role-override, 2 user-override, 1 archived), 20,000
messages over 30 simulated days on a diurnal curve (exactly 15% in DMs
across 40 pairs), 300 attachment rows (60/10/10/20%, 10KB-5MB), 500
reactions, 30 invites (10 revoked), one disabled plugin row. Deterministic
by construction — fixed seed, fixed clock, constant bcrypt hash, explicit
ids and timestamps, VACUUM INTO as the canonical bytes — and
TestAlphaProfileByteIdentical holds the property (two full runs compared
byte for byte; a schema_versions wall-clock leak was the one leak found,
now pinned by the scrub). Two constants deliberately leave no rows and say
why in the package comment: voice sessions are LiveKit-ephemeral, and the
replay log is empty exactly as on a server restarted for an upgrade.

The committed snapshot (3.2MB, under the 5MB LFS line) is the scrubbed
VACUUM of that profile at the alpha.4 migration set - the schema has not
moved since the tag, so it is a true alpha.4 artifact. scrub.sql beside it
also anonymises a real donated database. db/alpha_snapshot_test.go is the
standing canary: provenance (31 applied migrations), HEAD migrations apply
cleanly, and every promised row count checks out, FTS included. Consumers
(B4 HP-4, B6 upgrade rehearsal, B10 in-place upgrade) are named in the
snapshot README and docs/deployment.md.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8dwVLEihnGZYtH9X631F4
2026-08-31 05:14:13 +00:00
11 changed files with 1121 additions and 4 deletions
+4 -1
View File
@@ -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
+13 -1
View File
@@ -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)
}
+769
View File
@@ -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. 145 min, 26 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, // 0005
3, 5, 7, 8, 8, 8, // 0611
9, 9, 8, 8, 9, 10, // 1217
11, 12, 11, 8, 5, 2, // 1823
}
// 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 314 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
}
+72
View File
@@ -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")
}
}
+135
View File
@@ -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)
}
}
}
+1
View File
@@ -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 ────────────────────────────────────────────────────────────────
+60
View File
@@ -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 KB5 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.
+58
View File
@@ -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;
Binary file not shown.
+3 -2
View File
@@ -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.
<!-- dbinventory:end -->
+6
View File
@@ -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