feat(schema): M4.1 — tenant_registry schema + migrate binary #6

Merged
sharang merged 1 commits from feat/m4.1-schema into main 2026-05-19 10:10:15 +00:00
Owner

What

M4.1's full deliverable: PLATFORM_ARCHITECTURE.md §5c schema as a single golang-migrate migration, a standalone cmd/migrate binary, and testcontainers integration tests that exercise it against a real Postgres 16.

  • 6 tables: tenants, tenant_projects, tenant_products, tenant_idp_config, api_keys, audit_log.
  • 4 enums: tenant_status, tenant_kind, idp_kind, tenant_project_status.
  • cmd/migrate: embedded SQL via migrations/embed.go; subcommands up / down / version / force. Ships as a self-contained Orca init container in prod.
  • Makefile: make migrate-up / down / down-all / version / create NAME=…, make test-short for environments without Docker.

Why

M4.1 acceptance: make migrate-up on a fresh Postgres produces the documented schema. M4.2 (full REST surface + pgx-backed store) builds on top of this without changing the schema.

Linked milestone: M4.1

How

  • One migration file (0001_init.up.sql) lands the entire spec instead of N small migrations. Reasoning: this is the initial schema, not an evolution — splitting it across 6 files makes the round-trip review harder and the migration table noisier. Future schema changes get their own numbered pairs.
  • Constraints baked in: tenant.slug regex enforces ^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$ so the portal can trust slugs everywhere downstream. tenant_products is keyed on (tenant_id, product) so a product entitlement is either on or off, not duplicated. audit_log uses ON DELETE SET NULL for tenant_id so forensic history outlives a tenant delete.
  • Indexes chosen for the queries M4.2 + M10.2 will run: tenants_status_idx, audit_log(tenant_id, created_at DESC), audit_log(product, …), audit_log(actor_id, …), partial index on non-revoked api_keys.
  • updated_at triggers on the four mutable tables — no application-layer code path can forget to touch the column.
  • Embedded migrations: migrations/embed.go declares package migrations + //go:embed *.sql so both cmd/migrate and (future) cmd/server ship the SQL as part of the binary. No file mounting at runtime.
  • golang-migrate as a library, not the CLI — keeps the install surface small (no go install step in the Dockerfile), keeps the version pinned in go.mod.

Test plan

  • go test -race ./... — 6 unit tests + 3 testcontainers tests, all green (~25s total)
  • make build → static binary; make build-migrate → static migrator
  • Local Dockerfile build (multi-stage, both binaries land in the distroless image)
  • make migrate-up against the dev Postgres works end-to-end (verified locally)
  • Regression test added — the round-trip up/down asserts schema empties cleanly, catching any future migration that forgets symmetric DROP statements

Risk

Blast radius: repo-local. No service consumes the schema yet — M4.2 is the first consumer. Stage/prod databases don't exist yet (M1.2 brings VMs; M3.1 brings Infisical for the DB URL).

What could break:

  • The slug regex disallows [A-Z_.]. If anyone wants to import existing customers with non-conforming slugs, they'll need to either rewrite or relax. Caught by TestSlugConstraint.
  • The audit_log SET NULL on tenant delete is a deliberate choice — alternative is CASCADE (delete audit too). Talked through in code comments. If we later need full erasure for GDPR, do it in a separate redaction migration.

Rollback plan: make migrate-down-all in dev. The down migration is symmetric and tested.

Checklist

  • Unit tests added (testcontainers)
  • Docs updated (README has a full migrations section; CHANGELOG entry)
  • Secrets via Infisical — DATABASE_URL is read from env; Makefile defaults at the dev DSN, prod resolves via Infisical
  • Migration is forward-only + idempotent — migrate up is a no-op when on-version
  • Tenant scoping — n/a at the schema layer; M4.2 handlers enforce it
  • OpenAPI spec — n/a (no API changes); lands with M4.2
  • CHANGELOG entry under "Added"
## What M4.1's full deliverable: `PLATFORM_ARCHITECTURE.md §5c` schema as a single `golang-migrate` migration, a standalone `cmd/migrate` binary, and testcontainers integration tests that exercise it against a real Postgres 16. - **6 tables**: `tenants`, `tenant_projects`, `tenant_products`, `tenant_idp_config`, `api_keys`, `audit_log`. - **4 enums**: `tenant_status`, `tenant_kind`, `idp_kind`, `tenant_project_status`. - **`cmd/migrate`**: embedded SQL via `migrations/embed.go`; subcommands `up` / `down` / `version` / `force`. Ships as a self-contained Orca init container in prod. - **Makefile**: `make migrate-up / down / down-all / version / create NAME=…`, `make test-short` for environments without Docker. ## Why M4.1 acceptance: `make migrate-up` on a fresh Postgres produces the documented schema. M4.2 (full REST surface + pgx-backed store) builds on top of this without changing the schema. Linked milestone: **M4.1** ## How - **One migration file** (`0001_init.up.sql`) lands the entire spec instead of N small migrations. Reasoning: this is the *initial* schema, not an evolution — splitting it across 6 files makes the round-trip review harder and the migration table noisier. Future schema changes get their own numbered pairs. - **Constraints baked in**: `tenant.slug` regex enforces `^[a-z0-9][a-z0-9-]{1,38}[a-z0-9]$` so the portal can trust slugs everywhere downstream. `tenant_products` is keyed on `(tenant_id, product)` so a product entitlement is either on or off, not duplicated. `audit_log` uses `ON DELETE SET NULL` for `tenant_id` so forensic history outlives a tenant delete. - **Indexes** chosen for the queries M4.2 + M10.2 will run: `tenants_status_idx`, `audit_log(tenant_id, created_at DESC)`, `audit_log(product, …)`, `audit_log(actor_id, …)`, partial index on non-revoked `api_keys`. - **`updated_at` triggers** on the four mutable tables — no application-layer code path can forget to touch the column. - **Embedded migrations**: `migrations/embed.go` declares `package migrations` + `//go:embed *.sql` so both `cmd/migrate` and (future) `cmd/server` ship the SQL as part of the binary. No file mounting at runtime. - **`golang-migrate` as a library, not the CLI** — keeps the install surface small (no `go install` step in the Dockerfile), keeps the version pinned in `go.mod`. ## Test plan - [x] `go test -race ./...` — 6 unit tests + 3 testcontainers tests, all green (~25s total) - [x] `make build` → static binary; `make build-migrate` → static migrator - [x] Local Dockerfile build (multi-stage, both binaries land in the distroless image) - [x] `make migrate-up` against the dev Postgres works end-to-end (verified locally) - [x] Regression test added — the round-trip up/down asserts schema empties cleanly, catching any future migration that forgets symmetric `DROP` statements ## Risk **Blast radius:** repo-local. No service consumes the schema yet — M4.2 is the first consumer. Stage/prod databases don't exist yet (M1.2 brings VMs; M3.1 brings Infisical for the DB URL). **What could break:** - The slug regex disallows `[A-Z_.]`. If anyone wants to import existing customers with non-conforming slugs, they'll need to either rewrite or relax. Caught by `TestSlugConstraint`. - The `audit_log SET NULL` on tenant delete is a deliberate choice — alternative is CASCADE (delete audit too). Talked through in code comments. If we later need full erasure for GDPR, do it in a separate redaction migration. **Rollback plan:** `make migrate-down-all` in dev. The down migration is symmetric and tested. ## Checklist - [x] Unit tests added (testcontainers) - [x] Docs updated (README has a full migrations section; CHANGELOG entry) - [x] Secrets via Infisical — `DATABASE_URL` is read from env; Makefile defaults at the dev DSN, prod resolves via Infisical - [x] Migration is forward-only + idempotent — `migrate up` is a no-op when on-version - [ ] Tenant scoping — n/a at the schema layer; M4.2 handlers enforce it - [ ] OpenAPI spec — n/a (no API changes); lands with M4.2 - [x] CHANGELOG entry under "Added"
CODEOWNERS rules requested review from Benjamin_Boenisch 2026-05-19 10:02:12 +00:00
sharang force-pushed feat/m4.1-schema from a27072b6d0 to 8a35942aea 2026-05-19 10:06:06 +00:00 Compare
sharang force-pushed feat/m4.1-schema from 8a35942aea to 2a05dfc166 2026-05-19 10:07:40 +00:00 Compare
sharang added 1 commit 2026-05-19 10:08:47 +00:00
feat(schema): M4.1 — full tenant_registry schema + migrate binary
ci / shared (pull_request) Successful in 5s
ci / test (pull_request) Successful in 38s
ci / image (pull_request) Has been skipped
f9e9f0e21b
PLATFORM_ARCHITECTURE.md §5c schema, end-to-end:

  enums:    tenant_status (demo/trial/active/frozen/archived),
            tenant_kind (customer/demo), idp_kind (oidc/saml),
            tenant_project_status (active/archived)

  tables:   tenants            id/slug/name/status/kind/plan/erp_id/
                               stripe_id/trial_ends_at/contract_dates/
                               sales_owner
            tenant_projects    sub-tenancy (GCP-Project style); opt-in
                               via product manifest.supports_projects=true
            tenant_products    tenant ↔ product matrix + JSONB config
            tenant_idp_config  enterprise SSO (OIDC/SAML metadata)
            api_keys           argon2 hash + prefix + scopes + revoked_at
            audit_log          Retraced-compatible; indexed for cross-
                               product filtering per §8.4

  triggers: updated_at auto-bump on every mutable table
  fks:      ON DELETE CASCADE for owned rows; SET NULL for audit_log

cmd/migrate (new binary): golang-migrate as a library with migrations
embedded via migrations/embed.go; subcommands up/down/version/force.
Ships as a self-contained Orca init container in prod.

Tests (require Docker; gated by -short):
  TestMigrate_upDownRoundTrip   schema → 6 tables + 4 enums; down→
                                empty; up-after-down clean
  TestSeed_canInsertAndQuery    insert across every table; FK cascade;
                                audit_log SET-NULL keeps the row
  TestSlugConstraint            regex rejects too-short / leading dash /
                                trailing dash / uppercase / underscore

Makefile: migrate-up/down/down-all/version/create NAME=...; test-short
to skip integration when Docker isn't around; build-migrate for just
the migrator.

CI: pin golangci-lint to v2.12.2 (Go 1.25-compatible) + bump
golangci-lint-action to v7 (v6 rejects v2.x).

The handler-layer in-memory store is unchanged; M4.2 swaps it for the
pgx-backed implementation against this schema.

Refs: M4.1
sharang force-pushed feat/m4.1-schema from 2a05dfc166 to f9e9f0e21b 2026-05-19 10:08:47 +00:00 Compare
sharang merged commit d66760b246 into main 2026-05-19 10:10:15 +00:00
sharang deleted branch feat/m4.1-schema 2026-05-19 10:10:15 +00:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: platform/tenant-registry#6