portal-db-migrations
Create reversible SQL migrations with dbmate for PostgreSQL 15 + pgvector. Use when adding tables (catalog_source, catalog_field, catalog_tribal_note, app_user, export_job), columns, indexes, extensio
SKILL.md.claude/skills/portal-db-migrations/SKILL.md
GitHubPortal DB Migrations Skill (dbmate)
Current migrations: ! ls db/migrations/ 2>/dev/null | head -10
Rules — NON-NEGOTIABLE
make db-new SLUG=x(dbmate new) para TODO cambio de esquema; archivodb/migrations/<timestamp>_<slug>.sql.- Siempre secciones
-- migrate:upy-- migrate:down(reversible);db/schema.sqlversionado en git tras cadamake db-up. - Jamás editar una migración ya aplicada: se corrige con una migración nueva.
- Jamás
SQLModel.metadata.create_all()en prod — dbmate es la única vía de cambio de esquema (los modelos SQLModel solo REFLEJAN, verportal-db-models). - Extensiones vía migración:
CREATE EXTENSION IF NOT EXISTS vector;(pgvector, fase RAG). - Timestamps
TIMESTAMPTZ DEFAULT now(); FKs conON DELETEexplícito; unicidad de negocio conUNIQUE(respaldan los 409 del API). - Seeds de demo (usuarios, catálogo) viven en migraciones o en
db/seeds/invocados pormake data; contraseñas seed SIEMPRE pre-hasheadas con Argon2 (nunca texto plano en SQL).
Migraciones canónicas del proyecto
| Orden | Slug | Contenido |
|---|---|---|
| 1 | create_catalog |
catalog_source, catalog_field, catalog_tribal_note |
| 2 | create_app_user |
app_user + seed 7 usuarios (hashes Argon2) |
| 3 | create_export_job |
export_job con auditoría |
| 4 | enable_pgvector |
CREATE EXTENSION vector + columna embedding en catalog_field + índice HNSW |
create_catalog
-- migrate:up
CREATE TABLE catalog_source (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE, -- 'creditos' | 'liquidez' | 'derivados'
description TEXT NOT NULL,
owner_area TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE TABLE catalog_field (
id BIGSERIAL PRIMARY KEY,
source_id BIGINT NOT NULL REFERENCES catalog_source(id) ON DELETE CASCADE,
physical_name TEXT NOT NULL, -- cryptic silo column, e.g. 'sdo_cap'
business_name TEXT NOT NULL, -- e.g. 'saldo de capital'
definition TEXT NOT NULL,
sensitivity TEXT NOT NULL CHECK (sensitivity IN ('publica', 'interna', 'restringida')),
metric_agg TEXT CHECK (metric_agg IN ('sum', 'mean', 'count', 'max')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (source_id, physical_name)
);
CREATE INDEX catalog_field_business_name_idx ON catalog_field
USING gin (to_tsvector('spanish', business_name || ' ' || definition));
CREATE TABLE catalog_tribal_note (
id BIGSERIAL PRIMARY KEY,
field_id BIGINT NOT NULL REFERENCES catalog_field(id) ON DELETE CASCADE,
note TEXT NOT NULL, -- e.g. 'fecha valor es T+1'
applicability TEXT NOT NULL, -- Tk-Boost applicability condition
author TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- migrate:down
DROP TABLE IF EXISTS catalog_tribal_note;
DROP TABLE IF EXISTS catalog_field;
DROP TABLE IF EXISTS catalog_source;
create_app_user (+ seed)
-- migrate:up
CREATE TABLE app_user (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username TEXT NOT NULL UNIQUE,
email TEXT NOT NULL UNIQUE,
full_name TEXT NOT NULL,
hashed_password TEXT NOT NULL, -- argon2id via pwdlib, never plaintext
role TEXT NOT NULL CHECK (role IN ('operativo', 'analista', 'directivo', 'admin')),
disabled BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Seed: 1 admin + 2 por perfil (7 total). Hashes generados con
-- python -c "from pwdlib import PasswordHash; print(PasswordHash.recommended().hash('...'))"
INSERT INTO app_user (username, email, full_name, hashed_password, role) VALUES
('admin', 'admin@portal.demo', 'Admin Portal', '$argon2id$...', 'admin'),
('operativo1', 'op1@portal.demo', 'Operativo Uno', '$argon2id$...', 'operativo'),
('operativo2', 'op2@portal.demo', 'Operativo Dos', '$argon2id$...', 'operativo'),
('analista1', 'an1@portal.demo', 'Analista Uno', '$argon2id$...', 'analista'),
('analista2', 'an2@portal.demo', 'Analista Dos', '$argon2id$...', 'analista'),
('directivo1', 'dir1@portal.demo', 'Directivo Uno', '$argon2id$...', 'directivo'),
('directivo2', 'dir2@portal.demo', 'Directivo Dos', '$argon2id$...', 'directivo');
-- migrate:down
DROP TABLE IF EXISTS app_user;
enable_pgvector (fase RAG)
-- migrate:up
CREATE EXTENSION IF NOT EXISTS vector;
ALTER TABLE catalog_field ADD COLUMN embedding VECTOR(768); -- Gemini embedding dim
CREATE INDEX catalog_field_embedding_idx ON catalog_field
USING hnsw (embedding vector_cosine_ops) WITH (m = 16, ef_construction = 64);
-- migrate:down
DROP INDEX IF EXISTS catalog_field_embedding_idx;
ALTER TABLE catalog_field DROP COLUMN IF EXISTS embedding;
DROP EXTENSION IF EXISTS vector;
Comandos
make db-new SLUG=create_catalog # dbmate new create_catalog
make db-up # dbmate up (aplica + regenera schema.sql)
make db-rollback # dbmate rollback (última migración)
dbmate status # migraciones pendientes/aplicadas