You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
senzapaura_es/scripts/db/documentar-modelo.mjs

418 lines
16 KiB

/** Genera el diccionario del modelo desde el esquema PostgreSQL realmente aplicado. */
import fs from 'node:fs/promises';
import pg from 'pg';
import { format } from 'prettier';
if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
const salida = process.argv[2] ?? 'docs/modelo-datos.md';
const cliente = new pg.Client({
connectionString: process.env.DATABASE_URL,
query_timeout: 120_000
});
const dominios = [
[
'Base y artistas',
['pais', 'medio', 'persona', 'artista', 'artista_persona', 'artista_identificador']
],
[
'Catálogo musical',
[
'estilo',
'album',
'cancion',
'cancion_interprete',
'cancion_audio',
'traduccion',
'cancion_persona',
'registro_obra',
'autoria',
'video',
'cancion_enlace',
'album_enlace',
'credito_album',
'etiqueta',
'cancion_etiqueta'
]
],
[
'Cuentas y suscripciones',
['usuario', 'plan_suscripcion', 'suscripcion', 'identidad', 'codigo_acceso', 'sesion', 'limite']
],
[
'Interacción y escucha',
[
'favorito',
'lista_reproduccion',
'lista_reproduccion_cancion',
'acceso_cancion_usuario',
'valoracion',
'reproduccion'
]
],
[
'Comercio',
[
'carrito',
'carrito_item',
'producto',
'carrito_producto',
'pedido',
'pedido_item',
'pedido_linea',
'compra',
'intencion_pago'
]
],
['Facturación', ['datos_facturacion', 'contador_factura', 'factura', 'descarga']],
[
'Editorial y legal',
[
'entrada',
'entrada_cancion',
'entrada_etiqueta',
'pagina',
'documento_legal',
'version_legal',
'redireccion'
]
],
[
'Taller de letras',
[
'genero',
'genero_origen',
'genero_pais',
'genero_seccion',
'genero_alias',
'genero_tema',
'cancion_genero',
'metro',
'genero_metro',
'compas',
'genero_compas',
'instrumento',
'genero_instrumento',
'genero_persona',
'obra_referencia',
'obra_referencia_persona',
'obra_referencia_enlace',
'verso',
'verso_linea',
'capitulo',
'termino',
'acepcion',
'termino_variante',
'capitulo_acepcion',
'modelo_ia',
'animo',
'prompt'
]
],
['Jukebox', ['programacion_jukebox', 'jukebox']]
];
const explicaciones = {
valoracion:
'Interacción unificada por cuenta y canción. `like` alimenta el contador público; `value` conserva la puntuación de 1 a 5 para un uso profesional posterior. Son independientes.',
cancion:
'Tema principal o versión musical. `version_de_id` apunta a otra canción y separa obra principal de arreglo o grabación.',
medio:
'Archivo administrado: imagen, audio, muestra o documento, con metadatos técnicos e integridad SHA-256.',
jukebox: 'Programación independiente y versionada de una cuenta.',
programacion_jukebox:
'Programación histórica por cuenta, conservada por compatibilidad con el modelo anterior de un único Jukebox.',
compra: 'Permiso materializado de descarga de una canción adquirida.',
factura: 'Documento fiscal inmutable; una rectificativa referencia otra factura.',
producto: 'Oferta comercial genérica; sus datos específicos se conservan en JSON.',
prompt:
'Prompt probado para un modelo de IA, género, ánimo y, opcionalmente, una canción resultante.'
};
const celda = (valor) =>
String(valor ?? '')
.replaceAll('|', '\\|')
.replaceAll('\n', ' ');
const codigo = (valor) => `\`${celda(valor).replaceAll('`', '\\`')}\``;
const nombreDominio = (tabla) =>
dominios.find(([, tablas]) => tablas.includes(tabla))?.[0] ?? 'Otras estructuras';
await cliente.connect();
await cliente.query('BEGIN READ ONLY');
try {
// Una transacción pertenece a una sola conexión: las consultas se ejecutan en
// orden para no solaparlas sobre el mismo cliente de PostgreSQL.
const { rows: objetos } =
await cliente.query(`select c.relname as nombre, c.relkind, obj_description(c.oid, 'pg_class') as comentario,
case when c.relkind in ('v','m') then pg_get_viewdef(c.oid,true) end as definicion
from pg_class c join pg_namespace n on n.oid=c.relnamespace
where n.nspname='public' and c.relkind in ('r','p','v','m') order by c.relkind, c.relname`);
const { rows: columnas } =
await cliente.query(`select c.relname as tabla, a.attnum as posicion, a.attname as nombre,
pg_catalog.format_type(a.atttypid,a.atttypmod) as tipo, not a.attnotnull as nullable,
pg_get_expr(ad.adbin,ad.adrelid) as defecto, col_description(c.oid,a.attnum) as comentario
from pg_attribute a join pg_class c on c.oid=a.attrelid join pg_namespace n on n.oid=c.relnamespace
left join pg_attrdef ad on ad.adrelid=a.attrelid and ad.adnum=a.attnum
where n.nspname='public' and c.relkind in ('r','p','v','m') and a.attnum>0 and not a.attisdropped
order by c.relname,a.attnum`);
const { rows: restricciones } =
await cliente.query(`select c.relname as tabla, con.conname as nombre, con.contype as tipo,
pg_get_constraintdef(con.oid,true) as definicion
from pg_constraint con join pg_class c on c.oid=con.conrelid join pg_namespace n on n.oid=c.relnamespace
where n.nspname='public' order by c.relname, con.contype, con.conname`);
const { rows: indices } =
await cliente.query(`select tablename as tabla,indexname as nombre,indexdef as definicion
from pg_indexes where schemaname='public' order by tablename,indexname`);
const { rows: disparadores } =
await cliente.query(`select c.relname as tabla,t.tgname as nombre,pg_get_triggerdef(t.oid,true) as definicion
from pg_trigger t join pg_class c on c.oid=t.tgrelid join pg_namespace n on n.oid=c.relnamespace
where n.nspname='public' and not t.tgisinternal order by c.relname,t.tgname`);
const { rows: secuencias } =
await cliente.query(`select sequencename as nombre,data_type,start_value,increment_by,min_value,max_value,cycle
from pg_sequences where schemaname='public' order by sequencename`);
const { rows: enumeraciones } =
await cliente.query(`select t.typname as tipo,e.enumlabel as valor,e.enumsortorder as orden
from pg_type t join pg_enum e on e.enumtypid=t.oid join pg_namespace n on n.oid=t.typnamespace
where n.nspname='public' order by t.typname,e.enumsortorder`);
const { rows: funciones } = await cliente.query(`select p.proname as nombre,
pg_get_function_identity_arguments(p.oid) as argumentos,
pg_get_function_result(p.oid) as resultado,l.lanname as lenguaje
from pg_proc p join pg_namespace n on n.oid=p.pronamespace join pg_language l on l.oid=p.prolang
where n.nspname='public' and p.prokind='f' order by p.proname,argumentos`);
const { rows: migraciones } = await cliente.query(
`select count(*)::int as total,max(created_at)::text as ultima from drizzle.__drizzle_migrations`
);
const tablas = objetos.filter((o) => ['r', 'p'].includes(o.relkind));
const vistas = objetos.filter((o) => ['v', 'm'].includes(o.relkind));
const lineas = [];
lineas.push('# Modelo de datos completo de Senza Paura', '');
lineas.push(
`Generado el ${new Date().toISOString()} desde el esquema PostgreSQL aplicado y contrastado con los contratos TypeScript de la aplicación.`,
''
);
lineas.push(
`**Alcance:** ${tablas.length} tablas, ${vistas.length} ${vistas.length === 1 ? 'vista' : 'vistas'}, ${secuencias.length} secuencias, ${funciones.length} funciones de base y ${migraciones[0]?.total ?? 0} migraciones aplicadas.`,
''
);
lineas.push(
'## Fuentes y convenciones',
'',
'- Esquema declarativo: `src/lib/server/db/schema/`.',
'- Historial físico: `drizzle/`.',
'- Campos TypeScript en `camelCase`; nombres PostgreSQL en `snake_case`.',
'- `text` es el identificador opaco habitual. `pais.codigo` es la excepción: usa ISO 3166-1 alfa-2.',
'- Los importes monetarios son enteros en céntimos y la moneda usa código ISO en minúsculas.',
'- Las marcas de tiempo son `timestamp with time zone`.',
'- Los estados editoriales comunes son `borrador`, `publicado` y `retirado`.',
''
);
lineas.push(
'## Vista conceptual',
'',
'```mermaid',
'flowchart LR',
' BASE[Base y artistas] --> CAT[Catálogo musical]',
' CAT --> INT[Interacción y escucha]',
' CTA[Cuentas y suscripciones] --> INT',
' CTA --> COM[Comercio]',
' CAT --> COM',
' COM --> FAC[Facturación]',
' CAT --> EDI[Editorial y legal]',
' BASE --> TALLER[Taller de letras]',
' CAT --> TALLER',
' CTA --> JUKE[Jukebox]',
' CAT --> JUKE',
'```',
''
);
lineas.push(
'La entidad central es `cancion`. Se relaciona con álbumes, estilos, intérpretes, audios, autoría, etiquetas, ventas, descargas, listas, reproducciones, likes/valoraciones, entradas editoriales, géneros del taller y prompts.',
''
);
lineas.push(
'## Reglas que definen el modelo',
'',
'- Una versión musical es otra fila de `cancion`; `version_de_id` apunta a la canción principal. Una versión no puede venderse por separado.',
'- `valoracion` tiene una sola fila por `(usuario_id, cancion_id)`: `like` y `value` no se pisan. Quitar un like conserva `value` y `date`.',
'- `artista` es la identidad pública; `persona` es la persona física o acreditada. `artista_persona` permite solistas, grupos y proyectos.',
'- `compra` materializa el permiso de descarga. El pedido conserva el hecho comercial; la factura conserva el hecho fiscal.',
'- Una factura emitida no se reescribe: las correcciones se modelan como facturas rectificativas.',
'- La programación del Jukebox vive como franjas JSON versionadas mediante `revision` para control de concurrencia.',
'- El taller usa vocabularios normalizados y tablas puente para géneros, países, metros, compases, instrumentos, figuras, obras, versos y glosario.',
''
);
lineas.push(
'## Relaciones principales',
'',
'```mermaid',
'erDiagram',
' USUARIO ||--o{ VALORACION : emite',
' CANCION ||--o{ VALORACION : recibe',
' USUARIO ||--o{ FAVORITO : guarda',
' USUARIO ||--o{ LISTA_REPRODUCCION : crea',
' LISTA_REPRODUCCION ||--o{ LISTA_REPRODUCCION_CANCION : contiene',
' CANCION ||--o{ LISTA_REPRODUCCION_CANCION : aparece',
' ESTILO ||--o{ ALBUM : agrupa',
' ESTILO ||--o{ CANCION : clasifica',
' ALBUM ||--o{ CANCION : publica',
' CANCION ||--o{ CANCION_AUDIO : dispone',
' MEDIO ||--o{ CANCION_AUDIO : almacena',
' ARTISTA ||--o{ CANCION_INTERPRETE : interpreta',
' CANCION ||--o{ CANCION_INTERPRETE : acredita',
' USUARIO ||--o{ PEDIDO : realiza',
' PEDIDO ||--o{ PEDIDO_LINEA : contiene',
' PEDIDO ||--o{ FACTURA : documenta',
' USUARIO ||--o{ JUKEBOX : configura',
'```',
''
);
lineas.push(
'## Contratos JSON de la aplicación',
'',
'### Contenido editorial por bloques',
'',
'`artista.secciones`, `album.secciones`, `cancion.inspiracion_secciones` y `entrada.secciones` contienen una lista ordenada de bloques:',
'',
'- Base: `{ id, tipo, titulo? }`.',
'- Texto: `{ tipo: "texto", texto }`.',
'- Código: `{ tipo: "codigo", codigo, lenguaje? }`.',
'- Medio: `{ tipo: "video" | "imagen" | "audio", url, pie?, subtitulos?, alt? }`.',
'',
'### Presentación editorial',
'',
'`artista.presentacion`, `album.presentacion`, `cancion.presentacion` y `entrada.presentacion` usan `{ plantilla, heroe }`, donde `heroe` es `automatico`, `visible` u `oculto`.',
'',
'### Jukebox',
'',
'`jukebox.franjas` y `programacion_jukebox.franjas` contienen objetos `{ id, inicio, fin, orden, catalogo, etiquetas?, contenidos }`. `orden` es `secuencial` o `aleatoria`; `contenidos` referencia listas o canciones mediante `{ tipo, id }`; varias etiquetas se combinan como unión.',
'',
'### Comercio abierto',
'',
'`producto.datos`, `pedido_linea.datos`, `intencion_pago.datos` y `factura.datos_congelados` conservan atributos variables o una instantánea del momento comercial. Los campos consultables y las cantidades monetarias permanecen normalizados en columnas.',
''
);
for (const [dominio] of dominios.concat([['Otras estructuras', []]])) {
const delDominio = tablas.filter((t) => nombreDominio(t.nombre) === dominio);
if (!delDominio.length) continue;
lineas.push(`## ${dominio}`, '');
for (const tabla of delDominio) {
lineas.push(`### ${codigo(tabla.nombre)}`, '');
if (explicaciones[tabla.nombre]) lineas.push(explicaciones[tabla.nombre], '');
lineas.push(
'| Columna | Tipo PostgreSQL | Nulo | Valor por defecto |',
'|---|---|:---:|---|'
);
for (const col of columnas.filter((c) => c.tabla === tabla.nombre)) {
lineas.push(
`| ${codigo(col.nombre)} | ${codigo(col.tipo)} | ${col.nullable ? 'sí' : 'no'} | ${col.defecto ? codigo(col.defecto) : '—'} |`
);
}
lineas.push('');
const cons = restricciones.filter((c) => c.tabla === tabla.nombre);
if (cons.length) {
lineas.push('Claves y restricciones:', '');
for (const con of cons) {
const clase =
{ p: 'PK', f: 'FK', u: 'UNIQUE', c: 'CHECK', x: 'EXCLUDE' }[con.tipo] ?? con.tipo;
lineas.push(`- **${clase} ${celda(con.nombre)}:** ${codigo(con.definicion)}`);
}
lineas.push('');
}
const idx = indices.filter(
(i) => i.tabla === tabla.nombre && !cons.some((c) => c.nombre === i.nombre)
);
if (idx.length)
lineas.push(`Índices adicionales: ${idx.map((i) => codigo(i.nombre)).join(', ')}.`, '');
const trg = disparadores.filter((t) => t.tabla === tabla.nombre);
if (trg.length) {
lineas.push('Disparadores:', '');
for (const t of trg) lineas.push(`- ${codigo(t.nombre)}: ${codigo(t.definicion)}`);
lineas.push('');
}
}
}
lineas.push('## Vistas', '');
if (!vistas.length) lineas.push('No hay vistas en el esquema público.', '');
for (const vista of vistas) {
lineas.push(
`### ${codigo(vista.nombre)}`,
'',
'| Columna | Tipo PostgreSQL | Nulo |',
'|---|---|:---:|'
);
for (const col of columnas.filter((c) => c.tabla === vista.nombre))
lineas.push(
`| ${codigo(col.nombre)} | ${codigo(col.tipo)} | ${col.nullable ? 'sí' : 'no'} |`
);
lineas.push('', 'Definición:', '', '```sql', vista.definicion, '```', '');
}
lineas.push('## Enumeraciones PostgreSQL', '');
if (!enumeraciones.length) lineas.push('No hay tipos enumerados en el esquema público.', '');
for (const tipo of [...new Set(enumeraciones.map((e) => e.tipo))]) {
lineas.push(
`- ${codigo(tipo)}: ${enumeraciones
.filter((e) => e.tipo === tipo)
.map((e) => codigo(e.valor))
.join(', ')}.`
);
}
lineas.push(
'',
'Los demás vocabularios cerrados se almacenan como `text` y aparecen en las restricciones `CHECK` de cada tabla.',
''
);
lineas.push(
'## Funciones de base de datos',
'',
'| Función | Argumentos | Resultado | Lenguaje |',
'|---|---|---|---|'
);
for (const f of funciones)
lineas.push(
`| ${codigo(f.nombre)} | ${f.argumentos ? codigo(f.argumentos) : '—'} | ${codigo(f.resultado)} | ${codigo(f.lenguaje)} |`
);
lineas.push(
'',
'Las funciones asociadas a disparadores se localizan además bajo la tabla afectada, en el apartado “Disparadores”.',
''
);
lineas.push(
'## Secuencias',
'',
'| Secuencia | Tipo | Inicio | Incremento | Ciclo |',
'|---|---|---:|---:|:---:|'
);
for (const s of secuencias)
lineas.push(
`| ${codigo(s.nombre)} | ${codigo(s.data_type)} | ${s.start_value} | ${s.increment_by} | ${s.cycle ? 'sí' : 'no'} |`
);
lineas.push(
'',
'## Mantenimiento',
'',
'Este documento se regenera desde la base aplicada:',
'',
'```powershell',
'node --env-file=.env scripts/db/documentar-modelo.mjs',
'```',
'',
'Después de una migración, regénéralo y revisa también los contratos JSON si cambió su estructura TypeScript.',
''
);
const markdown = await format(`${lineas.join('\n')}\n`, { parser: 'markdown' });
await fs.writeFile(salida, markdown, 'utf8');
console.log(
`${salida}: ${tablas.length} tablas, ${vistas.length} ${vistas.length === 1 ? 'vista' : 'vistas'}, ${columnas.length} columnas.`
);
} finally {
await cliente.query('ROLLBACK').catch(() => {});
await cliente.end();
}

Powered by TurnKey Linux.