Lo que bloqueaba la fase 4 era el cliente, no las rutas: el catálogo
viajaba en el paquete de JavaScript y las tarjetas, el carrito, la cuenta
y el reproductor lo consultaban por su cuenta. Baja por `page.data` desde
el layout —veintiún kilobytes, medidos, menos de lo que ocupaba en el
paquete— y `$lib/catalogo/cliente.svelte` lo ata a las mismas consultas
que usa el servidor. La otra opción era pasar por props lo que cada
componente busca solo, y eso obligaba a tocar la compra, el carrito y el
reproductor a la vez.
Siete rutas dejan de ser cargadores universales: música, estilo, álbum,
canción, portada y las dos de etiquetas. La ficha del tema se resuelve
entera en el servidor, incluida la letra, que se pide sola y no viaja con
el catálogo.
Faltaba dónde guardar dos textos: la introducción de cada estilo y las
notas de cada disco eran el cuerpo de su Markdown y el volcado se quedó
solo con el frontmatter. Migración 0010 y `npm run db:completar` los
rellena.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Antes de cambiar veinte rutas de sitio, poder demostrar que sale lo
mismo. `npm run db:comparar` pone lado a lado las dieciocho canciones,
los cuatro discos y los cuatro estilos, campo a campo, y dice dónde
difieren con la ruta exacta —`dorina.registro.reparto[0].porcentaje`—.
Confiar en que las pruebas de interfaz noten una diferencia de un año o
de un porcentaje sería confiar de más: pintan lo que les den.
La primera pasada encontró ciento y pico diferencias, y no eran del
comparador:
- **Ningún medio estaba enlazado.** El volcado del catálogo y el de
archivos se hicieron por separado, así que las veintitrés canciones
tenían `audio_id`, `muestra_id` y `portada_id` a nulo. La web habría
dejado de reproducir en cuanto leyera de la base. No se vio al volcar
porque aquello comprobaba las letras carácter a carácter —lo
delicado— y no que las relaciones estuvieran puestas.
- **Las etiquetas volvían en orden alfabético.** No es cosmético: la
primera etiqueta de un tema dice de qué va y las de después matizan.
Ahora `cancion_etiqueta` tiene `orden`.
- **La nota de cada versión no tenía columna** donde caer.
- El país de edición se guardaba en ninguna parte, y los cuatro discos
se quedaron sin él.
`scripts/db/completar-catalogo.mjs` ata esos cabos y es idempotente. Los
alias de `medio` son de verdad y no la misma tabla dos veces: sin ellos
Postgres devuelve la misma fila para el audio y para la portada.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fase 3 del modelo, que iba primero por ser lo único que podía costarle
dinero a alguien: siete tablas guardaban `cancion_slug` sin clave
foránea, porque cuando se escribieron el catálogo vivía en archivos y no
había a qué apuntar. Renombrar un slug dejaba huérfana una compra en
silencio, y quien la había pagado se enteraba al intentar descargar.
Ahora apuntan a `cancion.id`, que es opaco y no cambia. El borrado no se
propaga igual en todas: `compra`, `pedido_item` y `descarga` lo
RESTRINGEN —una canción pagada no se puede borrar, y el registro de
entrega tiene que sobrevivir a lo que prueba—; `carrito_item`,
`favorito`, `valoracion` y `reproduccion` caen con ella.
En dos migraciones y no en una: entre la 0007 y la 0008 conviven las dos
columnas, así que el despliegue no tiene que parar el sitio. La 0007 se
niega a seguir si alguna compra apunta a un slug que ya no existe:
borrarla en silencio sería repetir el fallo que esto viene a arreglar.
Por fuera nada cambia de forma. Los módulos siguen recibiendo slugs —es
lo que hay en las URL— y traducen en el borde, en `$lib/server/canciones`.
Al hacerlo, tres funciones pasan a devolver `false` cuando el slug no
existe, que antes se guardaba tal cual: se podía meter en el carrito un
tema inventado.
El script de guardas estaba roto desde que se volcó el catálogo —usaba
slugs de verdad y moría en la primera inserción por clave duplicada, sin
comprobar nada—. Ahora usa el prefijo `zz-` y el país ZZ, que ISO 3166
reserva para uso privado. Y la prueba de la valoración pasaba por el
motivo equivocado: rechazaba el seis porque la columna ya no existía, no
por el rango.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
La ficha decía que el bolero va en octosílabo y endecasílabo alternados
sin enseñar nunca uno. Un taller que describe la métrica y no la muestra
está pidiendo que se la imaginen.
`verso` es la estrofa y no el verso suelto —la rima y la alternancia de
medidas no se ven en menos de dos versos— y `verso_linea` lleva cada uno
con su escansión: `silabas` es la métrica y no la del diccionario, así
que la sinalefa mete dos palabras en una casilla —«za⁀a»—, que es justo
lo que en un texto corrido no se ve. `medida` va aparte porque un verso
agudo suma una sílaba y uno esdrújulo resta una: «yo respondo con el
son» tiene siete casillas y es un octosílabo.
Los diez ejemplos son originales, escritos para enseñar, y entran
marcados con `fuente = 'ejemplo'`. Ninguno es cita: para eso está
`obra_referencia.cita`, y dos CHECK lo sostienen —un verso «cita» sin
obra, o uno «propio» sin canción, no entran—.
La escansión se comprueba antes de cargarla, contra la ley del acento
final, y el sembrador aborta si algo no cuadra. Un ejemplo mal contado
enseña a contar mal y eso no lo detecta ningún tipo ni ningún lint: por
eso la comprobación está en su propio módulo, con nueve pruebas.
`obra_referencia_enlace` sustituye a la columna `enlace`, que era texto
suelto y solo daba sitio para uno; una obra está en Spotify **y** en
YouTube. Comparte el vocabulario `PLATAFORMAS` con los enlaces de las
canciones. De momento son búsquedas y no grabaciones concretas: una URL
inventada manda a un 404 con pinta de dato bueno, y los enlaces exactos
llegarán en el JSON de la ficha.
De paso, dos cosas que se vieron al montarlo:
- El orden de la ficha cambia. Primero lo que se usa para escribir
—ejemplos, rasgos, temática— y después lo que sitúa el género. Es un
taller: quien abre la página viene a escribir una letra.
- `repeat(auto-fit, minmax(24rem, 1fr))` desbordaba ochenta píxeles en
un móvil de 320, porque la rejilla pone la columna aunque no quepa.
Ahora es `min(24rem, 100%)`, en los tres sitios donde estaba.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Su troceador de SQL no entiende los cuerpos `$$` de plpgsql, y este
esquema tiene dos funciones —la que comprueba que el reparto de autoría
suma cien y la que impide ciclos en el linaje de géneros—. A partir de
esa migración deja de aplicar nada y termina con éxito.
Se descubrió porque `cancion.nota` seguía existiendo dos migraciones
después de borrarla. Es decir: durante un rato la base y el esquema
dijeron cosas distintas y nada avisó.
`scripts/db/migrar.mjs` trocea por el mismo separador que escribe
Drizzle, aplica en orden, anota en su misma tabla y con su mismo hash, y
cuando algo falla dice en qué sentencia y deshace.
De paso:
- 0004 rehace `cancion_principal` alrededor del `DROP COLUMN`. Un
`CREATE VIEW ... SELECT *` expande las columnas al crearse, así que la
vista impedía borrar la columna.
- 0005 le da procedencia a `persona`. Las figuras de un género las
escribe un modelo igual que las fichas, con fechas que hay que
verificar; sin esto no se distingue a quien firma una canción del
catálogo de una semblanza importada sin revisar.
- `marked`, para componer en el servidor el Markdown que ahora vive en
una columna.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Los 48 archivos entran en la base: 18 canciones con sus 5 versiones como
filas hermanas, 4 discos, 4 estilos, 8 géneros, 14 capítulos, 9 personas,
53 etiquetas, 2 entradas, 5 legales y una página. La web sigue leyendo de
los archivos; esto solo llena el sitio donde caben.
Reutiliza los analizadores del sitio en vez de leer el frontmatter otra
vez. Es la decisión que sostiene todo lo demás: si un valor por defecto o
una normalización cambian, cambian para los dos. Un segundo analizador
«parecido» es lo que garantiza que un día la base y la web cuenten cosas
distintas sin que nadie lo note.
Todo en una transacción, y al final comprueba lo escrito contra lo leído
—la letra de cada tema carácter a carácter— porque «no dio error» no es lo
mismo que «está bien». Si no cuadra, deshace y dice qué falló. Y es
idempotente: se puede lanzar las veces que haga falta sin duplicar nada,
sin tocar cuentas, pedidos ni compras.
Para poder reutilizar ese código desde Node hizo falta un gancho de
resolución —`$lib`, imports sin extensión y un sustituto de `$app/paths`—
y quitar una propiedad de parámetro de `ErrorDeContenido`: es sintaxis
exclusiva de TypeScript y ningún cargador que solo borre tipos puede con
ella.
Y una corrección: el cuerpo del Markdown de una canción NO era un
comentario, es la letra. Lo dije al revés en el documento del modelo y lo
repetí varias veces. El campo `letra:` del frontmatter —que aparece en un
solo archivo— son los créditos de quién la firma. Corregido en el
documento, en el esquema, y quitada la columna `nota` que sobraba.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
52 tablas nuevas, una vista y las guardas que Drizzle no sabe expresar. El
sitio sigue leyendo de los archivos: esto solo crea el sitio donde caben.
El esquema pasa de archivo a carpeta —con el catálogo dentro pasaría de
las dos mil líneas—, y el corte se hizo por rangos de línea para no
reescribir los comentarios que explican decisiones. Se comprobó que era un
no-op: «No schema changes, nothing to migrate».
Lo que Drizzle no genera va escrito a mano al final de la migración, y es
justo lo que impide que el modelo mienta: la clave foránea de las
versiones contra su propia tabla, dos columnas generadas y una clave
compuesta que impiden a la vez la versión de una versión y los ciclos, la
vista `cancion_principal`, el disparador que exige que el reparto de
autoría sume cien, el que impide ciclos en el linaje de géneros, y los
índices parciales del estilo principal y de la arista principal.
Y las guardas se comprueban intentando lo que deben rechazar, en
`scripts/db/comprobar-guardas.mjs`. Que una restricción exista en
`pg_constraint` no significa que impida nada.
Tres cosas que salieron de hacerlo y no de suponerlo:
- `creado_en` era obligatorio SIN valor por defecto en la base: lo ponía
JavaScript al insertar con Drizzle, así que cualquier INSERT escrito a
mano fallaba y la invariante la sostenía la aplicación, no la tabla.
Ahora es `defaultNow()`.
- `drizzle-kit migrate` se atasca con los cuerpos `$$` de plpgsql. La
migración se aplicó con `scripts/db/probar-migracion.mjs`, que además
dice en qué sentencia falla, y se anotó en su registro.
- Dos pruebas pasaban por el motivo equivocado: una por clave duplicada en
vez de por la suma, y otra porque un disparador aplazado no salta dentro
de un savepoint que nunca se cierra. Las dos corregidas; una guarda dada
por buena sin ejecutarse es peor que no tenerla.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Detrás de la cuenta hay compras y facturación, y un código por correo apoya
toda esa puerta en el buzón: quien entre ahí, entra aquí. Delegar la
identidad en un proveedor que ya tiene doble factor la sostiene mejor.
Es el flujo de código de autorización con PKCE, escrito a mano porque son
cien líneas y las partes que importan son tres, mejor a la vista que
detrás de una dependencia: comparar el `state`, mandar el `code_verifier`
y exigir que el correo venga verificado antes de enlazar nada.
Lo que NO hace, a propósito: verificar la firma del `id_token`. La
identidad no se saca de lo que trae el navegador, sino de una llamada
nuestra al proveedor por TLS, así que no hay firma ajena que comprobar.
Y no guarda ningún token: se usa una vez para preguntar quién es y se
tira.
El enlazado por correo verificado es lo delicado, y por eso está separado
en `usuarioParaPerfil` con su explicación: sin exigir la verificación,
cualquiera podría poner la dirección de otro en un perfil suyo y quedarse
con su cuenta y sus compras.
El código por correo se queda debajo, como alternativa. Sin credenciales
puestas, los botones no se pintan y la ruta contesta 503: un botón que
solo lleva a un error es peor que no tenerlo.
Y los textos legales al día, que ahora sí hay terceros: qué se manda a
Google y a Facebook, cuándo, qué se guarda y cómo revocarlo.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
El usuario ha puesto un servidor con PostgreSQL 16, así que la base pasa allí.
El catálogo no se toca: sigue siendo Markdown del repositorio; lo que se muda es
lo que generan las personas, que es lo único que estaba en la base.
Dos diferencias entre motores que habrían pasado desapercibidas y se cazaron
comprobando con datos de verdad, no compilando:
- `integer` en Postgres son cuatro bytes y en SQLite ocho. El limitador de
envíos guarda su vencimiento en milisegundos desde 1970 —trece dígitos, del
orden del billón— y en `integer` desbordaría, dejando de limitar. Va en
`bigint`.
- `avg()` y `count()` los entrega el driver como cadena para no perder
precisión. Sin convertirlas, `cuantas === 0` nunca se cumpliría y «1 voto»
saldría siempre en plural. Se convierten en la consulta.
Verificado contra el servidor: once tablas creadas, las 113 pruebas de extremo a
extremo y las 224 unitarias en verde, el servidor de producción arrancando, el
limitador escribiendo sus trece dígitos y el borrado en cascada llevándose
valoraciones y favoritos al borrar una cuenta.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sitio del letrista y compositor, con SvelteKit 2 y Svelte 5.
El catálogo es contenido, no base de datos: estilos, álbumes, canciones y
capítulos son Markdown en `src/content/`, validados al compilar y compilados
dentro del bundle, así que recorrer la música no hace ni una consulta. La base
de datos guarda solo lo que generan las personas: cuentas, carritos, pedidos,
compras, valoraciones y favoritos.
Lo que hay:
- Música agrupada por estilos, con página propia para cada estilo, álbum y
tema, buscador y tabla ordenable.
- Reproductor persistente que sobrevive a la navegación. Suena un fragmento;
el archivo completo solo se obtiene comprando.
- Taller de letras: los principios del oficio y una ficha de creación por
estilo hispanoamericano.
- Blog con feed RSS, y etiquetas que cruzan blog y catálogo.
- Tienda con cuentas por código de correo, carrito, Stripe y descargas
protegidas.
- Panel de contenido en `/admin` que escribe los mismos Markdown.
- Tema oscuro de marca y su inversión clara, aplicado antes de pintar.
Lo que falta y no es código: las claves de Stripe y de correo, el tratamiento
del IVA, las páginas de condiciones y privacidad, y el material real. Las
canciones de ejemplo son inventadas y están para sustituirse; el audio son
marcadores salvo un tema.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>