Files
member-console/docs/database-management.md
T
cgalo5758 dd3962990b Adopt entity keys and add invoice numbers
Replace the entity slugs on organizations, workspaces, resource pools,
and
plan ladders with nullable `key` columns and add keys to products,
prices,
and entitlement sets. Rename `providers.slug` to `provider` and add
partial
unique indexes for system and org role names.

Assign invoice numbers per billing account from a gapless transactional
counter; Stripe's number moves to the invoice mapping as an external
reference.

Seeds, fixtures, and the operator lookup address rows by key, and the
returning-login resync no longer blanks a display name when the IdP
sends
no `name` claim.
2026-08-29 20:12:04 -05:00

199 lines
9.1 KiB
Markdown

---
title: "Database Management"
audience: [developer, admin]
summary: "goose migrations, sqlc code generation, and the core + per-integration schema and migration-stream conventions."
---
# Database Management
This project uses [pressly/goose](https://github.com/pressly/goose) for database migrations and [sqlc](https://github.com/sqlc-dev/sqlc) for type-safe SQL code generation. Requires PostgreSQL 18+ (for native `uuidv7()`).
## Schema layout
The database has three PostgreSQL schemas:
- **`core`** — every domain table (identity, organization, billing, entitlements,
and the provider registry that integrations register against). This is the
single source of business truth; there is no per-Go-package schema split.
`internal/db/migrations/` owns this stream.
- **`stripe`** — the Stripe provider's own mapping tables (customers,
subscriptions, invoices, etc.), FK'd into `core`.
`internal/integrations/stripe/store/migrations/` owns this stream.
- **`fedwiki`** — the FedWiki provider's own tables (sites, site swap policy),
FK'd into `core`. `internal/integrations/fedwiki/store/migrations/` owns
this stream.
The Go package layout does **not** mirror the schema layout:
`internal/identity`, `internal/organization`, `internal/billing`,
`internal/entitlements`, and `internal/integration` are separate packages
that all read and write tables in the single `core` schema. Schema boundaries
track **core domain vs. provider integration**, not Go package boundaries —
see the `schema-consolidation` change record under `openspec/changes/archive/`
for the rationale.
## Per-Source Migrations
Each schema is owned by exactly one embedded `migrations/` directory:
```
internal/db/migrations/ # core schema (identity, organization, billing, entitlements, provider registry)
internal/integrations/fedwiki/store/migrations/ # fedwiki schema (sites, site_swap_policy)
internal/integrations/stripe/store/migrations/ # stripe schema (mapping tables)
```
Each source runs against its **own goose ledger table**,
`goose_db_version_<name>` (`goose_db_version_core`, `goose_db_version_fedwiki`,
`goose_db_version_stripe`), and keeps its native `00001…` file numbering.
There is no shared `goose_db_version` table and no global version namespace —
a source's version numbers are independent of its position in the
dependency-ordered source list returned by `internal/migrate.Sources()`.
`internal/db/migrations.go` loops the sources in order, pointing goose at
each source's embedded FS and ledger table in turn.
Source order matters only weakly: `core` must run first, because the FK DAG
is one-directional — integration schemas reference `core`, never the reverse
and never each other — so core's tables must exist before any integration's
migrations apply. Beyond that, integration sources are mutually
order-independent. Appending a new integration source, or reordering
integration sources relative to each other, does not renumber or otherwise
disturb any other stream's ledger.
Rollback (`migrate down`) walks the sources in reverse and rolls back one
step of the most-recently-populated stream — integrations before core.
Status (`migrate status`) prints each stream's ledger under its own
`== <name> ==` header.
Migrations run automatically on startup. The CLI also provides `migrate up`,
`migrate down`, and `migrate status` commands.
## Creating New Migrations
```bash
# Install goose CLI tool
go install github.com/pressly/goose/v3/cmd/goose@latest
# Create a new migration in the appropriate source directory
cd internal/db/migrations # or internal/integrations/stripe/store/migrations,
# internal/integrations/fedwiki/store/migrations
goose create your_migration_name sql
```
### Always Use `StatementBegin` / `StatementEnd`
Every migration with more than one SQL statement **must** wrap both Up and Down sections in `-- +goose StatementBegin` / `-- +goose StatementEnd`. Without these directives, goose v3 splits statements and runs them individually, which can silently skip failed statements while still marking the migration as applied.
### Migrations Run Without `search_path`
The `ConnectAndMigrate` function runs migrations on a **separate database connection** that does not include the custom `search_path` (`core,public`). This is intentional — a pgx/PostgreSQL 18 interaction causes multi-statement DDL sent via the simple query protocol to silently lose tables when a non-default schema appears in the connection-level `search_path`. After migrations complete, the application connection uses the full `search_path` as normal.
```sql
-- +goose Up
-- +goose StatementBegin
CREATE TABLE core.example (...);
CREATE INDEX idx_example ON core.example(...);
GRANT ALL ON ALL TABLES IN SCHEMA core TO core_owner;
-- +goose StatementEnd
-- +goose Down
-- +goose StatementBegin
DROP TABLE IF EXISTS core.example;
-- +goose StatementEnd
```
This ensures PostgreSQL receives the entire block as a single unit, so failures are atomic — either all statements succeed or none are applied.
## Roles
Each schema has an owner/writer/reader role triple: `core_owner` /
`core_writer` / `core_reader`, `stripe_owner` / `stripe_writer` /
`stripe_reader`, `fedwiki_owner` / `fedwiki_writer` / `fedwiki_reader`. Each
triple is created by its owning stream's own baseline migration, not by any
other stream: the core baseline (`internal/db/migrations/00001_init.sql`)
creates only the `core_*` triple, and each integration's baseline
(`internal/integrations/<key>/store/migrations/00001_init.sql`) creates its
own `{key}_owner` / `{key}_writer` / `{key}_reader` triple. Because
integration tables FK into `core`, each integration's baseline also grants
`core_reader` to its own `{key}_writer`; this is safe because `core` always
migrates first (see `internal/migrate.Sources()`), so `core_reader` already
exists by the time an integration's baseline runs. The `member_console` login
role (the app's DSN user) ends up a member of `core_writer` and every
`{key}_writer` — membership in `core_writer` is granted by the core
baseline, and membership in each `{key}_writer` is granted by that
integration's own baseline. Owner roles are reserved for running migrations.
Each schema's authoring stream is solely responsible for its own roles,
grants, and `member_console` membership — there is no privilege wiring
performed from outside a schema's own migration stream.
## sqlc Code Generation
Each Go package has its own `sqlc.yaml` that generates type-safe Go code from SQL queries.
```bash
# Regenerate sqlc code after schema or query changes
cd internal/entitlements && sqlc generate
```
### `internal/db/sqlc_schemas.sql`
This file is **not** a goose migration — it is a sqlc-only helper that declares all PostgreSQL schemas (`CREATE SCHEMA IF NOT EXISTS ...`) so that sqlc can resolve schema-qualified table names (e.g., `core.accounts`) across packages. It declares `core`, `stripe`, and `fedwiki`, and should be listed in every package's `sqlc.yaml` schema paths alongside the migration directories it needs for FK resolution.
### Naming Convention
Table names must **not** repeat the schema name:
- Use `core.accounts`, not `core.billing_accounts`
- Use `core.persons`, not `core.identity_persons`
Generated Go type names carry no schema prefix — the package itself provides the namespace:
- `billing.Account`, not `billing.CoreAccount`
- `identity.Person`, not `identity.CorePerson`
This follows Go's own convention: `http.Request` not `http.HttpRequest`.
### Standard `sqlc.yaml` Template
Every package follows this pattern:
```yaml
schema:
- "../db/sqlc_schemas.sql" # always first — declares all schemas for cross-package resolution
- "../db/migrations/" # the core baseline (most packages read/write core tables)
# add the provider's own migrations only for stripe/fedwiki packages
# e.g. - "migrations/" # (internal/integrations/stripe/store, internal/integrations/fedwiki/store only)
queries: "queries/"
```
An integration's `sqlc.yaml` sits two directories deeper than a typical
package's — `internal/integrations/<key>/store/sqlc.yaml`, with `store/`
nested under the integration's own tree root — so its relative paths climb
two extra levels: `../../../db/migrations/` and
`../../../db/sqlc_schemas.sql`, alongside its own `migrations/` for the
provider's tables.
### `rename:` Block Placement
The `rename:` block goes inside `gen.go:`, after `emit_empty_slices` and before `overrides:`. Keys use the `{schema}_{singular_table}` format — with a single domain schema, most keys are `core_*` regardless of which Go package owns the query:
```yaml
gen:
go:
package: "billing"
emit_empty_slices: true
rename:
core_account: Account # core.accounts → Account
core_price: Price
overrides:
- db_type: "uuid"
go_type: "string"
```
Rename maps are pinned per package so that generated type names do not churn
when the underlying schema is shared — e.g. `internal/billing/sqlc.yaml` and
`internal/identity/sqlc.yaml` both see `core.persons` but only one may need to
generate a `Person` type, depending on which package's queries touch it.