Move settled investigation records into `status/explorations/<topic>-<date>/` with README indexes, promote durable conventions to `docs/`, and add `docs/identifiers.md` plus upstream Doc 46 person-name provenance documents. Update cross-references and archive resolved issues.
249 lines
11 KiB
SQL
249 lines
11 KiB
SQL
-- +goose Up
|
|
|
|
-- entity-keys (docs/identifiers.md §2 to §4, §7). Every entity has
|
|
-- an ID (UUIDv7, always database-generated; Decision 30 stands) and a name.
|
|
-- Tenants and catalog entities also carry a nullable `key`: a unique string
|
|
-- that lets something outside the database (a seed, a configuration file, a
|
|
-- test, a script, an API client) address the row without knowing its
|
|
-- generated ID. Vocabulary tables keep their primary keys as their keys.
|
|
--
|
|
-- Column contract, one rule for every table below:
|
|
-- * `key TEXT NULL`;
|
|
-- * grammar `^[a-z][a-z0-9_]*$`, at most 64 characters (the vocabulary
|
|
-- grammar already ratified for org types, resource keys, and roles);
|
|
-- * unique within the smallest namespace the entity inhabits: the table
|
|
-- for a root entity, `(parent_id, key)` for a child. NULLs are distinct,
|
|
-- so rows without a key never conflict;
|
|
-- * never derived from a person's data. The system writes a key only as a
|
|
-- fixed constant for a row it creates by design: the System tenant
|
|
-- (`system`) and each organization's default pool (`default`).
|
|
--
|
|
-- The three `slug` columns become `key` (the address role they were meant
|
|
-- for, without the defect: they were NOT NULL and filled from a login name or
|
|
-- a display name, which is why they read as asking for the name twice). The
|
|
-- plan ladder's `ladder_key` becomes `key` under the same contract.
|
|
-- Entitlement sets, products, and prices gain the column. The System tenant
|
|
-- singleton and the two name guards from the earlier cut of this migration
|
|
-- are unchanged.
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- (a) Organizations: `slug` → `key`, nullable, root scope.
|
|
-- ---------------------------------------------------------------------------
|
|
-- The singleton first, so a database holding two system organizations fails
|
|
-- on the index that names the rule rather than on the key assignment below.
|
|
CREATE UNIQUE INDEX uq_organizations_one_system
|
|
ON core.organizations (org_type) WHERE org_type = 'system';
|
|
|
|
ALTER TABLE core.organizations
|
|
DROP CONSTRAINT uq_organizations_slug;
|
|
DROP INDEX core.idx_organizations_slug;
|
|
ALTER TABLE core.organizations
|
|
RENAME COLUMN slug TO key;
|
|
ALTER TABLE core.organizations
|
|
ALTER COLUMN key TYPE TEXT,
|
|
ALTER COLUMN key DROP NOT NULL;
|
|
|
|
-- Every existing value was derived from the owner's login name or the
|
|
-- organization's name at creation; none was chosen, none is referenced. The
|
|
-- System tenant keeps the constant it is created with; everything else is
|
|
-- cleared (a personal organization gets no key by rule).
|
|
UPDATE core.organizations
|
|
SET key = CASE WHEN org_type = 'system' THEN 'system' END;
|
|
|
|
ALTER TABLE core.organizations
|
|
ADD CONSTRAINT uq_organizations_key UNIQUE (key),
|
|
ADD CONSTRAINT chk_organizations_key_grammar
|
|
CHECK (key IS NULL OR (key ~ '^[a-z][a-z0-9_]*$' AND length(key) <= 64));
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- (b) Workspaces: `slug` → `key`, nullable, scoped to the organization.
|
|
-- ---------------------------------------------------------------------------
|
|
ALTER TABLE core.workspaces
|
|
DROP CONSTRAINT uq_workspaces_org_id_slug;
|
|
ALTER TABLE core.workspaces
|
|
RENAME COLUMN slug TO key;
|
|
ALTER TABLE core.workspaces
|
|
ALTER COLUMN key TYPE TEXT,
|
|
ALTER COLUMN key DROP NOT NULL;
|
|
|
|
-- Every value was derived from the workspace's name; a singleton child (the
|
|
-- System tenant's workspace, an organization's first workspace) is addressed
|
|
-- through its parent and carries no key.
|
|
UPDATE core.workspaces SET key = NULL;
|
|
|
|
ALTER TABLE core.workspaces
|
|
ADD CONSTRAINT uq_workspaces_org_id_key UNIQUE (org_id, key),
|
|
ADD CONSTRAINT chk_workspaces_key_grammar
|
|
CHECK (key IS NULL OR (key ~ '^[a-z][a-z0-9_]*$' AND length(key) <= 64));
|
|
|
|
-- Workspace names disambiguate within their organization among live rows
|
|
-- (a soft-deleted workspace frees its name; upstream data model v16.1
|
|
-- documents the same predicate). Dedupe first: within each organization,
|
|
-- the second and later live workspaces sharing a name case-insensitively get
|
|
-- ' (2)', ' (3)', ... appended, oldest kept intact. created_at is the
|
|
-- authoritative order; workspace_id (uuidv7, time-ordered) breaks ties.
|
|
WITH ranked AS (
|
|
SELECT workspace_id,
|
|
ROW_NUMBER() OVER (
|
|
PARTITION BY org_id, lower(name)
|
|
ORDER BY created_at ASC, workspace_id ASC
|
|
) AS rn
|
|
FROM core.workspaces
|
|
WHERE status <> 'deleted'
|
|
)
|
|
UPDATE core.workspaces w
|
|
SET name = w.name || ' (' || ranked.rn || ')'
|
|
FROM ranked
|
|
WHERE w.workspace_id = ranked.workspace_id AND ranked.rn > 1;
|
|
|
|
CREATE UNIQUE INDEX uq_workspaces_org_id_name_ci
|
|
ON core.workspaces (org_id, lower(name)) WHERE status <> 'deleted';
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- (c) Resource pools: `slug` → `key`, nullable, scoped to the organization.
|
|
-- ---------------------------------------------------------------------------
|
|
ALTER TABLE core.resource_pools
|
|
DROP CONSTRAINT uq_resource_pools_org_id_slug;
|
|
ALTER TABLE core.resource_pools
|
|
RENAME COLUMN slug TO key;
|
|
ALTER TABLE core.resource_pools
|
|
ALTER COLUMN key TYPE TEXT,
|
|
ALTER COLUMN key DROP NOT NULL;
|
|
|
|
-- The default pool is the one row the system creates by design for every
|
|
-- organization; uq_resource_pools_one_default_per_org (00010) guarantees at
|
|
-- most one per organization, so the constant cannot collide.
|
|
UPDATE core.resource_pools
|
|
SET key = CASE WHEN pool_type = 'default' THEN 'default' END;
|
|
|
|
ALTER TABLE core.resource_pools
|
|
ADD CONSTRAINT uq_resource_pools_org_id_key UNIQUE (org_id, key),
|
|
ADD CONSTRAINT chk_resource_pools_key_grammar
|
|
CHECK (key IS NULL OR (key ~ '^[a-z][a-z0-9_]*$' AND length(key) <= 64));
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- (d) Plan ladders: `ladder_key` → `key`, nullable, root scope.
|
|
-- ---------------------------------------------------------------------------
|
|
ALTER TABLE core.plan_ladders
|
|
DROP CONSTRAINT uq_plan_ladders_ladder_key;
|
|
ALTER TABLE core.plan_ladders
|
|
RENAME COLUMN ladder_key TO key;
|
|
ALTER TABLE core.plan_ladders
|
|
ALTER COLUMN key TYPE TEXT,
|
|
ALTER COLUMN key DROP NOT NULL;
|
|
|
|
-- Existing ladder keys were shown to and confirmed by an operator at
|
|
-- creation, and the demo seed addresses its ladder by one, so they are kept
|
|
-- and brought under the grammar rather than cleared: lower-cased, separator
|
|
-- runs folded to '_', leading non-letters removed. A value the grammar still
|
|
-- rejects, or that collides after folding (the later row by created_at),
|
|
-- becomes NULL; nothing outside the database names those values.
|
|
UPDATE core.plan_ladders
|
|
SET key = regexp_replace(regexp_replace(lower(key), '[^a-z0-9_]+', '_', 'g'), '^[^a-z]+', '');
|
|
UPDATE core.plan_ladders
|
|
SET key = NULL
|
|
WHERE key !~ '^[a-z][a-z0-9_]*$' OR length(key) > 64;
|
|
WITH ranked AS (
|
|
SELECT plan_ladder_id,
|
|
ROW_NUMBER() OVER (PARTITION BY key ORDER BY created_at ASC, plan_ladder_id ASC) AS rn
|
|
FROM core.plan_ladders
|
|
WHERE key IS NOT NULL
|
|
)
|
|
UPDATE core.plan_ladders l
|
|
SET key = NULL
|
|
FROM ranked
|
|
WHERE l.plan_ladder_id = ranked.plan_ladder_id AND ranked.rn > 1;
|
|
|
|
ALTER TABLE core.plan_ladders
|
|
ADD CONSTRAINT uq_plan_ladders_key UNIQUE (key),
|
|
ADD CONSTRAINT chk_plan_ladders_key_grammar
|
|
CHECK (key IS NULL OR (key ~ '^[a-z][a-z0-9_]*$' AND length(key) <= 64));
|
|
|
|
-- Plan ladder names disambiguate globally: ladder names lead every catalog
|
|
-- and composite surface, so two ladders named the same are indistinguishable
|
|
-- to an operator. Same dedupe rule as (b).
|
|
WITH ranked AS (
|
|
SELECT plan_ladder_id,
|
|
ROW_NUMBER() OVER (
|
|
PARTITION BY lower(name)
|
|
ORDER BY created_at ASC, plan_ladder_id ASC
|
|
) AS rn
|
|
FROM core.plan_ladders
|
|
)
|
|
UPDATE core.plan_ladders l
|
|
SET name = l.name || ' (' || ranked.rn || ')'
|
|
FROM ranked
|
|
WHERE l.plan_ladder_id = ranked.plan_ladder_id AND ranked.rn > 1;
|
|
|
|
CREATE UNIQUE INDEX uq_plan_ladders_name_ci
|
|
ON core.plan_ladders (lower(name));
|
|
|
|
-- ---------------------------------------------------------------------------
|
|
-- (e) Entitlement sets and products gain `key` (root scope); prices gain
|
|
-- `key` scoped to their product. All NULL until a seed, a configuration
|
|
-- loader, an API client, or an operator sets one.
|
|
-- ---------------------------------------------------------------------------
|
|
ALTER TABLE core.entitlement_sets
|
|
ADD COLUMN key TEXT,
|
|
ADD CONSTRAINT uq_entitlement_sets_key UNIQUE (key),
|
|
ADD CONSTRAINT chk_entitlement_sets_key_grammar
|
|
CHECK (key IS NULL OR (key ~ '^[a-z][a-z0-9_]*$' AND length(key) <= 64));
|
|
|
|
ALTER TABLE core.products
|
|
ADD COLUMN key TEXT,
|
|
ADD CONSTRAINT uq_products_key UNIQUE (key),
|
|
ADD CONSTRAINT chk_products_key_grammar
|
|
CHECK (key IS NULL OR (key ~ '^[a-z][a-z0-9_]*$' AND length(key) <= 64));
|
|
|
|
ALTER TABLE core.prices
|
|
ADD COLUMN key TEXT,
|
|
ADD CONSTRAINT uq_prices_product_id_key UNIQUE (product_id, key),
|
|
ADD CONSTRAINT chk_prices_key_grammar
|
|
CHECK (key IS NULL OR (key ~ '^[a-z][a-z0-9_]*$' AND length(key) <= 64));
|
|
|
|
-- +goose Down
|
|
|
|
-- The rollback restores the shape, not the values: the renamed columns go
|
|
-- back to their old names, nullable, holding whatever keys were set (the
|
|
-- cleared and folded values are not recoverable), and the added columns are
|
|
-- dropped. The dedupe renames in (b) and (d) are data changes with no record
|
|
-- of the prior names; Down leaves them in place. This matches the
|
|
-- repository's snapshot-based rollback convention (see
|
|
-- 00010_schema_hardening.sql).
|
|
|
|
ALTER TABLE core.prices
|
|
DROP CONSTRAINT chk_prices_key_grammar,
|
|
DROP CONSTRAINT uq_prices_product_id_key,
|
|
DROP COLUMN key;
|
|
ALTER TABLE core.products
|
|
DROP CONSTRAINT chk_products_key_grammar,
|
|
DROP CONSTRAINT uq_products_key,
|
|
DROP COLUMN key;
|
|
ALTER TABLE core.entitlement_sets
|
|
DROP CONSTRAINT chk_entitlement_sets_key_grammar,
|
|
DROP CONSTRAINT uq_entitlement_sets_key,
|
|
DROP COLUMN key;
|
|
|
|
DROP INDEX core.uq_plan_ladders_name_ci;
|
|
ALTER TABLE core.plan_ladders
|
|
DROP CONSTRAINT chk_plan_ladders_key_grammar,
|
|
DROP CONSTRAINT uq_plan_ladders_key;
|
|
ALTER TABLE core.plan_ladders RENAME COLUMN key TO ladder_key;
|
|
|
|
ALTER TABLE core.resource_pools
|
|
DROP CONSTRAINT chk_resource_pools_key_grammar,
|
|
DROP CONSTRAINT uq_resource_pools_org_id_key;
|
|
ALTER TABLE core.resource_pools RENAME COLUMN key TO slug;
|
|
|
|
DROP INDEX core.uq_workspaces_org_id_name_ci;
|
|
ALTER TABLE core.workspaces
|
|
DROP CONSTRAINT chk_workspaces_key_grammar,
|
|
DROP CONSTRAINT uq_workspaces_org_id_key;
|
|
ALTER TABLE core.workspaces RENAME COLUMN key TO slug;
|
|
|
|
ALTER TABLE core.organizations
|
|
DROP CONSTRAINT chk_organizations_key_grammar,
|
|
DROP CONSTRAINT uq_organizations_key;
|
|
ALTER TABLE core.organizations RENAME COLUMN key TO slug;
|
|
DROP INDEX core.uq_organizations_one_system;
|