/** 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(); }