From 9e74d252beba4bc1d498133162cae445645d5fd0 Mon Sep 17 00:00:00 2001 From: angel <23301964+mayvqt@users.noreply.github.com> Date: Fri, 11 Sep 2026 01:10:05 +1200 Subject: [PATCH] docs: initialize project guides and harden migrations --- .github/workflows/ci.yml | 3 - README.md | 24 +---- docs/CONTRIBUTING.md | 2 +- docs/capabilities.md | 16 +++ docs/development.md | 31 +----- docs/development/README.md | 14 +++ docs/development/architecture.md | 37 +++++++ docs/development/codebase-map.md | 20 ++++ docs/development/data.md | 33 ++++++ docs/development/documentation-conventions.md | 28 +++++ docs/development/operations.md | 33 ++++++ docs/development/validation.md | 39 +++++++ docs/setup.md | 7 +- internal/db/schema.go | 62 +++++++++-- internal/db/schema_test.go | 102 ++++++++++++++++++ 15 files changed, 390 insertions(+), 61 deletions(-) create mode 100644 docs/capabilities.md create mode 100644 docs/development/README.md create mode 100644 docs/development/architecture.md create mode 100644 docs/development/codebase-map.md create mode 100644 docs/development/data.md create mode 100644 docs/development/documentation-conventions.md create mode 100644 docs/development/operations.md create mode 100644 docs/development/validation.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba63e86..fa6e901 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,9 +3,6 @@ name: CI on: workflow_call: pull_request: - push: - branches: - - main permissions: contents: read diff --git a/README.md b/README.md index 7309c51..1acbc44 100644 --- a/README.md +++ b/README.md @@ -1,29 +1,13 @@ # Aperture -## Overview - Aperture creates Jellyfin or Emby accounts from controlled invite links and applies non-admin policy templates. -## Quick start - -```bash -cp .env.example .env -docker compose up -d --build -``` - -Open `http://localhost:8099`, complete browser setup, then sign in with a media-server administrator account. - -Complete setup on a trusted network before exposing Aperture publicly. Fresh installs keep state in `./config`; -existing installs should retain their current data path. See [setup and backups](docs/setup.md). - ## Documentation -- [Setup](docs/setup.md) -- [Configuration](docs/configuration.md) -- [Unraid](docs/unraid.md) -- [Security](docs/SECURITY.md) -- [Development](docs/development.md) -- [Contributing](docs/CONTRIBUTING.md) +- Get started: [Setup](docs/setup.md) and [Configuration](docs/configuration.md) +- Run Aperture: [Operations](docs/development/operations.md) and [Unraid](docs/unraid.md) +- See what it does: [Capabilities](docs/capabilities.md) +- Contribute: [Development](docs/development/README.md), [Contributing](docs/CONTRIBUTING.md), and [Security](docs/SECURITY.md) - [License](LICENSE) - [Issues](https://github.com/mayvqt/Aperture/issues) diff --git a/docs/CONTRIBUTING.md b/docs/CONTRIBUTING.md index d16bb5e..10a8e97 100644 --- a/docs/CONTRIBUTING.md +++ b/docs/CONTRIBUTING.md @@ -6,5 +6,5 @@ Keep changes small and limited to secure invitation, registration, policy applic - Test behavior, security, persistence, and failure paths. - Version and test schema changes against existing databases. - Update affected deployment and configuration files. -- Run every check in [development.md](development.md). +- Use the focused checks and final gate in [Validation](development/validation.md). - Never commit `.env`, databases, credentials, tokens, logs, or generated artifacts. diff --git a/docs/capabilities.md b/docs/capabilities.md new file mode 100644 index 0000000..d90ea9f --- /dev/null +++ b/docs/capabilities.md @@ -0,0 +1,16 @@ +# Capabilities + +Aperture provides: + +- browser-based first-run configuration for Jellyfin or Emby; +- administrator authentication through the configured media server; +- reusable non-administrator policy templates, including import from an existing user; +- bounded, expiring invite links with usage limits and optional account expiry; +- account creation, policy application, retry, disable, and recovery workflows; +- managed-user and registration history views; +- Discord and generic JSON webhooks with selected events; and +- administrative audit history with bounded retention. + +The media server remains the source of truth for users, passwords, and effective +permissions. Aperture stores the state needed to coordinate invitations and +recovery. Configuration details are in [Configuration](configuration.md). diff --git a/docs/development.md b/docs/development.md index 98f20cc..78f5640 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,33 +1,12 @@ # Development -```bash -go run ./cmd/aperture serve -``` +Use the [development task index](development/README.md) to find ownership, +architecture, data, validation, operations, and documentation guidance. -Open `http://localhost:8099/setup`. Go does not load `.env` automatically. +For a local server: ```bash -gofmt -w -go test ./... -go vet ./... -go test -race ./... -staticcheck ./... -govulncheck ./... +go run ./cmd/aperture serve ``` -Keep SQL in `internal/db`, HTTP workflows in `internal/httpserver`, media protocol code in `internal/mediaserver`, and -secret primitives in `internal/security`. Schema changes require a revision update and upgrade/fresh-install tests. - -Tests use temporary SQLite databases and fake media-server clients. No live -Jellyfin/Emby accounts or external database containers are needed. Use existing -installed tools; ask before installing missing tools or downloading dependencies. -CI pins tool versions and action commits, and image publishing reuses its full -checks. Run `sh scripts/test-entrypoint.sh` for entrypoint changes and -`CGO_ENABLED=0 go build -trimpath -o ./build/aperture ./cmd/aperture` for release -build validation. Published images report their tag or commit through -`aperture version`; local builds report `dev`. - -No browser wrapper is checked in. Use available shared browser tooling with a -disposable config directory and synthetic users/invites; check desktop/mobile, -validation errors, permission denial, and the setup/login/invite flows. Templates, -CSS and JavaScript are embedded, so rebuild before checking rendered changes. +Open `http://localhost:8099/setup`. Go does not load `.env` automatically. diff --git a/docs/development/README.md b/docs/development/README.md new file mode 100644 index 0000000..5211b6e --- /dev/null +++ b/docs/development/README.md @@ -0,0 +1,14 @@ +# Development task index + +Read only the pages needed for the task: + +- Find ownership or an entry point: [Codebase map](codebase-map.md). +- Change boundaries, request flow, or integrations: [Architecture](architecture.md). +- Change SQLite, migrations, retention, or secrets: [Data](data.md). +- Select checks or gather evidence: [Validation](validation.md). +- Deploy, back up, restore, release, or roll back: [Operations](operations.md). +- Add or update documentation: [Documentation conventions](documentation-conventions.md). + +User and operator pages are indexed from the repository [README](../../README.md). +Security-sensitive changes also require [Security](../SECURITY.md). If a task has +no suitable page, create one focused page and link it here. diff --git a/docs/development/architecture.md b/docs/development/architecture.md new file mode 100644 index 0000000..22831cd --- /dev/null +++ b/docs/development/architecture.md @@ -0,0 +1,37 @@ +# Architecture + +`cmd/aperture` loads configuration, opens the store, initializes schema and +runtime secrets, selects a media-server adapter, starts the maintenance worker, +and serves `internal/httpserver`. Handlers depend on narrow store and media-server +interfaces; persistence stays in `internal/db`, while outbound Jellyfin/Emby +protocol details stay in `internal/mediaserver`. + +## Sources of truth and contracts + +- Jellyfin or Emby owns user identity, passwords, administrator status, and + effective user policy. +- SQLite owns Aperture settings, templates, invite state, registration recovery, + managed-user tracking, sessions, webhooks, and audit records. +- Environment variables and flags override browser-managed settings. The database + owns settings that are not deployment-managed. +- Public HTTP routes are registered in `internal/httpserver/routes.go`. + `/i/{token}` and registration are unauthenticated; `/setup` is available only + before setup completes; `/admin/*` requires a verified media-server admin. +- Jellyfin and Emby request shapes and authorization are provider contracts. + Verify changes against the supported upstream documentation and cover them + with deterministic HTTP fixtures before any opt-in live smoke test. + +## Security and concurrency boundaries + +Invite tokens, session material, API keys, access tokens, webhook URLs, cookies, +and the entire state directory are sensitive. Preserve CSRF checks, bounded +request bodies, rate limits, trusted-proxy parsing, security headers, redaction, +and no-redirect outbound clients. + +SQLite is deliberately limited to one connection and uses WAL, foreign keys, and +a busy timeout. Setup is serialized in-process. Registration reserves invite +capacity before provisioning; ambiguous external failures retain evidence rather +than silently releasing capacity. The maintenance worker reconciles stale work, +retries templates, disables expired users, and prunes audit events. Webhook +deliveries are tracked and receive a bounded graceful-shutdown window. Changes to +these flows must preserve idempotency, bounded work, and cancellation behavior. diff --git a/docs/development/codebase-map.md b/docs/development/codebase-map.md new file mode 100644 index 0000000..2af5d5d --- /dev/null +++ b/docs/development/codebase-map.md @@ -0,0 +1,20 @@ +# Codebase map + +| Path | Ownership | +| --- | --- | +| `cmd/aperture` | CLI dispatch, configuration startup, logging, process lifecycle, and version output. | +| `internal/config` | Environment/flag parsing, defaults, URL validation, and generated encryption-key bootstrap. | +| `internal/db` | SQLite schema, migrations, settings, invites, templates, sessions, registrations, managed users, and audit records. | +| `internal/httpserver` | Routes, middleware, browser workflows, embedded templates/assets, webhooks, and maintenance coordination. | +| `internal/mediaserver` | Provider-neutral contracts and URL rules. | +| `internal/mediaserver/jellyfin`, `emby`, `protocol`, `router` | Provider adapters, HTTP protocol, and runtime provider selection. | +| `internal/security` | Encryption, token helpers, and diagnostic redaction. | +| `scripts`, `docker-entrypoint.sh`, `Dockerfile`, `docker-compose.yml` | Container build, startup, ownership, and regression checks. | +| `templates/unraid` | Unraid application template. | +| `.github/workflows` | CI, image publication, dependency updates, and release announcements. | + +The nearest tests live beside their packages. Presentation sources are +`internal/httpserver/templates` and `internal/httpserver/assets`; both are +embedded into the binary. + +Update this map when a top-level domain is added or ownership moves. diff --git a/docs/development/data.md b/docs/development/data.md new file mode 100644 index 0000000..6598c9c --- /dev/null +++ b/docs/development/data.md @@ -0,0 +1,33 @@ +# Data and schema + +`internal/db/schema.go` is the schema and revision source of truth. +`PRAGMA user_version` records the revision. The database contains settings, +templates, invites, registrations, audit events, sessions, webhooks, and managed +users. Secret settings, retained invite tokens, access tokens, and webhook URLs +are encrypted with the installation encryption key; hashed identifiers are used +where raw tokens are unnecessary. + +`internal/db/store.go` owns connection policy: one connection, WAL mode, foreign +keys, a five-second busy timeout, private directories, and mode `0600` database +files. Keep SQLite operations in `internal/db`; handlers must not become a second +schema or query owner. + +## Schema changes + +Migrations are forward-only and transactional. Add a new revision instead of +rewriting the meaning of an applied revision. Prefer expand/contract evolution: +add compatible columns or tables, deploy readers/writers that tolerate the +transition, and remove obsolete data only in a later explicitly supported step. +Startup applies every outstanding supported revision in order and rejects +databases newer than the binary. + +Every schema change requires: + +- a fresh-database test; +- an upgrade test starting at every affected supported revision; +- preservation tests for existing rows and encrypted values; +- failure/rollback-boundary coverage; and +- corresponding backup and release guidance when operator action is required. + +Never repair an upgrade by deleting the database. Back up and restore the whole +state set as described in [Operations](operations.md). diff --git a/docs/development/documentation-conventions.md b/docs/development/documentation-conventions.md new file mode 100644 index 0000000..8d45d4d --- /dev/null +++ b/docs/development/documentation-conventions.md @@ -0,0 +1,28 @@ +# Documentation conventions + +Write for one audience and one task per page. Keep commands executable, link to +the source of truth instead of copying it, and describe only shipped behavior. +Unfinished work belongs in a dedicated roadmap only when it has an accepted +scope; confirmed defects belong in a known-issues page; release notes describe +one shipped audience-facing revision. Move facts between those pages rather than +duplicating them. + +For large, high-risk, or external-integration work, keep the accepted plan +immutable and record execution/evidence separately. Inventory each operation, +verify upstream contracts against canonical sources, use deterministic fakes, +and make real-service smoke tests explicit and opt-in. Record only reproduced +defects. + +## Documentation impact map + +| Change | Update in the same batch | +| --- | --- | +| User-visible behavior | Nearest user page, [Capabilities](../capabilities.md), and [README](../../README.md) if navigation changes | +| Configuration or setup | [Configuration](../configuration.md), [Setup](../setup.md), relevant deployment page, and their nearest index | +| Package ownership or architecture | [Codebase map](codebase-map.md), [Architecture](architecture.md), and this index | +| Schema, retention, encryption, or stored data | [Data](data.md), [Operations](operations.md) when operator impact changes, and nearest index | +| Tests, tools, UI states, or CI | [Validation](validation.md) and nearest index | +| Security boundary or disclosure process | [Security](../SECURITY.md), relevant architecture/data/operations page, and nearest index | +| Deployment, backup, health, rollback, or release | [Operations](operations.md), relevant public setup/configuration page, and [README](../../README.md) | + +Update this map when a new canonical documentation domain is introduced. diff --git a/docs/development/operations.md b/docs/development/operations.md new file mode 100644 index 0000000..52ddef2 --- /dev/null +++ b/docs/development/operations.md @@ -0,0 +1,33 @@ +# Operations + +Run one Aperture process per state directory. The container runs without added +privileges and should receive only a writable `/config`, its listening port, and +network access to the configured media server and webhook destinations. Restrict +proxy trust to the directly connected proxy. Keep `.env`, `/config`, cookies, +invite URLs, logs, and backups private; diagnostics must be redacted before +sharing. + +## Backup and restore + +Before upgrades, stop Aperture and copy the entire host state directory, +including `encryption.key`, the database, and any matching WAL/SHM files. Store +the backup off-host with restricted access. Restore into a separate directory +using the matching application version; never combine a database with stale +WAL/SHM files. Verify `/healthz`, setup/login, templates, invites, registration +history, and a synthetic media-server connection before treating the backup as +usable. Record the last successful restore test. + +## Release and rollback + +Builds are published by `.github/workflows/docker-image.yml` after the reusable +CI workflow succeeds. Deploy an immutable version tag or digest and record it +with the backup used for the change; `latest` is not a rollback reference. +After deployment, verify process health, `/healthz`, authentication, an +administrator read, and configured outbound connectivity without exposing +secrets. + +Schema upgrades are forward-only. Application rollback therefore means restoring +the pre-upgrade state with the previous immutable image. Stop on failed health or +data verification; preserve failed state for diagnosis. Maintenance or repair +actions that mutate users, registrations, or SQLite state are explicit, +operator-approved procedures, never automatic troubleshooting steps. diff --git a/docs/development/validation.md b/docs/development/validation.md new file mode 100644 index 0000000..9a5f63b --- /dev/null +++ b/docs/development/validation.md @@ -0,0 +1,39 @@ +# Validation + +Use the smallest focused check that covers the changed contract: + +| Change | Focused check | +| --- | --- | +| CLI behavior | `go test ./cmd/aperture` | +| SQLite/schema | `go test ./internal/db` | +| HTTP, templates, CSS, or JavaScript | `go test ./internal/httpserver` plus browser checks below | +| Jellyfin/Emby protocol | `go test ./internal/mediaserver/...` | +| Configuration/security | `go test ./internal/config ./internal/security` | +| Entrypoint | `sh scripts/test-entrypoint.sh` | +| Release binary | `CGO_ENABLED=0 go build -trimpath -o /tmp/aperture ./cmd/aperture` | + +Format touched Go files with `gofmt -w`. Performance or refactor claims require a +representative before/after benchmark or trace and a regression threshold; a +clean test run alone is not performance evidence. + +## UI evidence + +Rebuild before visual checks because templates, CSS, and JavaScript are embedded. +Use a disposable database and synthetic users/invites. For every affected flow, +check desktop and narrow mobile widths, keyboard-only navigation, visible focus, +labels and error association, and loading, empty, validation-error, upstream-error, +and permission-denied states. Cover setup, login, invite registration, and the +affected admin page. Do not use real credentials or production data. + +## Prerequisites and final gate + +Use the Go version in `go.mod`. Do not install missing tools or download +dependencies without approval. Reuse local dependencies only when `go.sum`, the +Go toolchain, OS/architecture, and installed module tree match the revision being +validated; otherwise fail closed and use a clean locked environment. + +The one comprehensive gate is the complete GitHub Actions `CI` workflow in +`.github/workflows/ci.yml` for the exact revision. It checks formatting, +whitespace, `go test ./...`, the entrypoint, vet, race detection, pinned +Staticcheck and govulncheck versions, a release-style build, and the Docker image. +Do not claim release readiness until both jobs pass on that revision. diff --git a/docs/setup.md b/docs/setup.md index a666e6e..4810142 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -17,8 +17,5 @@ installs must retain their current host data directory when adopting `.env`; set `APERTURE_DATA_DIR` to that directory. Without this override Compose preserves its original `/mnt/cache/appdata/aperture` mapping. -For backup, stop Aperture and copy the entire host state directory, including -`encryption.key` and any SQLite WAL/SHM files, then restart it. Restore into a -separate directory with the matching application version and verify setup/login, -templates, invites, and registration history. Never combine a restored database -with stale WAL/SHM files. Keep a pre-upgrade backup: schema upgrades are forward-only. +Before upgrades, follow the [backup, restore, and rollback +procedure](development/operations.md). Schema upgrades are forward-only. diff --git a/internal/db/schema.go b/internal/db/schema.go index 98778a0..dd757a5 100644 --- a/internal/db/schema.go +++ b/internal/db/schema.go @@ -2,6 +2,7 @@ package db import ( "context" + "database/sql" "fmt" ) @@ -151,7 +152,7 @@ func (s *Store) InitSchema(ctx context.Context) error { return fmt.Errorf("begin schema initialization: %w", err) } defer tx.Rollback() - if revision == 1 { + if revision > 0 && revision < 2 { if _, err := tx.ExecContext(ctx, `ALTER TABLE registrations ADD COLUMN template_attempts INTEGER NOT NULL DEFAULT 0`); err != nil { return fmt.Errorf("add template attempts: %w", err) } @@ -159,12 +160,34 @@ func (s *Store) InitSchema(ctx context.Context) error { return fmt.Errorf("add template retry time: %w", err) } } - if revision == 2 { - if _, err := tx.ExecContext(ctx, `ALTER TABLE webhooks ADD COLUMN kind TEXT NOT NULL DEFAULT 'discord'`); err != nil { - return fmt.Errorf("add webhook kind: %w", err) + if revision > 0 && revision <= 2 { + var webhooksTable bool + if err := tx.QueryRowContext(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'webhooks' + ) + `).Scan(&webhooksTable); err != nil { + return fmt.Errorf("inspect webhook schema: %w", err) } - if _, err := tx.ExecContext(ctx, `ALTER TABLE webhooks ADD COLUMN role_ids TEXT NOT NULL DEFAULT ''`); err != nil { - return fmt.Errorf("add webhook role IDs: %w", err) + if webhooksTable { + kindExists, err := schemaColumnExists(ctx, tx, "webhooks", "kind") + if err != nil { + return fmt.Errorf("inspect webhook kind: %w", err) + } + if !kindExists { + if _, err := tx.ExecContext(ctx, `ALTER TABLE webhooks ADD COLUMN kind TEXT NOT NULL DEFAULT 'discord'`); err != nil { + return fmt.Errorf("add webhook kind: %w", err) + } + } + roleIDsExists, err := schemaColumnExists(ctx, tx, "webhooks", "role_ids") + if err != nil { + return fmt.Errorf("inspect webhook role IDs: %w", err) + } + if !roleIDsExists { + if _, err := tx.ExecContext(ctx, `ALTER TABLE webhooks ADD COLUMN role_ids TEXT NOT NULL DEFAULT ''`); err != nil { + return fmt.Errorf("add webhook role IDs: %w", err) + } + } } } if _, err := tx.ExecContext(ctx, schema); err != nil { @@ -185,3 +208,30 @@ func (s *Store) InitSchema(ctx context.Context) error { } return s.restrictDatabaseFiles() } + +type schemaQuerier interface { + QueryContext(context.Context, string, ...any) (*sql.Rows, error) +} + +func schemaColumnExists(ctx context.Context, db schemaQuerier, table, column string) (bool, error) { + rows, err := db.QueryContext(ctx, "PRAGMA table_info("+table+")") + if err != nil { + return false, err + } + defer rows.Close() + for rows.Next() { + var cid, notNull, primaryKey int + var name, columnType string + var defaultValue any + if err := rows.Scan(&cid, &name, &columnType, ¬Null, &defaultValue, &primaryKey); err != nil { + return false, err + } + if name == column { + return true, nil + } + } + if err := rows.Err(); err != nil { + return false, err + } + return false, nil +} diff --git a/internal/db/schema_test.go b/internal/db/schema_test.go index 64d70c8..e16e88e 100644 --- a/internal/db/schema_test.go +++ b/internal/db/schema_test.go @@ -2,6 +2,7 @@ package db import ( "context" + "fmt" "path/filepath" "strings" "testing" @@ -64,3 +65,104 @@ func TestInitSchemaMigratesRevisionThreeWithManagedUsers(t *testing.T) { t.Fatalf("managed_users was not created during migration: %v", err) } } + +func TestInitSchemaMigratesRevisionOneThroughCurrent(t *testing.T) { + ctx, store := openLegacySchemaStore(t, 1, false) + if err := store.InitSchema(ctx); err != nil { + t.Fatal(err) + } + + assertSchemaRevisionAndColumns(t, ctx, store, map[string][]string{ + "registrations": {"template_attempts", "next_template_attempt_at"}, + "webhooks": {"kind", "role_ids"}, + }) +} + +func TestInitSchemaMigratesRevisionTwoWebhookColumns(t *testing.T) { + ctx, store := openLegacySchemaStore(t, 2, true) + if err := store.InitSchema(ctx); err != nil { + t.Fatal(err) + } + + assertSchemaRevisionAndColumns(t, ctx, store, map[string][]string{ + "webhooks": {"kind", "role_ids"}, + }) +} + +func openLegacySchemaStore(t *testing.T, revision int, registrationRetry bool) (context.Context, *Store) { + t.Helper() + ctx := context.Background() + store, err := Open(filepath.Join(t.TempDir(), "aperture.db"), "test-encryption-key-32-characters") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + if err := store.Close(); err != nil { + t.Fatal(err) + } + }) + + registrationColumns := ` + disable_attempts INTEGER NOT NULL DEFAULT 0, + next_disable_attempt_at DATETIME,` + if registrationRetry { + registrationColumns += ` + template_attempts INTEGER NOT NULL DEFAULT 0, + next_template_attempt_at DATETIME,` + } + if _, err := store.db.ExecContext(ctx, `CREATE TABLE registrations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + invite_id INTEGER NOT NULL, + external_user_id TEXT, + username TEXT NOT NULL, + status TEXT NOT NULL, + template_name TEXT, + template_policy_json TEXT, + error_message TEXT, + user_disable_at DATETIME, + user_disabled_at DATETIME,`+registrationColumns+` + ip_address TEXT, + user_agent TEXT, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + )`); err != nil { + t.Fatal(err) + } + if _, err := store.db.ExecContext(ctx, `CREATE TABLE webhooks ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + url_encrypted TEXT NOT NULL, + events TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + )`); err != nil { + t.Fatal(err) + } + if _, err := store.db.ExecContext(ctx, fmt.Sprintf(`PRAGMA user_version = %d`, revision)); err != nil { + t.Fatal(err) + } + return ctx, store +} + +func assertSchemaRevisionAndColumns(t *testing.T, ctx context.Context, store *Store, expected map[string][]string) { + t.Helper() + var revision int + if err := store.db.QueryRowContext(ctx, `PRAGMA user_version`).Scan(&revision); err != nil { + t.Fatal(err) + } + if revision != schemaRevision { + t.Fatalf("schema revision = %d, want %d", revision, schemaRevision) + } + for table, columns := range expected { + for _, column := range columns { + exists, err := schemaColumnExists(ctx, store.db, table, column) + if err != nil { + t.Fatalf("inspect %s.%s: %v", table, column, err) + } + if !exists { + t.Fatalf("schema is missing %s.%s", table, column) + } + } + } +}