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.
418 lines
16 KiB
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();
|
|
}
|