--- name: database-marchat description: >- Changes marchat SQL schema and queries across SQLite, PostgreSQL, and MySQL using dialect helpers. Use when editing server/db.go, db_dialect.go, schema migrations, MARCHAT_DB_PATH, or multi-database behavior. paths: - "server/db.go" - "server/db_dialect.go" - "server/migrate.go" - "server/db_*_test.go" - "server/migrate_test.go" - "server/handlers.go" - "server/message_state.go" --- # Database (marchat) Runtime backend via `MARCHAT_DB_PATH`: SQLite (default), PostgreSQL, or MySQL. Detection and DSN parsing: `server/db_dialect.go`, `InitDB` in `server/db.go`. ## Rules - Parameterized queries only; no string-concatenated user input. - Every schema or query change must work on all three dialects (or use `db_dialect.go` helpers). - SQLite (`InitDB` only): - Put connection pragmas in the DSN so every pooled connection gets them (`_busy_timeout=5000`, `_journal_mode=WAL`, `_synchronous=NORMAL`, `_txlock=immediate` on the writer, plus `_pragma` for cache/temp). Join with `?` or `&` if the path already has a query (`appendSQLiteDSNPragmas`). - After `Ping`, set writer `SetMaxOpenConns(1)` and `SetMaxIdleConns(1)`. For file-backed SQLite, open a sibling reader pool (`MaxOpenConns(4)`, `_query_only=1`, same `busy_timeout`; no `_journal_mode`, no `mode=ro`, no `cache=shared`). Route SELECTs through `dbRead(db)`. Close with `CloseDB`. Do **not** leave the default unbounded `database/sql` pool on the writer. - Do **not** split `:memory:` (each connection is a different database). - Do **not** rely on one-shot `Exec("PRAGMA ...")` after open for settings that must stick on every connection (that was the #118 `SQLITE_BUSY` failure mode). - Verify after open: `busy_timeout > 0`; for file-backed DBs, `journal_mode` is `wal`. In-memory (`:memory:` / `mode=memory`) requires busy_timeout only. - Quote paths safely in `VACUUM INTO` and similar. - Postgres/MySQL: leave pool defaults alone (do not force `MaxOpenConns(1)`). - MySQL: DSN via `mysql:` or `mysql://`; `mysql.Config` with `parseTime=true`; indexed text rules for search. - Postgres: boolean columns need dialect boolean literals, not `= 0` / `= 1`. ## Durable tables Include messages plus durable state: reactions, read receipts, `user_message_state`, channel preferences. Not message rows alone. ## Testing | Level | Where | |-------|--------| | Unit / integration | In-memory or temp SQLite in `server/*_test.go` (`db_test.go` covers DSN join, file WAL, `:memory:`, concurrent inserts) | | CI smoke | `server/db_ci_smoke_test.go` with `MARCHAT_CI_POSTGRES_URL`, `MARCHAT_CI_MYSQL_URL` (also asserts pool is not forced to 1) | | Handlers | Visible replay SQL (`GetRecentMessagesForUser`), search, pin toggle | Locally, CI smoke tests skip without env vars. See `testing-marchat` skill. ## Schema change workflow 1. Add a new migration step in `server/migrate.go` (`applyMigrationV2`, etc.) and bump `currentSchemaVersion`; extend `verifySchema` when new required tables or columns ship. Prefer deterministic DDL for versions after the v1 baseline (avoid inspect-and-reconcile). 2. `MigrateSchema` runs ordered migrations, records `schema_version`, and verifies required tables (including `ban_history.expires_at`). SQLite/Postgres wrap each version in a transaction; MySQL cannot (DDL implicit commit) - document that when changing migrator behavior. `CreateSchema` in the same file is a thin `log.Fatal` wrapper for tests. 3. Add or extend `db_dialect_test.go` for new SQL fragments. 4. Run `go test ./server/...`. 5. Document env or migration notes in `ARCHITECTURE.md` / `CHANGELOG.md` if user-visible. ## Backup In-process `:backup` supports **SQLite only**. Postgres and MySQL require native backup tools; `BackupDatabase` returns an error when dialect is not SQLite. ## References - `ARCHITECTURE.md` database section - `internal/doctor` DB probes for `-doctor` output