refactor(server/db): adopt sqlc as the query layer — phase 1 (D2)

Wire the sqlc-generated dbgen package into db.DB so it stops being dead
code (audit A-2026-07-05) and becomes the real, CI-verified query layer.

db.DB now holds a *dbgen.Queries (initialized in Open via dbgen.New).
Query method bodies delegate to it; sqlc owns the SQL text and parameter
binding (make sqlc-verify), while db keeps its stable public API and
domain model types so no caller in api/admin/ws/service changes. The
migration is incremental — a method either delegates to d.q.* or still
runs raw SQL — so both layers are correct during the transition.

Converted domains (now load-bearing through sqlc):
- blocks: BlockUser, UnblockUser, IsBlocked, IsEitherBlocked,
  ListBlockedUsers (added the query to blocks.sql + regenerated).
  Empty ListBlockedUsers now returns []int64{} instead of nil, matching
  the MemStore backend — a latent inconsistency fixed, not a regression.
- lockouts: UpsertLockout, LoadActiveLockouts, CleanupExpiredLockouts,
  DeleteLockout (RFC3339 time formatting/parsing kept in the wrappers).
- roles: GetRoleByID, ListRoles, GetRoleForUser via a shared roleFromGen
  mapper (int64 position/is_default -> int/bool). GetUserWithRole stays
  raw for now.

Remaining domains stay on raw SQL and are tracked in
docs/plans/sqlc-adoption.md; store/ event+plugin SQL is intentionally
excluded (that layer is removed in D3). Decisions doc + audit closure
updated (A-2026-07-05 -> in progress).

Verified: go build ./...; go test -race ./db ./service ./auth ./ws (api
green non-race, race run matches CI's -timeout 20m); make sqlc-verify and
protocol-verify pass with the regenerated output committed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UA17KPvqGBX3XbXYnMf1rA
This commit is contained in:
Claude
2026-07-19 15:01:54 +00:00
parent 752c8f2bcc
commit 44323373e3
10 changed files with 179 additions and 113 deletions
+33 -47
View File
@@ -1,15 +1,21 @@
package db
import "fmt"
import (
"errors"
"fmt"
"database/sql"
"github.com/owncord/server/db/dbgen"
)
// BlockUser adds a block from blocker to blocked. Idempotent — re-blocking
// a user that is already blocked is a no-op (INSERT OR IGNORE).
func (d *DB) BlockUser(blockerID, blockedID int64) error {
_, err := d.sqlDB.Exec(
`INSERT OR IGNORE INTO user_blocks (blocker_id, blocked_id) VALUES (?, ?)`,
blockerID, blockedID,
)
if err != nil {
if err := d.q.BlockUser(dbCtx(), dbgen.BlockUserParams{
BlockerID: blockerID,
BlockedID: blockedID,
}); err != nil {
return fmt.Errorf("BlockUser: %w", err)
}
return nil
@@ -18,11 +24,10 @@ func (d *DB) BlockUser(blockerID, blockedID int64) error {
// UnblockUser removes a block. Idempotent — unblocking a non-blocked user is
// a no-op.
func (d *DB) UnblockUser(blockerID, blockedID int64) error {
_, err := d.sqlDB.Exec(
`DELETE FROM user_blocks WHERE blocker_id = ? AND blocked_id = ?`,
blockerID, blockedID,
)
if err != nil {
if err := d.q.UnblockUser(dbCtx(), dbgen.UnblockUserParams{
BlockerID: blockerID,
BlockedID: blockedID,
}); err != nil {
return fmt.Errorf("UnblockUser: %w", err)
}
return nil
@@ -30,15 +35,14 @@ func (d *DB) UnblockUser(blockerID, blockedID int64) error {
// IsBlocked returns true if blockerID has blocked blockedID.
func (d *DB) IsBlocked(blockerID, blockedID int64) (bool, error) {
var exists int
err := d.sqlDB.QueryRow(
`SELECT 1 FROM user_blocks WHERE blocker_id = ? AND blocked_id = ? LIMIT 1`,
blockerID, blockedID,
).Scan(&exists)
_, err := d.q.IsBlocked(dbCtx(), dbgen.IsBlockedParams{
BlockerID: blockerID,
BlockedID: blockedID,
})
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
if err.Error() == "sql: no rows in result set" {
return false, nil
}
return false, fmt.Errorf("IsBlocked: %w", err)
}
return true, nil
@@ -48,18 +52,16 @@ func (d *DB) IsBlocked(blockerID, blockedID int64) (bool, error) {
// Used for DM authorization — if either party has blocked the other,
// messaging is denied.
func (d *DB) IsEitherBlocked(userA, userB int64) (bool, error) {
var exists int
err := d.sqlDB.QueryRow(
`SELECT 1 FROM user_blocks
WHERE (blocker_id = ? AND blocked_id = ?)
OR (blocker_id = ? AND blocked_id = ?)
LIMIT 1`,
userA, userB, userB, userA,
).Scan(&exists)
_, err := d.q.IsEitherBlocked(dbCtx(), dbgen.IsEitherBlockedParams{
BlockerID: userA,
BlockedID: userB,
BlockerID_2: userB,
BlockedID_2: userA,
})
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
if err != nil {
if err.Error() == "sql: no rows in result set" {
return false, nil
}
return false, fmt.Errorf("IsEitherBlocked: %w", err)
}
return true, nil
@@ -67,25 +69,9 @@ func (d *DB) IsEitherBlocked(userA, userB int64) (bool, error) {
// ListBlockedUsers returns the IDs of all users blocked by the given user.
func (d *DB) ListBlockedUsers(blockerID int64) ([]int64, error) {
rows, err := d.sqlDB.Query(
`SELECT blocked_id FROM user_blocks WHERE blocker_id = ? ORDER BY created_at DESC`,
blockerID,
)
ids, err := d.q.ListBlockedUsers(dbCtx(), blockerID)
if err != nil {
return nil, fmt.Errorf("ListBlockedUsers: %w", err)
}
defer rows.Close() //nolint:errcheck
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, fmt.Errorf("ListBlockedUsers scan: %w", err)
}
ids = append(ids, id)
}
if rows.Err() != nil {
return nil, fmt.Errorf("ListBlockedUsers rows: %w", rows.Err())
}
return ids, nil
}
+15 -1
View File
@@ -7,15 +7,29 @@ import (
"database/sql"
"fmt"
"github.com/owncord/server/db/dbgen"
"github.com/owncord/server/migrations"
_ "modernc.org/sqlite" // register the sqlite3 driver
)
// DB wraps *sql.DB and exposes the subset of methods needed by the server.
//
// q is the sqlc-generated query layer (db/dbgen). Query method bodies delegate
// to it — sqlc is the source of truth for the SQL text and parameter binding
// (verified in CI by `make sqlc-verify`), while this package keeps the stable
// public API and the domain model types the rest of the server consumes.
// Migration is incremental (decision D2); methods not yet delegated still run
// their raw SQL directly against sqlDB.
type DB struct {
sqlDB *sql.DB
q *dbgen.Queries
}
// dbCtx is the context used for delegated dbgen calls. The public db.DB API is
// context-free today; callers that need cancellation use the *Context helpers
// directly. Using Background here preserves the existing behavior exactly.
func dbCtx() context.Context { return context.Background() }
// Open opens (or creates) a SQLite database at path, enables WAL mode and
// foreign key enforcement, and returns a ready-to-use DB.
func Open(path string) (*DB, error) {
@@ -72,7 +86,7 @@ func Open(path string) (*DB, error) {
return nil, fmt.Errorf("setting cache_size: %w", err)
}
return &DB{sqlDB: sqlDB}, nil
return &DB{sqlDB: sqlDB, q: dbgen.New(sqlDB)}, nil
}
// Migrate runs all SQL migration files from the embedded migrations FS in
+27
View File
@@ -65,6 +65,33 @@ func (q *Queries) IsEitherBlocked(ctx context.Context, arg IsEitherBlockedParams
return column_1, err
}
const listBlockedUsers = `-- name: ListBlockedUsers :many
SELECT blocked_id FROM user_blocks WHERE blocker_id = ? ORDER BY created_at DESC
`
func (q *Queries) ListBlockedUsers(ctx context.Context, blockerID int64) ([]int64, error) {
rows, err := q.db.QueryContext(ctx, listBlockedUsers, blockerID)
if err != nil {
return nil, err
}
defer rows.Close()
items := []int64{}
for rows.Next() {
var blocked_id int64
if err := rows.Scan(&blocked_id); err != nil {
return nil, err
}
items = append(items, blocked_id)
}
if err := rows.Close(); err != nil {
return nil, err
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const unblockUser = `-- name: UnblockUser :exec
DELETE FROM user_blocks WHERE blocker_id = ? AND blocked_id = ?
`
+1
View File
@@ -97,6 +97,7 @@ type Querier interface {
LeaveVoiceChannelIfMatch(ctx context.Context, arg LeaveVoiceChannelIfMatchParams) (sql.Result, error)
LinkAttachmentToMessage(ctx context.Context, arg LinkAttachmentToMessageParams) (sql.Result, error)
ListAllUsers(ctx context.Context, arg ListAllUsersParams) ([]ListAllUsersRow, error)
ListBlockedUsers(ctx context.Context, blockerID int64) ([]int64, error)
ListChannels(ctx context.Context) ([]ListChannelsRow, error)
ListInvites(ctx context.Context) ([]ListInvitesRow, error)
ListMembers(ctx context.Context) ([]ListMembersRow, error)
+16 -27
View File
@@ -1,54 +1,43 @@
package db
import "time"
import (
"time"
"github.com/owncord/server/db/dbgen"
)
// UpsertLockout inserts or replaces a rate-limit lockout entry.
func (d *DB) UpsertLockout(key string, expiresAt time.Time) error {
_, err := d.sqlDB.Exec(
`INSERT OR REPLACE INTO rate_lockouts (key, expires_at) VALUES (?, ?)`,
key, expiresAt.UTC().Format(time.RFC3339),
)
return err
return d.q.UpsertLockout(dbCtx(), dbgen.UpsertLockoutParams{
Key: key,
ExpiresAt: expiresAt.UTC().Format(time.RFC3339),
})
}
// LoadActiveLockouts returns all lockouts that have not yet expired as
// parallel slices of keys and expiry times.
func (d *DB) LoadActiveLockouts() (keys []string, expiresAt []time.Time, err error) {
rows, err := d.sqlDB.Query(
`SELECT key, expires_at FROM rate_lockouts WHERE expires_at > ?`,
time.Now().UTC().Format(time.RFC3339),
)
rows, err := d.q.LoadActiveLockouts(dbCtx(), time.Now().UTC().Format(time.RFC3339))
if err != nil {
return nil, nil, err
}
defer rows.Close() //nolint:errcheck
for rows.Next() {
var key, expiresStr string
if err := rows.Scan(&key, &expiresStr); err != nil {
return nil, nil, err
}
t, parseErr := time.Parse(time.RFC3339, expiresStr)
for _, r := range rows {
t, parseErr := time.Parse(time.RFC3339, r.ExpiresAt)
if parseErr != nil {
continue // skip unparseable rows
}
keys = append(keys, key)
keys = append(keys, r.Key)
expiresAt = append(expiresAt, t)
}
return keys, expiresAt, rows.Err()
return keys, expiresAt, nil
}
// CleanupExpiredLockouts removes lockout rows whose expiry has passed.
func (d *DB) CleanupExpiredLockouts() error {
_, err := d.sqlDB.Exec(
`DELETE FROM rate_lockouts WHERE expires_at <= ?`,
time.Now().UTC().Format(time.RFC3339),
)
return err
return d.q.CleanupExpiredLockouts(dbCtx(), time.Now().UTC().Format(time.RFC3339))
}
// DeleteLockout removes a single lockout entry.
func (d *DB) DeleteLockout(key string) error {
_, err := d.sqlDB.Exec(`DELETE FROM rate_lockouts WHERE key = ?`, key)
return err
return d.q.DeleteLockout(dbCtx(), key)
}
+3
View File
@@ -12,3 +12,6 @@ SELECT 1 FROM user_blocks
WHERE (blocker_id = ? AND blocked_id = ?)
OR (blocker_id = ? AND blocked_id = ?)
LIMIT 1;
-- name: ListBlockedUsers :many
SELECT blocked_id FROM user_blocks WHERE blocker_id = ? ORDER BY created_at DESC;
+25 -36
View File
@@ -4,48 +4,47 @@ import (
"database/sql"
"errors"
"fmt"
"github.com/owncord/server/db/dbgen"
)
// roleFromGen maps the sqlc-generated Role row to the domain Role model,
// narrowing the int64 position and converting the int64 is_default flag to a
// bool. Shared by all role reads that delegate to dbgen.
func roleFromGen(r dbgen.Role) *Role {
return &Role{
ID: r.ID,
Name: r.Name,
Color: r.Color,
Permissions: r.Permissions,
Position: int(r.Position),
IsDefault: r.IsDefault != 0,
}
}
// GetRoleByID returns the role with the given ID, or nil if not found.
func (d *DB) GetRoleByID(id int64) (*Role, error) {
row := d.sqlDB.QueryRow(
`SELECT id, name, color, permissions, position, is_default FROM roles WHERE id = ?`,
id,
)
r := &Role{}
var isDefault int
err := row.Scan(&r.ID, &r.Name, &r.Color, &r.Permissions, &r.Position, &isDefault)
r, err := d.q.GetRoleByID(dbCtx(), id)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("GetRoleByID: %w", err)
}
r.IsDefault = isDefault != 0
return r, nil
return roleFromGen(r), nil
}
// ListRoles returns all roles ordered by position descending.
func (d *DB) ListRoles() ([]*Role, error) {
rows, err := d.sqlDB.Query(
`SELECT id, name, color, permissions, position, is_default FROM roles ORDER BY position DESC`,
)
rows, err := d.q.ListRoles(dbCtx())
if err != nil {
return nil, fmt.Errorf("ListRoles: %w", err)
}
defer rows.Close() //nolint:errcheck
var roles []*Role
for rows.Next() {
r := &Role{}
var isDefault int
if err := rows.Scan(&r.ID, &r.Name, &r.Color, &r.Permissions, &r.Position, &isDefault); err != nil {
return nil, fmt.Errorf("ListRoles scan: %w", err)
}
r.IsDefault = isDefault != 0
roles = append(roles, r)
roles := make([]*Role, 0, len(rows))
for _, r := range rows {
roles = append(roles, roleFromGen(r))
}
return roles, rows.Err()
return roles, nil
}
// GetRoleForUser returns only the role for a given user via a single JOIN.
@@ -53,24 +52,14 @@ func (d *DB) ListRoles() ([]*Role, error) {
// TOTP secret). Use this on hot paths like permission checks.
// Returns (nil, nil) when the user is not found.
func (d *DB) GetRoleForUser(userID int64) (*Role, error) {
row := d.sqlDB.QueryRow(
`SELECT r.id, r.name, r.color, r.permissions, r.position, r.is_default
FROM users u
JOIN roles r ON u.role_id = r.id
WHERE u.id = ?`,
userID,
)
r := &Role{}
var isDefault int
err := row.Scan(&r.ID, &r.Name, &r.Color, &r.Permissions, &r.Position, &isDefault)
r, err := d.q.GetRoleForUser(dbCtx(), userID)
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("GetRoleForUser: %w", err)
}
r.IsDefault = isDefault != 0
return r, nil
return roleFromGen(r), nil
}
// GetUserWithRole returns the user and their role in a single query.
+1 -1
View File
@@ -18,7 +18,7 @@ accepted-risk note before the beta gate. MEDIUMs are folded into the backlog
| A-2026-07-02 | HIGH | Client HTTP path accepts any TLS certificate (`allowSelfSigned` hardcoded; no TOFU pinning, unlike WS/LiveKit paths) | CLOSED 2026-07-19 — HTTP TOFU proxy implemented (`http_proxy.rs` + `httpProxy.ts`); REST path now cert-pinned, `acceptInvalidCerts` removed |
| A-2026-07-03 | HIGH | Reference specs (api.md / protocol.md / schema.md) frozen at 2026-04-02; systemic drift incl. whole undocumented subsystems (voice E2EE, plugins) | CLOSED 2026-07-19 — full refresh of api.md/protocol.md/schema.md landed (all §2 fix-spec items); keep-current-per-PR rule now applies |
| A-2026-07-04 | HIGH | Client unit test suite "KNOWN RED" and non-blocking in CI; E2E never gated | OPEN — supersedes prior #11's scope |
| A-2026-07-05 | MEDIUM | Dead sqlc layer: `Server/db/dbgen/` (~3.5k LOC) generated + CI-verified but imported by nothing | DECIDED 2026-07-19 — adopt sqlc as the real query layer (D2) (see [plans/audit-2026-07-19-decisions.md](plans/audit-2026-07-19-decisions.md)) |
| A-2026-07-05 | MEDIUM | Dead sqlc layer: `Server/db/dbgen/` (~3.5k LOC) generated + CI-verified but imported by nothing | IN PROGRESS 2026-07-19 — `dbgen` now wired into `db.DB` and load-bearing (blocks/lockouts/roles delegate); no longer dead. Remaining domains tracked in [plans/sqlc-adoption.md](plans/sqlc-adoption.md) |
| A-2026-07-06 | MEDIUM | Three coexisting DB-access styles (raw `*db.DB` in api/admin/ws, `store.Store` under service, dead dbgen) | DECIDED 2026-07-19 — single data layer: sqlc-backed db pkg, remove store/ (D2+D3) (see [plans/audit-2026-07-19-decisions.md](plans/audit-2026-07-19-decisions.md)) |
| A-2026-07-07 | MEDIUM | Channel-visibility logic duplicated across ~4 sites with "must mirror" comments | OPEN |
| A-2026-07-08 | MEDIUM | Protocol constants on both sides claim generation from `docs/protocol-schema.json`, which does not exist in the repo | CLOSED 2026-07-19 — codegen implemented: `docs/protocol-schema.json` + `Server/scripts/genprotocol` + `make protocol-verify` CI gate |
+1 -1
View File
@@ -15,7 +15,7 @@ here (and the audit's closure table) as items land.
| # | Decision point | Audit ID | Decision | Status |
|---|----------------|----------|----------|--------|
| D1 | `announcement` channel type (documented + offered by admin API, rejected by DB triggers) | A-2026-07-01 | **Implement end-to-end**: migration to allow the type, posting-permission semantics, admin support, client rendering, spec updates. Not a doc-strip — this becomes a real feature. | Planned (not yet greenlit to start) |
| D2 | Data-layer direction (raw SQL vs dead sqlc `db/dbgen` vs `store.Store`) | A-2026-07-05 / A-2026-07-06 | **Adopt sqlc for real**: wire `db.DB` method bodies to the generated `dbgen` queries so sqlc becomes the actual, type-checked query layer. The `sqlc-verify` CI job stays and starts earning its keep. | Planned |
| D2 | Data-layer direction (raw SQL vs dead sqlc `db/dbgen` vs `store.Store`) | A-2026-07-05 / A-2026-07-06 | **Adopt sqlc for real**: wire `db.DB` method bodies to the generated `dbgen` queries so sqlc becomes the actual, type-checked query layer. The `sqlc-verify` CI job stays and starts earning its keep. | **In progress — phase 1 done 2026-07-19**: `dbgen.Queries` wired into `db.DB`; blocks/lockouts/roles delegate (sqlc now load-bearing, no longer dead code). Remaining domains tracked in [sqlc-adoption.md](sqlc-adoption.md). |
| D3 | Fate of `Server/store/` (untested abstraction seam) | prior audit #6 | **Remove `store/`**: execute the prior audit's P4 "single data layer" direction. Services call the (sqlc-backed) `db` package directly; tests use in-memory SQLite instead of `MemStore`. | Planned (sequence with/after D2) |
| D4 | Protocol constants sync (`message_types.go` / `protocolTypes.ts` claim a nonexistent `docs/protocol-schema.json`) | A-2026-07-08 | **Create real codegen**: commit an actual `protocol-schema.json` plus a generator that emits the Go and TS constant files (and, ideally, protocol.md's message table), making the "single source of truth" comment true. | **Implemented 2026-07-19**: `docs/protocol-schema.json` + `Server/scripts/genprotocol` + `make protocol-generate`/`protocol-verify` + CI gate. protocol.md table generation deferred to D7. |
| D5 | Client HTTP TLS gap (`allowSelfSigned: true`, no TOFU pinning on the REST path) | A-2026-07-02 | **Next security work**: build the TOFU HTTP proxy in Rust (mirroring `ws_proxy.rs`) as the next security task — highest-priority security item. | **Implemented 2026-07-19**`src-tauri/src/http_proxy.rs` (per-host loopback TCP→TLS tunnels, shared TOFU cert store + `cert-tofu` events) + `src/lib/httpProxy.ts`; REST/health/attachments routed through it; `acceptInvalidCerts`/`allowSelfSigned` and the `dangerous-settings` feature removed. See [http-tofu-proxy.md](http-tofu-proxy.md). |
+57
View File
@@ -0,0 +1,57 @@
# sqlc Adoption (D2) — Progress & Plan
**Decision:** D2 in [audit-2026-07-19-decisions.md](audit-2026-07-19-decisions.md) — adopt
sqlc as the real query layer; **Closes:** audit finding A-2026-07-05 (dead `db/dbgen`).
## Approach
`db.DB` now holds a `*dbgen.Queries` (`db/db.go`, initialized in `Open` via
`dbgen.New(sqlDB)`). Query method bodies delegate to it; sqlc owns the SQL text
and parameter binding (verified in CI by `make sqlc-verify`), while the `db`
package keeps its **stable public API and domain model types** so no caller in
`api`/`admin`/`ws`/`service` changes. Migration is **incremental** — a method
either delegates to `d.q.*` or still runs raw SQL against `sqlDB`; both are
correct during the transition.
Two mechanical frictions drive the per-domain effort:
1. **Model mismatch.** Generated row structs use `int64`/`*int64` and store
booleans as `int64`; the domain models use `int`/`*int`/`bool`. SELECT
conversions need a small `fromGen` mapper per domain (see `roleFromGen` in
`db/role_queries.go`).
2. **Missing queries.** A few hand-written methods had no generated
counterpart (e.g. `ListBlockedUsers`); add the query to
`db/queries/sqlite/<domain>.sql` and `make sqlc-generate` before delegating.
## Status
### Phase 1 — done (2026-07-19)
sqlc is now **load-bearing in production** (previously dead code):
| Domain | Methods delegated | Notes |
|--------|-------------------|-------|
| blocks (`block_queries.go`) | BlockUser, UnblockUser, IsBlocked, IsEitherBlocked, ListBlockedUsers | Added `ListBlockedUsers` query. Empty result now `[]int64{}` (matches MemStore; was `nil`). |
| lockouts (`lockout_queries.go`) | UpsertLockout, LoadActiveLockouts, CleanupExpiredLockouts, DeleteLockout | Time formatting/parsing kept in the wrapper. |
| roles (`role_queries.go`) | GetRoleByID, ListRoles, GetRoleForUser | Shared `roleFromGen` mapper. `GetUserWithRole` (joined User+Role) stays raw. |
### Phase 2 — remaining domains (raw SQL still, each survives D3)
Convert with the same pattern; add missing queries + a `fromGen` mapper where
needed. Rough order by mapping simplicity:
- **Simple/exec-heavy:** invites (`invite_queries.go`), profile
(`profile_queries.go`), attachments (`attachment_queries.go`).
- **Model-mapped reads:** sessions (`auth_queries.go`), users
(`auth_queries.go``GetUserByID`/`GetUserByUsername`/`ListAllUsers`),
channels (`channel_queries.go`), voice (`voice_queries.go`),
dm (`dm_queries.go`), admin/settings (`admin_queries.go`).
- **Complex/joined:** messages (`message_queries.go` — search, cursor
pagination, reactions), `GetUserWithRole`, `GetServerStats`.
### Out of scope for D2
- `store/` event + plugin SQL (`store/sqlite_events.go`, plugin store) — these
live in the store layer being **removed in D3**; converting them is throwaway.
D3 moves the surviving `db` methods (sqlc-backed) to direct service use.
## Verification (per phase)
`go build ./...`; `go test -race ./db/ ./service/ ./auth/ ./api/ ./ws/`;
`make sqlc-verify` (generated output committed & in sync).