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.
199 lines
9.1 KiB
Markdown
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.
|