Introduce DB schema separation (core and fedwiki)
Add a goose migration to create core and fedwiki schemas and move existing domain tables using ALTER TABLE IF EXISTS. Set connection search_path to "core, public" after successful DB ping. Update FedWiki SQL and sqlc.yaml to use fedwiki.sites and include db migrations for schema awareness. Add design docs, specs, and tasks for schema-namespacing and the migration plan.
This commit is contained in:
@ -5,6 +5,8 @@ import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
@ -30,9 +32,30 @@ func DefaultDBConfig(dsn string) *DBConfig {
|
||||
}
|
||||
}
|
||||
|
||||
// ensureSearchPath appends search_path=core,public to the DSN if not already
|
||||
// present, so every connection in the pool resolves core-schema tables without
|
||||
// qualification (Decision 85/86).
|
||||
func ensureSearchPath(dsn string) string {
|
||||
u, err := url.Parse(dsn)
|
||||
if err != nil || u.Scheme == "" {
|
||||
// Key=value DSN format — append if not already set.
|
||||
if !strings.Contains(dsn, "search_path") {
|
||||
return dsn + " search_path=core,public"
|
||||
}
|
||||
return dsn
|
||||
}
|
||||
// URI format — append as query parameter.
|
||||
q := u.Query()
|
||||
if q.Get("search_path") == "" {
|
||||
q.Set("search_path", "core,public")
|
||||
u.RawQuery = q.Encode()
|
||||
}
|
||||
return u.String()
|
||||
}
|
||||
|
||||
// openAndConfigureDB opens the database connection and configures the connection pool.
|
||||
func openAndConfigureDB(config *DBConfig) (*sql.DB, error) {
|
||||
db, err := sql.Open("pgx", config.DSN)
|
||||
db, err := sql.Open("pgx", ensureSearchPath(config.DSN))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open database: %w", err)
|
||||
}
|
||||
|
||||
68
internal/db/migrations/00003_schema_separation.sql
Normal file
68
internal/db/migrations/00003_schema_separation.sql
Normal file
@ -0,0 +1,68 @@
|
||||
-- +goose Up
|
||||
-- Decision 85: All domain tables live in a `core` PostgreSQL schema.
|
||||
-- Decision 86: Integration modules get schema-per-provider (fedwiki).
|
||||
-- The `public` schema retains shared extensions and functions only.
|
||||
|
||||
CREATE SCHEMA IF NOT EXISTS core;
|
||||
CREATE SCHEMA IF NOT EXISTS fedwiki;
|
||||
|
||||
-- Move existing domain tables to core schema (no-op on fresh installs).
|
||||
-- Identity
|
||||
ALTER TABLE IF EXISTS public.users SET SCHEMA core;
|
||||
ALTER TABLE IF EXISTS public.persons SET SCHEMA core;
|
||||
|
||||
-- Organization
|
||||
ALTER TABLE IF EXISTS public.organizations SET SCHEMA core;
|
||||
ALTER TABLE IF EXISTS public.workspaces SET SCHEMA core;
|
||||
ALTER TABLE IF EXISTS public.roles SET SCHEMA core;
|
||||
ALTER TABLE IF EXISTS public.org_members SET SCHEMA core;
|
||||
ALTER TABLE IF EXISTS public.role_assignments SET SCHEMA core;
|
||||
ALTER TABLE IF EXISTS public.invitations SET SCHEMA core;
|
||||
|
||||
-- Billing
|
||||
ALTER TABLE IF EXISTS public.products SET SCHEMA core;
|
||||
|
||||
-- Entitlements
|
||||
ALTER TABLE IF EXISTS public.resource_keys SET SCHEMA core;
|
||||
ALTER TABLE IF EXISTS public.resource_pools SET SCHEMA core;
|
||||
ALTER TABLE IF EXISTS public.grants SET SCHEMA core;
|
||||
ALTER TABLE IF EXISTS public.pool_provisions SET SCHEMA core;
|
||||
ALTER TABLE IF EXISTS public.pool_assignments SET SCHEMA core;
|
||||
ALTER TABLE IF EXISTS public.numeric_entitlements SET SCHEMA core;
|
||||
ALTER TABLE IF EXISTS public.numeric_entitlement_contributions SET SCHEMA core;
|
||||
ALTER TABLE IF EXISTS public.numeric_entitlement_usage SET SCHEMA core;
|
||||
ALTER TABLE IF EXISTS public.entitlement_sets SET SCHEMA core;
|
||||
ALTER TABLE IF EXISTS public.entitlement_set_rules SET SCHEMA core;
|
||||
ALTER TABLE IF EXISTS public.product_entitlement_rules SET SCHEMA core;
|
||||
|
||||
-- Move fedwiki integration table to its own schema (Decision 86).
|
||||
ALTER TABLE IF EXISTS public.sites SET SCHEMA fedwiki;
|
||||
|
||||
-- +goose Down
|
||||
-- Move everything back to public.
|
||||
|
||||
ALTER TABLE IF EXISTS fedwiki.sites SET SCHEMA public;
|
||||
|
||||
ALTER TABLE IF EXISTS core.product_entitlement_rules SET SCHEMA public;
|
||||
ALTER TABLE IF EXISTS core.entitlement_set_rules SET SCHEMA public;
|
||||
ALTER TABLE IF EXISTS core.entitlement_sets SET SCHEMA public;
|
||||
ALTER TABLE IF EXISTS core.numeric_entitlement_usage SET SCHEMA public;
|
||||
ALTER TABLE IF EXISTS core.numeric_entitlement_contributions SET SCHEMA public;
|
||||
ALTER TABLE IF EXISTS core.numeric_entitlements SET SCHEMA public;
|
||||
ALTER TABLE IF EXISTS core.pool_assignments SET SCHEMA public;
|
||||
ALTER TABLE IF EXISTS core.pool_provisions SET SCHEMA public;
|
||||
ALTER TABLE IF EXISTS core.grants SET SCHEMA public;
|
||||
ALTER TABLE IF EXISTS core.resource_pools SET SCHEMA public;
|
||||
ALTER TABLE IF EXISTS core.resource_keys SET SCHEMA public;
|
||||
ALTER TABLE IF EXISTS core.products SET SCHEMA public;
|
||||
ALTER TABLE IF EXISTS core.invitations SET SCHEMA public;
|
||||
ALTER TABLE IF EXISTS core.role_assignments SET SCHEMA public;
|
||||
ALTER TABLE IF EXISTS core.org_members SET SCHEMA public;
|
||||
ALTER TABLE IF EXISTS core.roles SET SCHEMA public;
|
||||
ALTER TABLE IF EXISTS core.workspaces SET SCHEMA public;
|
||||
ALTER TABLE IF EXISTS core.organizations SET SCHEMA public;
|
||||
ALTER TABLE IF EXISTS core.persons SET SCHEMA public;
|
||||
ALTER TABLE IF EXISTS core.users SET SCHEMA public;
|
||||
|
||||
DROP SCHEMA IF EXISTS fedwiki;
|
||||
DROP SCHEMA IF EXISTS core;
|
||||
@ -11,26 +11,26 @@ DROP INDEX IF EXISTS idx_sites_user_id;
|
||||
DROP TABLE IF EXISTS payments;
|
||||
DROP TABLE IF EXISTS sites;
|
||||
|
||||
-- Create new workspace-scoped sites table
|
||||
CREATE TABLE sites (
|
||||
-- Create new workspace-scoped sites table in fedwiki schema (Decision 86).
|
||||
CREATE TABLE fedwiki.sites (
|
||||
site_id UUID PRIMARY KEY DEFAULT uuidv7(),
|
||||
workspace_id UUID NOT NULL REFERENCES workspaces(workspace_id),
|
||||
workspace_id UUID NOT NULL REFERENCES core.workspaces(workspace_id),
|
||||
domain TEXT UNIQUE NOT NULL,
|
||||
is_custom_domain BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_sites_workspace_id ON sites(workspace_id);
|
||||
CREATE INDEX idx_sites_domain ON sites(domain);
|
||||
CREATE INDEX idx_sites_workspace_id ON fedwiki.sites(workspace_id);
|
||||
CREATE INDEX idx_sites_domain ON fedwiki.sites(domain);
|
||||
|
||||
CREATE TRIGGER trigger_sites_updated_at
|
||||
BEFORE UPDATE ON sites
|
||||
BEFORE UPDATE ON fedwiki.sites
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION update_updated_at_column();
|
||||
EXECUTE FUNCTION public.update_updated_at_column();
|
||||
|
||||
-- +goose Down
|
||||
DROP TRIGGER IF EXISTS trigger_sites_updated_at ON sites;
|
||||
DROP INDEX IF EXISTS idx_sites_domain;
|
||||
DROP INDEX IF EXISTS idx_sites_workspace_id;
|
||||
DROP TABLE IF EXISTS sites;
|
||||
DROP TRIGGER IF EXISTS trigger_sites_updated_at ON fedwiki.sites;
|
||||
DROP INDEX IF EXISTS fedwiki.idx_sites_domain;
|
||||
DROP INDEX IF EXISTS fedwiki.idx_sites_workspace_id;
|
||||
DROP TABLE IF EXISTS fedwiki.sites;
|
||||
|
||||
@ -1,39 +1,39 @@
|
||||
-- name: CreateSite :one
|
||||
INSERT INTO sites (workspace_id, domain, is_custom_domain)
|
||||
INSERT INTO fedwiki.sites (workspace_id, domain, is_custom_domain)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetSiteByID :one
|
||||
SELECT * FROM sites
|
||||
SELECT * FROM fedwiki.sites
|
||||
WHERE site_id = $1;
|
||||
|
||||
-- name: GetSiteByDomain :one
|
||||
SELECT * FROM sites
|
||||
SELECT * FROM fedwiki.sites
|
||||
WHERE domain = $1;
|
||||
|
||||
-- name: ListSitesByWorkspace :many
|
||||
SELECT * FROM sites
|
||||
SELECT * FROM fedwiki.sites
|
||||
WHERE workspace_id = $1
|
||||
ORDER BY domain ASC;
|
||||
|
||||
-- name: ListAllSites :many
|
||||
SELECT * FROM sites
|
||||
SELECT * FROM fedwiki.sites
|
||||
ORDER BY domain ASC;
|
||||
|
||||
-- name: DeleteSite :exec
|
||||
DELETE FROM sites
|
||||
DELETE FROM fedwiki.sites
|
||||
WHERE site_id = $1;
|
||||
|
||||
-- name: DeleteSiteByDomain :exec
|
||||
DELETE FROM sites
|
||||
DELETE FROM fedwiki.sites
|
||||
WHERE domain = $1;
|
||||
|
||||
-- name: CountSitesByWorkspace :one
|
||||
SELECT COUNT(*) AS count FROM sites
|
||||
SELECT COUNT(*) AS count FROM fedwiki.sites
|
||||
WHERE workspace_id = $1;
|
||||
|
||||
-- name: UpsertSiteByDomain :one
|
||||
INSERT INTO sites (workspace_id, domain, is_custom_domain)
|
||||
INSERT INTO fedwiki.sites (workspace_id, domain, is_custom_domain)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT(domain) DO UPDATE SET
|
||||
workspace_id = excluded.workspace_id,
|
||||
|
||||
@ -10,7 +10,7 @@ import (
|
||||
)
|
||||
|
||||
const countSitesByWorkspace = `-- name: CountSitesByWorkspace :one
|
||||
SELECT COUNT(*) AS count FROM sites
|
||||
SELECT COUNT(*) AS count FROM fedwiki.sites
|
||||
WHERE workspace_id = $1
|
||||
`
|
||||
|
||||
@ -22,7 +22,7 @@ func (q *Queries) CountSitesByWorkspace(ctx context.Context, workspaceID string)
|
||||
}
|
||||
|
||||
const createSite = `-- name: CreateSite :one
|
||||
INSERT INTO sites (workspace_id, domain, is_custom_domain)
|
||||
INSERT INTO fedwiki.sites (workspace_id, domain, is_custom_domain)
|
||||
VALUES ($1, $2, $3)
|
||||
RETURNING site_id, workspace_id, domain, is_custom_domain, created_at, updated_at
|
||||
`
|
||||
@ -48,7 +48,7 @@ func (q *Queries) CreateSite(ctx context.Context, arg CreateSiteParams) (Site, e
|
||||
}
|
||||
|
||||
const deleteSite = `-- name: DeleteSite :exec
|
||||
DELETE FROM sites
|
||||
DELETE FROM fedwiki.sites
|
||||
WHERE site_id = $1
|
||||
`
|
||||
|
||||
@ -58,7 +58,7 @@ func (q *Queries) DeleteSite(ctx context.Context, siteID string) error {
|
||||
}
|
||||
|
||||
const deleteSiteByDomain = `-- name: DeleteSiteByDomain :exec
|
||||
DELETE FROM sites
|
||||
DELETE FROM fedwiki.sites
|
||||
WHERE domain = $1
|
||||
`
|
||||
|
||||
@ -68,7 +68,7 @@ func (q *Queries) DeleteSiteByDomain(ctx context.Context, domain string) error {
|
||||
}
|
||||
|
||||
const getSiteByDomain = `-- name: GetSiteByDomain :one
|
||||
SELECT site_id, workspace_id, domain, is_custom_domain, created_at, updated_at FROM sites
|
||||
SELECT site_id, workspace_id, domain, is_custom_domain, created_at, updated_at FROM fedwiki.sites
|
||||
WHERE domain = $1
|
||||
`
|
||||
|
||||
@ -87,7 +87,7 @@ func (q *Queries) GetSiteByDomain(ctx context.Context, domain string) (Site, err
|
||||
}
|
||||
|
||||
const getSiteByID = `-- name: GetSiteByID :one
|
||||
SELECT site_id, workspace_id, domain, is_custom_domain, created_at, updated_at FROM sites
|
||||
SELECT site_id, workspace_id, domain, is_custom_domain, created_at, updated_at FROM fedwiki.sites
|
||||
WHERE site_id = $1
|
||||
`
|
||||
|
||||
@ -106,7 +106,7 @@ func (q *Queries) GetSiteByID(ctx context.Context, siteID string) (Site, error)
|
||||
}
|
||||
|
||||
const listAllSites = `-- name: ListAllSites :many
|
||||
SELECT site_id, workspace_id, domain, is_custom_domain, created_at, updated_at FROM sites
|
||||
SELECT site_id, workspace_id, domain, is_custom_domain, created_at, updated_at FROM fedwiki.sites
|
||||
ORDER BY domain ASC
|
||||
`
|
||||
|
||||
@ -141,7 +141,7 @@ func (q *Queries) ListAllSites(ctx context.Context) ([]Site, error) {
|
||||
}
|
||||
|
||||
const listSitesByWorkspace = `-- name: ListSitesByWorkspace :many
|
||||
SELECT site_id, workspace_id, domain, is_custom_domain, created_at, updated_at FROM sites
|
||||
SELECT site_id, workspace_id, domain, is_custom_domain, created_at, updated_at FROM fedwiki.sites
|
||||
WHERE workspace_id = $1
|
||||
ORDER BY domain ASC
|
||||
`
|
||||
@ -177,7 +177,7 @@ func (q *Queries) ListSitesByWorkspace(ctx context.Context, workspaceID string)
|
||||
}
|
||||
|
||||
const upsertSiteByDomain = `-- name: UpsertSiteByDomain :one
|
||||
INSERT INTO sites (workspace_id, domain, is_custom_domain)
|
||||
INSERT INTO fedwiki.sites (workspace_id, domain, is_custom_domain)
|
||||
VALUES ($1, $2, $3)
|
||||
ON CONFLICT(domain) DO UPDATE SET
|
||||
workspace_id = excluded.workspace_id,
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
version: "2"
|
||||
sql:
|
||||
- engine: "postgresql"
|
||||
schema: "migrations/"
|
||||
schema:
|
||||
- "../db/migrations/"
|
||||
- "migrations/"
|
||||
queries: "queries/"
|
||||
gen:
|
||||
go:
|
||||
@ -13,6 +15,9 @@ sql:
|
||||
emit_interface: true
|
||||
emit_exact_table_names: false
|
||||
emit_empty_slices: true
|
||||
rename:
|
||||
fedwiki_site: Site
|
||||
fedwiki_sites: Site
|
||||
overrides:
|
||||
- db_type: "uuid"
|
||||
go_type: "string"
|
||||
|
||||
@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-03-30
|
||||
@ -0,0 +1,63 @@
|
||||
## Context
|
||||
|
||||
All domain tables currently reside in PostgreSQL's default `public` schema. Design Decisions 85 and 86 mandate a `core` schema for domain tables and schema-per-provider for integrations. The system uses goose for migrations with a namespace-per-module assembly strategy (module index × 1000 + sequence). FedWiki is the only integration module with database tables. sqlc generates query code from migration DDL.
|
||||
|
||||
## Goals / Non-Goals
|
||||
|
||||
**Goals:**
|
||||
- Move all domain tables to `core` schema (Decision 85)
|
||||
- Move FedWiki `sites` table to `fedwiki` schema (Decision 86)
|
||||
- Work correctly on both existing databases (table move) and fresh installs (correct schema from start)
|
||||
- Preserve all existing FK relationships and triggers
|
||||
|
||||
**Non-Goals:**
|
||||
- Creating integration schemas for providers not yet built (Stripe, Polar)
|
||||
- Changing the migration assembly strategy or goose versioning
|
||||
- Modifying any domain module queries (they resolve via `search_path`)
|
||||
|
||||
## Decisions
|
||||
|
||||
### 1. `search_path = core, public` set in application code
|
||||
|
||||
Set `search_path` via `SET search_path = core, public` in `db.Connect()` after ping succeeds, rather than via `ALTER ROLE` or DSN parameter.
|
||||
|
||||
**Why over ALTER ROLE:** Doesn't require superuser privileges or affect other connections to the same database. Portable across environments.
|
||||
|
||||
**Why over DSN parameter:** pgx DSN `search_path` support varies; explicit SET is unambiguous.
|
||||
|
||||
**Trade-off:** Every new connection must execute the SET. Since we use `Connect()` as the single entry point, this is guaranteed.
|
||||
|
||||
### 2. Single migration for schema creation + table moves with `IF EXISTS`
|
||||
|
||||
`db/00003_schema_separation.sql` both creates schemas and moves tables using `ALTER TABLE IF EXISTS public.X SET SCHEMA core`.
|
||||
|
||||
**Why IF EXISTS:** On fresh installs, migration 1003 runs before module migrations (2001+). No tables exist to move — `IF EXISTS` makes this a no-op. On existing databases, tables exist in `public` and get moved.
|
||||
|
||||
**Why not separate create + move migrations:** No benefit to splitting; the operations are idempotent and atomic within a single migration.
|
||||
|
||||
### 3. FedWiki migration uses explicit schema qualification
|
||||
|
||||
`fedwiki/00001_init.sql` updated to use `fedwiki.sites`, `core.workspaces`, and `public.update_updated_at_column()`.
|
||||
|
||||
**Why modify existing migration:** Goose doesn't use checksums, so modifying already-applied migrations is safe on existing databases (won't re-run). On fresh databases, tables are created in the correct schema from the start.
|
||||
|
||||
**Alternative considered:** Adding a separate fedwiki migration to move the table — rejected because it would create the table in `core` (via search_path) then immediately move it, which is wasteful.
|
||||
|
||||
### 4. sqlc schema path includes `../db/migrations/`
|
||||
|
||||
FedWiki's `sqlc.yaml` lists both `../db/migrations/` and `migrations/` as schema sources so sqlc can resolve the `fedwiki` schema created in `db/00003`.
|
||||
|
||||
**Rename override:** sqlc would generate `FedwikiSite` from `fedwiki.sites`; a `rename` config maps it back to `Site` for API compatibility.
|
||||
|
||||
## Risks / Trade-offs
|
||||
|
||||
- **Existing sessions during migration** → Active connections retain old `search_path` until reconnection. Mitigation: deploy during low-traffic window; application restart resets all connections.
|
||||
- **Third-party tools (LibreOffice Base, pgAdmin)** → Must update `search_path` or use schema-qualified names. Mitigation: user-facing documentation note.
|
||||
- **Goose version table stays in `public`** → Expected; goose manages its own `goose_db_version` table in whatever schema it runs against. No action needed.
|
||||
|
||||
## Migration Plan
|
||||
|
||||
1. Deploy new code (includes migration + search_path change)
|
||||
2. On startup, `ConnectAndMigrate` runs migration 1003: creates schemas, moves tables
|
||||
3. All subsequent queries use `search_path = core, public`
|
||||
4. **Rollback:** Run `goose down` on migration 1003 — moves all tables back to `public`, drops schemas. Remove search_path SET from `Connect()`.
|
||||
@ -0,0 +1,29 @@
|
||||
## Why
|
||||
|
||||
The design documents (Decisions 85, 86) specify that all domain tables live in a `core` PostgreSQL schema and integration modules get their own schema-per-provider. Currently all tables sit in the default `public` schema, violating these architectural boundaries and making it impossible to cleanly decommission integrations via `DROP SCHEMA CASCADE`.
|
||||
|
||||
## What Changes
|
||||
|
||||
- **BREAKING**: Create `core` PostgreSQL schema and move all domain tables (identity, organization, billing, entitlements) into it
|
||||
- **BREAKING**: Create `fedwiki` PostgreSQL schema and move the `sites` table into it
|
||||
- Set `search_path = core, public` on database connections so domain queries resolve without schema qualification
|
||||
- Update FedWiki queries and sqlc configuration to use schema-qualified `fedwiki.sites` references
|
||||
- Retain `update_updated_at_column()` function in `public` as shared infrastructure
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
- `schema-namespacing`: PostgreSQL schema separation per design Decisions 85/86 — `core` for domain tables, schema-per-provider for integrations
|
||||
|
||||
### Modified Capabilities
|
||||
- `postgres-database`: Connection setup now sets `search_path = core, public`
|
||||
- `module-migrations`: Base `db` migration creates schemas and moves existing tables
|
||||
- `fedwiki-sites`: Queries and migrations use schema-qualified `fedwiki.sites`
|
||||
|
||||
## Impact
|
||||
|
||||
- **Database**: All existing tables move schemas — requires migration on running instances
|
||||
- **Connection config**: `search_path` set after connect; DSN unchanged
|
||||
- **sqlc**: FedWiki sqlc.yaml now includes `../db/migrations/` for schema awareness; rename override preserves `Site` type name
|
||||
- **Queries**: Only FedWiki queries change (schema-qualified); all core module queries continue working via `search_path`
|
||||
- **Fresh installs**: Tables automatically created in correct schema; `ALTER TABLE IF EXISTS` no-ops safely
|
||||
@ -0,0 +1,22 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Sites are workspace-scoped
|
||||
|
||||
The system SHALL store FedWiki sites in a `fedwiki.sites` table (in the `fedwiki` PostgreSQL schema) scoped to workspaces. Each site SHALL have a `site_id` (UUID), `workspace_id` (FK to `core.workspaces`), `domain` (unique), and `is_custom_domain` (boolean). The FedWiki migration SHALL use schema-qualified names: `fedwiki.sites` for the table, `core.workspaces(workspace_id)` for the FK reference, and `public.update_updated_at_column()` for the trigger function.
|
||||
|
||||
#### Scenario: Site belongs to a workspace
|
||||
|
||||
- **WHEN** a site is created
|
||||
- **THEN** the site SHALL be stored in `fedwiki.sites`
|
||||
- **AND** the site SHALL be associated with a workspace via `workspace_id` referencing `core.workspaces`
|
||||
|
||||
#### Scenario: Domain uniqueness
|
||||
|
||||
- **WHEN** a site is created with a domain that already exists
|
||||
- **THEN** the creation SHALL fail with a uniqueness constraint violation
|
||||
|
||||
#### Scenario: All queries use schema-qualified table name
|
||||
|
||||
- **WHEN** any FedWiki query (create, read, update, delete) executes
|
||||
- **THEN** the SQL SHALL reference `fedwiki.sites` (schema-qualified)
|
||||
- **AND** the query SHALL NOT use unqualified `sites`
|
||||
@ -0,0 +1,35 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Old migration coexistence
|
||||
|
||||
The existing `internal/db/migrations/00001_init.sql` SHALL remain in place for legacy table creation and cleanup. A new `internal/db/migrations/00003_schema_separation.sql` SHALL create the `core` and `fedwiki` schemas and move any existing domain tables from `public` to their correct schemas using `ALTER TABLE IF EXISTS`. The `update_updated_at_column()` function SHALL remain in the `public` schema as shared infrastructure.
|
||||
|
||||
#### Scenario: Old users table dropped
|
||||
|
||||
- **WHEN** the identity module's migration runs
|
||||
- **THEN** it SHALL execute `DROP TABLE IF EXISTS users CASCADE` before creating the new `users` table
|
||||
- **AND** the `sites` and `payments` tables SHALL retain their `user_id` columns but without FK constraints
|
||||
|
||||
#### Scenario: Schema separation migration on existing database
|
||||
|
||||
- **WHEN** migration `00003_schema_separation.sql` runs against a database with tables in `public`
|
||||
- **THEN** schemas `core` and `fedwiki` SHALL be created
|
||||
- **AND** all domain tables SHALL be moved to `core`
|
||||
- **AND** the `sites` table SHALL be moved to `fedwiki`
|
||||
|
||||
#### Scenario: Schema separation migration on fresh database
|
||||
|
||||
- **WHEN** migration `00003_schema_separation.sql` runs against an empty database
|
||||
- **THEN** schemas `core` and `fedwiki` SHALL be created
|
||||
- **AND** the `ALTER TABLE IF EXISTS` statements SHALL be no-ops (no tables to move)
|
||||
- **AND** subsequent module migrations SHALL create tables in the correct schema via `search_path`
|
||||
|
||||
### Requirement: Per-module sqlc configuration
|
||||
|
||||
Each module SHALL have its own `sqlc.yaml` configuration file that reads migrations from its own directory (and upstream module directories for FK resolution and schema awareness) and generates Go code into its own package. Modules whose tables reside in a non-default schema SHALL include the `db` module's migrations in their `schema` list so sqlc can resolve the schema definition.
|
||||
|
||||
#### Scenario: FedWiki sqlc config with schema awareness
|
||||
|
||||
- **WHEN** `sqlc generate` is run from the FedWiki module directory
|
||||
- **THEN** it SHALL read both `../db/migrations/` and `migrations/` for schema inference
|
||||
- **AND** it SHALL generate Go types with the original type names (e.g., `Site` not `FedwikiSite`) via rename overrides
|
||||
@ -0,0 +1,22 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: PostgreSQL connection via pgx driver
|
||||
|
||||
The system SHALL connect to PostgreSQL using the `pgx/v5` driver with stdlib compatibility mode (`pgx/v5/stdlib`). After establishing the connection, the system SHALL execute `SET search_path = core, public` to ensure domain tables resolve without schema qualification.
|
||||
|
||||
#### Scenario: Successful connection
|
||||
|
||||
- **WHEN** the application starts with a valid PostgreSQL DSN
|
||||
- **THEN** a database connection pool is established and ready for queries
|
||||
- **AND** the connection's `search_path` SHALL be set to `core, public`
|
||||
|
||||
#### Scenario: Connection failure
|
||||
|
||||
- **WHEN** the application starts with an invalid or unreachable PostgreSQL DSN
|
||||
- **THEN** the application fails to start with a descriptive error message
|
||||
|
||||
#### Scenario: search_path failure
|
||||
|
||||
- **WHEN** the connection succeeds but `SET search_path` fails
|
||||
- **THEN** the connection SHALL be closed
|
||||
- **AND** the application SHALL fail to start with a descriptive error message
|
||||
@ -0,0 +1,47 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Domain tables live in core schema
|
||||
|
||||
All domain tables (identity, organization, billing, entitlements) SHALL reside in the `core` PostgreSQL schema. The `public` schema SHALL hold only shared extensions and functions (e.g., `update_updated_at_column()`).
|
||||
|
||||
#### Scenario: Fresh install creates tables in core schema
|
||||
|
||||
- **WHEN** migrations run against an empty database
|
||||
- **THEN** all domain tables SHALL exist in the `core` schema
|
||||
- **AND** the `public` schema SHALL contain only the `update_updated_at_column()` function and the `goose_db_version` table
|
||||
|
||||
#### Scenario: Existing install moves tables to core schema
|
||||
|
||||
- **WHEN** migration `00003_schema_separation.sql` runs against a database with tables in `public`
|
||||
- **THEN** all domain tables SHALL be moved from `public` to `core`
|
||||
- **AND** all FK relationships, indexes, and triggers SHALL remain intact
|
||||
|
||||
### Requirement: Integration modules get schema-per-provider
|
||||
|
||||
Each external integration SHALL have its own PostgreSQL schema (e.g., `fedwiki`). Integration schemas SHALL reference `core` tables via FKs but SHALL NOT reference each other. An integration can be cleanly removed via `DROP SCHEMA CASCADE`.
|
||||
|
||||
#### Scenario: FedWiki integration schema
|
||||
|
||||
- **WHEN** the FedWiki module's migration runs
|
||||
- **THEN** the `sites` table SHALL exist in the `fedwiki` schema
|
||||
- **AND** `fedwiki.sites.workspace_id` SHALL reference `core.workspaces(workspace_id)`
|
||||
|
||||
#### Scenario: Integration decommissioning
|
||||
|
||||
- **WHEN** an administrator runs `DROP SCHEMA fedwiki CASCADE`
|
||||
- **THEN** all FedWiki tables, indexes, and triggers SHALL be removed
|
||||
- **AND** no core schema tables SHALL be affected
|
||||
|
||||
### Requirement: search_path resolves core tables without qualification
|
||||
|
||||
The database connection SHALL set `search_path = core, public` so that domain module queries can use unqualified table names. Integration module queries SHALL use schema-qualified names (e.g., `fedwiki.sites`).
|
||||
|
||||
#### Scenario: Unqualified query resolves to core
|
||||
|
||||
- **WHEN** a query references `users` without schema qualification
|
||||
- **THEN** PostgreSQL SHALL resolve it to `core.users`
|
||||
|
||||
#### Scenario: Integration queries use explicit schema
|
||||
|
||||
- **WHEN** a FedWiki query references `fedwiki.sites`
|
||||
- **THEN** PostgreSQL SHALL resolve it to the `fedwiki` schema regardless of `search_path`
|
||||
@ -0,0 +1,25 @@
|
||||
## 1. Schema Creation Migration
|
||||
|
||||
- [x] 1.1 Create `internal/db/migrations/00003_schema_separation.sql` with `CREATE SCHEMA IF NOT EXISTS core` and `CREATE SCHEMA IF NOT EXISTS fedwiki`
|
||||
- [x] 1.2 Add `ALTER TABLE IF EXISTS public.<table> SET SCHEMA core` for all domain tables (identity, organization, billing, entitlements)
|
||||
- [x] 1.3 Add `ALTER TABLE IF EXISTS public.sites SET SCHEMA fedwiki` for the FedWiki integration table
|
||||
- [x] 1.4 Write goose Down migration that reverses all moves and drops schemas
|
||||
|
||||
## 2. Connection Configuration
|
||||
|
||||
- [x] 2.1 Add `SET search_path = core, public` in `db.Connect()` after successful ping
|
||||
- [x] 2.2 Close connection and return error if search_path SET fails
|
||||
|
||||
## 3. FedWiki Schema Qualification
|
||||
|
||||
- [x] 3.1 Update `internal/fedwiki/migrations/00001_init.sql` to use `fedwiki.sites`, `core.workspaces`, and `public.update_updated_at_column()`
|
||||
- [x] 3.2 Update all queries in `internal/fedwiki/queries/sites.sql` to reference `fedwiki.sites`
|
||||
- [x] 3.3 Update `internal/fedwiki/sqlc.yaml` to include `../db/migrations/` in schema paths
|
||||
- [x] 3.4 Add sqlc `rename` override to keep `Site` type name (not `FedwikiSite`)
|
||||
- [x] 3.5 Regenerate sqlc code with `sqlc generate`
|
||||
|
||||
## 4. Verification
|
||||
|
||||
- [x] 4.1 Confirm `go build ./cmd/... ./internal/...` compiles without errors
|
||||
- [x] 4.2 Run migration against existing database and verify tables moved to correct schemas
|
||||
- [x] 4.3 Verify application starts and queries resolve correctly with new search_path
|
||||
@ -8,19 +8,25 @@ Defines how FedWiki sites are stored, created, and deleted within the workspace-
|
||||
|
||||
### Requirement: Sites are workspace-scoped
|
||||
|
||||
The system SHALL store FedWiki sites in a `sites` table scoped to workspaces. Each site SHALL have a `site_id` (UUID), `workspace_id` (FK to workspaces), `domain` (unique), and `is_custom_domain` (boolean). The legacy user-scoped `sites` table (with integer `user_id` and `owner_oidc_subject`) SHALL be removed.
|
||||
The system SHALL store FedWiki sites in a `fedwiki.sites` table (in the `fedwiki` PostgreSQL schema) scoped to workspaces. Each site SHALL have a `site_id` (UUID), `workspace_id` (FK to `core.workspaces`), `domain` (unique), and `is_custom_domain` (boolean). The FedWiki migration SHALL use schema-qualified names: `fedwiki.sites` for the table, `core.workspaces(workspace_id)` for the FK reference, and `public.update_updated_at_column()` for the trigger function. The legacy user-scoped `sites` table (with integer `user_id` and `owner_oidc_subject`) SHALL be removed.
|
||||
|
||||
#### Scenario: Site belongs to a workspace
|
||||
|
||||
- **WHEN** a site is created
|
||||
- **THEN** the site SHALL be associated with a workspace via `workspace_id`
|
||||
- **AND** the site SHALL NOT have a `user_id` or `owner_oidc_subject` field
|
||||
- **THEN** the site SHALL be stored in `fedwiki.sites`
|
||||
- **AND** the site SHALL be associated with a workspace via `workspace_id` referencing `core.workspaces`
|
||||
|
||||
#### Scenario: Domain uniqueness
|
||||
|
||||
- **WHEN** a site is created with a domain that already exists
|
||||
- **THEN** the creation SHALL fail with a uniqueness constraint violation
|
||||
|
||||
#### Scenario: All queries use schema-qualified table name
|
||||
|
||||
- **WHEN** any FedWiki query (create, read, update, delete) executes
|
||||
- **THEN** the SQL SHALL reference `fedwiki.sites` (schema-qualified)
|
||||
- **AND** the query SHALL NOT use unqualified `sites`
|
||||
|
||||
### Requirement: Site creation gated by entitlement check
|
||||
|
||||
The system SHALL check the workspace's numeric entitlement for `resource_key = 'sites'` before creating a site. Site creation SHALL only proceed if the atomic usage increment succeeds (current_usage < resource_limit). If the limit is reached, site creation SHALL be denied.
|
||||
|
||||
@ -80,7 +80,7 @@ Adding a new migration file to any module SHALL NOT change the version numbers o
|
||||
|
||||
### Requirement: Per-module sqlc configuration
|
||||
|
||||
Each module SHALL have its own `sqlc.yaml` configuration file that reads migrations from its own directory (and upstream module directories for FK resolution) and generates Go code into its own package.
|
||||
Each module SHALL have its own `sqlc.yaml` configuration file that reads migrations from its own directory (and upstream module directories for FK resolution and schema awareness) and generates Go code into its own package. Modules whose tables reside in a non-default schema SHALL include the `db` module's migrations in their `schema` list so sqlc can resolve the schema definition.
|
||||
|
||||
#### Scenario: Identity module sqlc config
|
||||
|
||||
@ -94,9 +94,15 @@ Each module SHALL have its own `sqlc.yaml` configuration file that reads migrati
|
||||
- **THEN** it SHALL read both identity and organization migrations for schema inference
|
||||
- **AND** it SHALL generate Go types and query methods into the `organization` package
|
||||
|
||||
#### Scenario: FedWiki sqlc config with schema awareness
|
||||
|
||||
- **WHEN** `sqlc generate` is run from the FedWiki module directory
|
||||
- **THEN** it SHALL read both `../db/migrations/` and `migrations/` for schema inference
|
||||
- **AND** it SHALL generate Go types with the original type names (e.g., `Site` not `FedwikiSite`) via rename overrides
|
||||
|
||||
### Requirement: Old migration coexistence
|
||||
|
||||
The existing `internal/db/migrations/00001_init.sql` SHALL remain in place for the `sites` and `payments` tables. The identity module's init migration SHALL drop the old `users` table (CASCADE) to avoid conflicts with the new `users` table. The `sites` and `payments` tables SHALL continue to function with unconstrained `user_id` columns until Milestone 2 replaces them.
|
||||
The existing `internal/db/migrations/00001_init.sql` SHALL remain in place for legacy table creation and cleanup. A new `internal/db/migrations/00003_schema_separation.sql` SHALL create the `core` and `fedwiki` schemas and move any existing domain tables from `public` to their correct schemas using `ALTER TABLE IF EXISTS`. The `update_updated_at_column()` function SHALL remain in the `public` schema as shared infrastructure.
|
||||
|
||||
#### Scenario: Old users table dropped
|
||||
|
||||
@ -104,7 +110,16 @@ The existing `internal/db/migrations/00001_init.sql` SHALL remain in place for t
|
||||
- **THEN** it SHALL execute `DROP TABLE IF EXISTS users CASCADE` before creating the new `users` table
|
||||
- **AND** the `sites` and `payments` tables SHALL retain their `user_id` columns but without FK constraints
|
||||
|
||||
#### Scenario: Sites queries still work
|
||||
#### Scenario: Schema separation migration on existing database
|
||||
|
||||
- **WHEN** the system queries sites by `user_id` after the identity migration
|
||||
- **THEN** the queries SHALL execute successfully (the column exists, just unconstrained)
|
||||
- **WHEN** migration `00003_schema_separation.sql` runs against a database with tables in `public`
|
||||
- **THEN** schemas `core` and `fedwiki` SHALL be created
|
||||
- **AND** all domain tables SHALL be moved to `core`
|
||||
- **AND** the `sites` table SHALL be moved to `fedwiki`
|
||||
|
||||
#### Scenario: Schema separation migration on fresh database
|
||||
|
||||
- **WHEN** migration `00003_schema_separation.sql` runs against an empty database
|
||||
- **THEN** schemas `core` and `fedwiki` SHALL be created
|
||||
- **AND** the `ALTER TABLE IF EXISTS` statements SHALL be no-ops (no tables to move)
|
||||
- **AND** subsequent module migrations SHALL create tables in the correct schema via `search_path`
|
||||
|
||||
@ -7,16 +7,26 @@ Defines PostgreSQL as the application database: connection via pgx, migrations v
|
||||
## Requirements
|
||||
|
||||
### Requirement: PostgreSQL connection via pgx driver
|
||||
The system SHALL connect to PostgreSQL using the `pgx/v5` driver with stdlib compatibility mode (`pgx/v5/stdlib`).
|
||||
|
||||
The system SHALL connect to PostgreSQL using the `pgx/v5` driver with stdlib compatibility mode (`pgx/v5/stdlib`). After establishing the connection, the system SHALL ensure `search_path = core, public` so domain tables resolve without schema qualification.
|
||||
|
||||
#### Scenario: Successful connection
|
||||
|
||||
- **WHEN** the application starts with a valid PostgreSQL DSN
|
||||
- **THEN** a database connection pool is established and ready for queries
|
||||
- **AND** the connection's `search_path` SHALL be set to `core, public`
|
||||
|
||||
#### Scenario: Connection failure
|
||||
|
||||
- **WHEN** the application starts with an invalid or unreachable PostgreSQL DSN
|
||||
- **THEN** the application fails to start with a descriptive error message
|
||||
|
||||
#### Scenario: search_path failure
|
||||
|
||||
- **WHEN** the connection succeeds but `SET search_path` fails
|
||||
- **THEN** the connection SHALL be closed
|
||||
- **AND** the application SHALL fail to start with a descriptive error message
|
||||
|
||||
### Requirement: PostgreSQL connection string configuration
|
||||
The system SHALL accept a PostgreSQL connection string via the `--db-dsn` flag in the format `postgres://user:password@host:port/dbname?sslmode=disable`.
|
||||
|
||||
|
||||
53
openspec/specs/schema-namespacing/spec.md
Normal file
53
openspec/specs/schema-namespacing/spec.md
Normal file
@ -0,0 +1,53 @@
|
||||
# schema-namespacing
|
||||
|
||||
## Purpose
|
||||
|
||||
Defines PostgreSQL schema separation per design Decisions 85/86: domain tables in `core` schema, integration modules in schema-per-provider, shared infrastructure in `public`.
|
||||
|
||||
## Requirements
|
||||
|
||||
### Requirement: Domain tables live in core schema
|
||||
|
||||
All domain tables (identity, organization, billing, entitlements) SHALL reside in the `core` PostgreSQL schema. The `public` schema SHALL hold only shared extensions and functions (e.g., `update_updated_at_column()`).
|
||||
|
||||
#### Scenario: Fresh install creates tables in core schema
|
||||
|
||||
- **WHEN** migrations run against an empty database
|
||||
- **THEN** all domain tables SHALL exist in the `core` schema
|
||||
- **AND** the `public` schema SHALL contain only the `update_updated_at_column()` function and the `goose_db_version` table
|
||||
|
||||
#### Scenario: Existing install moves tables to core schema
|
||||
|
||||
- **WHEN** migration `00003_schema_separation.sql` runs against a database with tables in `public`
|
||||
- **THEN** all domain tables SHALL be moved from `public` to `core`
|
||||
- **AND** all FK relationships, indexes, and triggers SHALL remain intact
|
||||
|
||||
### Requirement: Integration modules get schema-per-provider
|
||||
|
||||
Each external integration SHALL have its own PostgreSQL schema (e.g., `fedwiki`). Integration schemas SHALL reference `core` tables via FKs but SHALL NOT reference each other. An integration can be cleanly removed via `DROP SCHEMA CASCADE`.
|
||||
|
||||
#### Scenario: FedWiki integration schema
|
||||
|
||||
- **WHEN** the FedWiki module's migration runs
|
||||
- **THEN** the `sites` table SHALL exist in the `fedwiki` schema
|
||||
- **AND** `fedwiki.sites.workspace_id` SHALL reference `core.workspaces(workspace_id)`
|
||||
|
||||
#### Scenario: Integration decommissioning
|
||||
|
||||
- **WHEN** an administrator runs `DROP SCHEMA fedwiki CASCADE`
|
||||
- **THEN** all FedWiki tables, indexes, and triggers SHALL be removed
|
||||
- **AND** no core schema tables SHALL be affected
|
||||
|
||||
### Requirement: search_path resolves core tables without qualification
|
||||
|
||||
The database connection SHALL set `search_path = core, public` so that domain module queries can use unqualified table names. Integration module queries SHALL use schema-qualified names (e.g., `fedwiki.sites`).
|
||||
|
||||
#### Scenario: Unqualified query resolves to core
|
||||
|
||||
- **WHEN** a query references `users` without schema qualification
|
||||
- **THEN** PostgreSQL SHALL resolve it to `core.users`
|
||||
|
||||
#### Scenario: Integration queries use explicit schema
|
||||
|
||||
- **WHEN** a FedWiki query references `fedwiki.sites`
|
||||
- **THEN** PostgreSQL SHALL resolve it to the `fedwiki` schema regardless of `search_path`
|
||||
Reference in New Issue
Block a user