Fase 2: el esquema entero en PostgreSQL

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>
master
dev 4 weeks ago
parent 91ac23f31e
commit 1a8c904ae8

@ -3,7 +3,7 @@ import { defineConfig } from 'drizzle-kit';
if (!process.env.DATABASE_URL) throw new Error('DATABASE_URL is not set');
export default defineConfig({
schema: './src/lib/server/db/schema.ts',
schema: './src/lib/server/db/schema/index.ts',
dialect: 'postgresql',
dbCredentials: { url: process.env.DATABASE_URL },
verbose: true,

@ -0,0 +1,807 @@
CREATE TABLE "medio" (
"id" text PRIMARY KEY NOT NULL,
"clase" text NOT NULL,
"clave" text NOT NULL,
"publico" boolean DEFAULT false NOT NULL,
"mime" text NOT NULL,
"bytes" integer NOT NULL,
"duracion_s" integer,
"ancho" integer,
"alto" integer,
"sha256" text NOT NULL,
"alt" text,
"creado_en" timestamp with time zone NOT NULL,
CONSTRAINT "medio_clave_unique" UNIQUE("clave")
);
--> statement-breakpoint
CREATE TABLE "pais" (
"codigo" char(2) PRIMARY KEY NOT NULL,
"nombre" text NOT NULL,
"gentilicio" text,
"region" text
);
--> statement-breakpoint
CREATE TABLE "persona" (
"id" text PRIMARY KEY NOT NULL,
"slug" text NOT NULL,
"nombre" text NOT NULL,
"nombre_legal" text,
"ipi" text,
"nacimiento" integer,
"defuncion" integer,
"pais_codigo" char(2),
"retrato_id" text,
"semblanza" text,
"estado" text DEFAULT 'borrador' NOT NULL,
"creado_en" timestamp with time zone NOT NULL,
"actualizado_en" timestamp with time zone,
CONSTRAINT "persona_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "album" (
"id" text PRIMARY KEY NOT NULL,
"slug" text NOT NULL,
"titulo" text NOT NULL,
"estilo_id" text NOT NULL,
"tipo" text DEFAULT 'album' NOT NULL,
"fecha" text NOT NULL,
"portada_id" text,
"sello" text,
"resumen" text DEFAULT '' NOT NULL,
"destacado" boolean DEFAULT false NOT NULL,
"numero_catalogo" text,
"formatos" text[],
"pais_edicion_codigo" char(2),
"estado" text DEFAULT 'borrador' NOT NULL,
"creado_en" timestamp with time zone NOT NULL,
"actualizado_en" timestamp with time zone,
CONSTRAINT "album_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "album_enlace" (
"album_id" text NOT NULL,
"plataforma" text NOT NULL,
"url" text NOT NULL,
CONSTRAINT "album_enlace_album_id_plataforma_pk" PRIMARY KEY("album_id","plataforma")
);
--> statement-breakpoint
CREATE TABLE "autoria" (
"cancion_id" text NOT NULL,
"persona_id" text NOT NULL,
"rol" text NOT NULL,
"porcentaje" numeric(5, 2) NOT NULL,
CONSTRAINT "autoria_cancion_id_persona_id_rol_pk" PRIMARY KEY("cancion_id","persona_id","rol")
);
--> statement-breakpoint
CREATE TABLE "cancion" (
"id" text PRIMARY KEY NOT NULL,
"slug" text NOT NULL,
"titulo" text NOT NULL,
"version_de_id" text,
"version_nombre" text,
"version_orden" integer DEFAULT 0 NOT NULL,
"estilo_id" text NOT NULL,
"album_id" text,
"numero" integer,
"audio_id" text,
"muestra_id" text,
"duracion_s" integer,
"ano" integer,
"letra" text,
"idioma" text DEFAULT 'es' NOT NULL,
"nota" text,
"inspiracion" text,
"instrumental" boolean DEFAULT false NOT NULL,
"destacada" boolean DEFAULT false NOT NULL,
"portada_id" text,
"ala_venta" boolean DEFAULT false NOT NULL,
"estado" text DEFAULT 'borrador' NOT NULL,
"creado_en" timestamp with time zone NOT NULL,
"actualizado_en" timestamp with time zone,
CONSTRAINT "cancion_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "cancion_enlace" (
"cancion_id" text NOT NULL,
"plataforma" text NOT NULL,
"url" text NOT NULL,
CONSTRAINT "cancion_enlace_cancion_id_plataforma_pk" PRIMARY KEY("cancion_id","plataforma")
);
--> statement-breakpoint
CREATE TABLE "cancion_etiqueta" (
"cancion_id" text NOT NULL,
"etiqueta_id" text NOT NULL,
CONSTRAINT "cancion_etiqueta_cancion_id_etiqueta_id_pk" PRIMARY KEY("cancion_id","etiqueta_id")
);
--> statement-breakpoint
CREATE TABLE "cancion_persona" (
"cancion_id" text NOT NULL,
"persona_id" text NOT NULL,
"rol" text NOT NULL,
"orden" integer DEFAULT 0 NOT NULL,
CONSTRAINT "cancion_persona_cancion_id_persona_id_rol_pk" PRIMARY KEY("cancion_id","persona_id","rol")
);
--> statement-breakpoint
CREATE TABLE "credito_album" (
"id" text PRIMARY KEY NOT NULL,
"album_id" text NOT NULL,
"persona_id" text,
"nombre" text,
"rol" text NOT NULL,
"orden" integer DEFAULT 0 NOT NULL
);
--> statement-breakpoint
CREATE TABLE "estilo" (
"id" text PRIMARY KEY NOT NULL,
"slug" text NOT NULL,
"nombre" text NOT NULL,
"resumen" text DEFAULT '' NOT NULL,
"orden" integer DEFAULT 0 NOT NULL,
"principal" boolean DEFAULT false NOT NULL,
"imagen_id" text,
"estado" text DEFAULT 'borrador' NOT NULL,
"creado_en" timestamp with time zone NOT NULL,
"actualizado_en" timestamp with time zone,
CONSTRAINT "estilo_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "etiqueta" (
"id" text PRIMARY KEY NOT NULL,
"slug" text NOT NULL,
"nombre" text NOT NULL,
CONSTRAINT "etiqueta_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "registro_obra" (
"cancion_id" text PRIMARY KEY NOT NULL,
"iswc" text,
"isrc" text,
"entidad" text,
"numero" text,
"editorial" text
);
--> statement-breakpoint
CREATE TABLE "traduccion" (
"cancion_id" text NOT NULL,
"idioma" text NOT NULL,
"texto" text NOT NULL,
CONSTRAINT "traduccion_cancion_id_idioma_pk" PRIMARY KEY("cancion_id","idioma")
);
--> statement-breakpoint
CREATE TABLE "video" (
"id" text PRIMARY KEY NOT NULL,
"cancion_id" text NOT NULL,
"youtube_id" text NOT NULL,
"titulo" text NOT NULL,
"orden" integer DEFAULT 0 NOT NULL
);
--> statement-breakpoint
CREATE TABLE "acepcion" (
"id" text PRIMARY KEY NOT NULL,
"termino_id" text NOT NULL,
"numero" integer NOT NULL,
"categoria" text NOT NULL,
"definicion" text NOT NULL,
"ampliacion" text,
"ejemplo" text
);
--> statement-breakpoint
CREATE TABLE "animo" (
"id" text PRIMARY KEY NOT NULL,
"codigo" text NOT NULL,
"nombre" text NOT NULL,
"gems" text,
"valencia" integer,
"energia" integer,
"descripcion" text,
CONSTRAINT "animo_codigo_unique" UNIQUE("codigo")
);
--> statement-breakpoint
CREATE TABLE "cancion_genero" (
"cancion_id" text NOT NULL,
"genero_id" text NOT NULL,
CONSTRAINT "cancion_genero_cancion_id_genero_id_pk" PRIMARY KEY("cancion_id","genero_id")
);
--> statement-breakpoint
CREATE TABLE "capitulo" (
"id" text PRIMARY KEY NOT NULL,
"slug" text NOT NULL,
"titulo" text NOT NULL,
"tipo" text NOT NULL,
"genero_id" text,
"orden" integer DEFAULT 0 NOT NULL,
"resumen" text DEFAULT '' NOT NULL,
"cuerpo" text DEFAULT '' NOT NULL,
"estado" text DEFAULT 'borrador' NOT NULL,
"creado_en" timestamp with time zone NOT NULL,
"actualizado_en" timestamp with time zone,
CONSTRAINT "capitulo_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "capitulo_acepcion" (
"capitulo_id" text NOT NULL,
"acepcion_id" text NOT NULL,
"automatico" boolean DEFAULT true NOT NULL,
"confirmado" boolean DEFAULT false NOT NULL,
CONSTRAINT "capitulo_acepcion_capitulo_id_acepcion_id_pk" PRIMARY KEY("capitulo_id","acepcion_id")
);
--> statement-breakpoint
CREATE TABLE "compas" (
"id" text PRIMARY KEY NOT NULL,
"codigo" text NOT NULL,
"nombre" text,
"numerador" integer NOT NULL,
"denominador" integer NOT NULL,
"nota" text,
CONSTRAINT "compas_codigo_unique" UNIQUE("codigo")
);
--> statement-breakpoint
CREATE TABLE "genero" (
"id" text PRIMARY KEY NOT NULL,
"slug" text NOT NULL,
"nombre" text NOT NULL,
"resumen" text DEFAULT '' NOT NULL,
"origen_lugar" text,
"origen_ano" integer,
"tempo" text,
"rima" text,
"metrica_nota" text,
"compas_nota" text,
"rasgos" text[],
"estado" text DEFAULT 'borrador' NOT NULL,
"origen" text DEFAULT 'manual' NOT NULL,
"modelo_origen" text,
"importado_en" timestamp with time zone,
"revisado_por" text,
"revisado_en" timestamp with time zone,
"creado_en" timestamp with time zone NOT NULL,
"actualizado_en" timestamp with time zone,
CONSTRAINT "genero_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "genero_alias" (
"id" text PRIMARY KEY NOT NULL,
"genero_id" text NOT NULL,
"nombre" text NOT NULL,
"pais_codigo" char(2),
"nota" text
);
--> statement-breakpoint
CREATE TABLE "genero_compas" (
"genero_id" text NOT NULL,
"compas_id" text NOT NULL,
"principal" boolean DEFAULT false NOT NULL,
"nota" text,
CONSTRAINT "genero_compas_genero_id_compas_id_pk" PRIMARY KEY("genero_id","compas_id")
);
--> statement-breakpoint
CREATE TABLE "genero_instrumento" (
"genero_id" text NOT NULL,
"instrumento_id" text NOT NULL,
"importancia" text DEFAULT 'habitual' NOT NULL,
"papel" text,
"orden" integer DEFAULT 0 NOT NULL,
CONSTRAINT "genero_instrumento_genero_id_instrumento_id_pk" PRIMARY KEY("genero_id","instrumento_id")
);
--> statement-breakpoint
CREATE TABLE "genero_metro" (
"genero_id" text NOT NULL,
"metro_id" text NOT NULL,
"predominante" boolean DEFAULT false NOT NULL,
CONSTRAINT "genero_metro_genero_id_metro_id_pk" PRIMARY KEY("genero_id","metro_id")
);
--> statement-breakpoint
CREATE TABLE "genero_origen" (
"genero_id" text NOT NULL,
"origen_id" text NOT NULL,
"tipo" text NOT NULL,
"principal" boolean DEFAULT false NOT NULL,
"nota" text,
"orden" integer DEFAULT 0 NOT NULL,
CONSTRAINT "genero_origen_genero_id_origen_id_tipo_pk" PRIMARY KEY("genero_id","origen_id","tipo")
);
--> statement-breakpoint
CREATE TABLE "genero_pais" (
"genero_id" text NOT NULL,
"pais_codigo" char(2) NOT NULL,
"relacion" text DEFAULT 'adoptado' NOT NULL,
"nota" text,
CONSTRAINT "genero_pais_genero_id_pais_codigo_pk" PRIMARY KEY("genero_id","pais_codigo")
);
--> statement-breakpoint
CREATE TABLE "genero_persona" (
"genero_id" text NOT NULL,
"persona_id" text NOT NULL,
"papel" text NOT NULL,
"nota" text,
"orden" integer DEFAULT 0 NOT NULL,
CONSTRAINT "genero_persona_genero_id_persona_id_papel_pk" PRIMARY KEY("genero_id","persona_id","papel")
);
--> statement-breakpoint
CREATE TABLE "genero_seccion" (
"id" text PRIMARY KEY NOT NULL,
"genero_id" text NOT NULL,
"clave" text NOT NULL,
"titulo" text NOT NULL,
"cuerpo" text NOT NULL,
"orden" integer DEFAULT 0 NOT NULL
);
--> statement-breakpoint
CREATE TABLE "genero_tema" (
"genero_id" text NOT NULL,
"etiqueta_id" text NOT NULL,
"orden" integer DEFAULT 0 NOT NULL,
CONSTRAINT "genero_tema_genero_id_etiqueta_id_pk" PRIMARY KEY("genero_id","etiqueta_id")
);
--> statement-breakpoint
CREATE TABLE "instrumento" (
"id" text PRIMARY KEY NOT NULL,
"slug" text NOT NULL,
"nombre" text NOT NULL,
"familia" text NOT NULL,
"subfamilia" text,
"pais_codigo" char(2),
"descripcion" text,
"afinacion" text,
"imagen_id" text,
"muestra_id" text,
"estado" text DEFAULT 'borrador' NOT NULL,
"origen" text DEFAULT 'manual' NOT NULL,
"modelo_origen" text,
"importado_en" timestamp with time zone,
"revisado_por" text,
"revisado_en" timestamp with time zone,
CONSTRAINT "instrumento_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "metro" (
"id" text PRIMARY KEY NOT NULL,
"codigo" text NOT NULL,
"nombre" text NOT NULL,
"silabas" integer,
"termino_id" text,
"nota" text,
CONSTRAINT "metro_codigo_unique" UNIQUE("codigo")
);
--> statement-breakpoint
CREATE TABLE "modelo_ia" (
"id" text PRIMARY KEY NOT NULL,
"codigo" text NOT NULL,
"familia" text NOT NULL,
"version" text NOT NULL,
"nombre" text NOT NULL,
"vigente" boolean DEFAULT true NOT NULL,
"nota_sintaxis" text,
"esquema" jsonb,
CONSTRAINT "modelo_ia_codigo_unique" UNIQUE("codigo")
);
--> statement-breakpoint
CREATE TABLE "obra_referencia" (
"id" text PRIMARY KEY NOT NULL,
"slug" text NOT NULL,
"titulo" text NOT NULL,
"ano" integer,
"genero_id" text,
"nota" text,
"enlace" text,
"cita" text,
"estado" text DEFAULT 'borrador' NOT NULL,
"origen" text DEFAULT 'manual' NOT NULL,
"modelo_origen" text,
"importado_en" timestamp with time zone,
"revisado_por" text,
"revisado_en" timestamp with time zone,
CONSTRAINT "obra_referencia_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "obra_referencia_persona" (
"obra_id" text NOT NULL,
"persona_id" text NOT NULL,
"papel" text NOT NULL,
"orden" integer DEFAULT 0 NOT NULL,
CONSTRAINT "obra_referencia_persona_obra_id_persona_id_papel_pk" PRIMARY KEY("obra_id","persona_id","papel")
);
--> statement-breakpoint
CREATE TABLE "prompt" (
"id" text PRIMARY KEY NOT NULL,
"modelo_ia_id" text NOT NULL,
"genero_id" text,
"animo_id" text,
"idioma" text DEFAULT 'en' NOT NULL,
"contenido" jsonb NOT NULL,
"notas" text,
"valoracion" integer,
"ejemplo_id" text,
"preferido" boolean DEFAULT false NOT NULL,
"visibilidad" text DEFAULT 'privado' NOT NULL,
"estado" text DEFAULT 'borrador' NOT NULL,
"creado_en" timestamp with time zone NOT NULL,
"actualizado_en" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "termino" (
"id" text PRIMARY KEY NOT NULL,
"slug" text NOT NULL,
"termino" text NOT NULL,
CONSTRAINT "termino_slug_unique" UNIQUE("slug"),
CONSTRAINT "termino_termino_unique" UNIQUE("termino")
);
--> statement-breakpoint
CREATE TABLE "termino_variante" (
"termino_id" text NOT NULL,
"forma" text NOT NULL,
CONSTRAINT "termino_variante_termino_id_forma_pk" PRIMARY KEY("termino_id","forma")
);
--> statement-breakpoint
CREATE TABLE "documento_legal" (
"id" text PRIMARY KEY NOT NULL,
"slug" text NOT NULL,
"titulo" text NOT NULL,
"resumen" text DEFAULT '' NOT NULL,
"orden" integer DEFAULT 0 NOT NULL,
"version_vigente_id" text,
CONSTRAINT "documento_legal_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "entrada" (
"id" text PRIMARY KEY NOT NULL,
"slug" text NOT NULL,
"titulo" text NOT NULL,
"fecha" text NOT NULL,
"resumen" text DEFAULT '' NOT NULL,
"cuerpo" text DEFAULT '' NOT NULL,
"imagen_id" text,
"estado" text DEFAULT 'borrador' NOT NULL,
"creado_en" timestamp with time zone NOT NULL,
"actualizado_en" timestamp with time zone,
CONSTRAINT "entrada_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "entrada_cancion" (
"entrada_id" text NOT NULL,
"cancion_id" text NOT NULL,
CONSTRAINT "entrada_cancion_entrada_id_cancion_id_pk" PRIMARY KEY("entrada_id","cancion_id")
);
--> statement-breakpoint
CREATE TABLE "entrada_etiqueta" (
"entrada_id" text NOT NULL,
"etiqueta_id" text NOT NULL,
CONSTRAINT "entrada_etiqueta_entrada_id_etiqueta_id_pk" PRIMARY KEY("entrada_id","etiqueta_id")
);
--> statement-breakpoint
CREATE TABLE "pagina" (
"id" text PRIMARY KEY NOT NULL,
"slug" text NOT NULL,
"titulo" text NOT NULL,
"descripcion" text DEFAULT '' NOT NULL,
"cuerpo" text DEFAULT '' NOT NULL,
"estado" text DEFAULT 'borrador' NOT NULL,
"creado_en" timestamp with time zone NOT NULL,
"actualizado_en" timestamp with time zone,
CONSTRAINT "pagina_slug_unique" UNIQUE("slug")
);
--> statement-breakpoint
CREATE TABLE "redireccion" (
"desde" text PRIMARY KEY NOT NULL,
"hacia" text NOT NULL,
"permanente" boolean DEFAULT true NOT NULL,
"motivo" text,
"creada_en" timestamp with time zone NOT NULL
);
--> statement-breakpoint
CREATE TABLE "version_legal" (
"id" text PRIMARY KEY NOT NULL,
"documento_id" text NOT NULL,
"cuerpo" text NOT NULL,
"vigente_desde" timestamp with time zone NOT NULL,
"resumen_cambios" text
);
--> statement-breakpoint
CREATE TABLE "contador_factura" (
"serie" text PRIMARY KEY NOT NULL,
"ultimo_numero" integer DEFAULT 0 NOT NULL,
"actualizado_en" timestamp with time zone NOT NULL
);
--> statement-breakpoint
CREATE TABLE "datos_facturacion" (
"id" text PRIMARY KEY NOT NULL,
"usuario_id" text NOT NULL,
"tipo" text DEFAULT 'particular' NOT NULL,
"nombre" text NOT NULL,
"nif" text,
"direccion" text,
"cp" text,
"ciudad" text,
"provincia" text,
"pais_codigo" char(2) NOT NULL,
"predeterminada" boolean DEFAULT false NOT NULL,
"creado_en" timestamp with time zone NOT NULL,
"actualizado_en" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "descarga" (
"id" text PRIMARY KEY NOT NULL,
"usuario_id" text NOT NULL,
"cancion_slug" text NOT NULL,
"bajada_en" timestamp with time zone NOT NULL,
"ip" text,
"agente" text
);
--> statement-breakpoint
CREATE TABLE "factura" (
"id" text PRIMARY KEY NOT NULL,
"pedido_id" text NOT NULL,
"serie" text NOT NULL,
"numero" integer NOT NULL,
"tipo" text DEFAULT 'normal' NOT NULL,
"rectifica_a_id" text,
"fecha" timestamp with time zone NOT NULL,
"base_centimos" integer NOT NULL,
"iva_tipo" integer NOT NULL,
"iva_cuota_centimos" integer NOT NULL,
"total_centimos" integer NOT NULL,
"moneda" text DEFAULT 'eur' NOT NULL,
"datos_congelados" jsonb NOT NULL,
"pais_codigo" char(2) NOT NULL
);
--> statement-breakpoint
CREATE TABLE "reproduccion" (
"id" text PRIMARY KEY NOT NULL,
"cancion_slug" text NOT NULL,
"usuario_id" text,
"segundos" integer DEFAULT 0 NOT NULL,
"dia" text NOT NULL,
"creado_en" timestamp with time zone NOT NULL
);
--> statement-breakpoint
ALTER TABLE "persona" ADD CONSTRAINT "persona_pais_codigo_pais_codigo_fk" FOREIGN KEY ("pais_codigo") REFERENCES "public"."pais"("codigo") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "persona" ADD CONSTRAINT "persona_retrato_id_medio_id_fk" FOREIGN KEY ("retrato_id") REFERENCES "public"."medio"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "album" ADD CONSTRAINT "album_estilo_id_estilo_id_fk" FOREIGN KEY ("estilo_id") REFERENCES "public"."estilo"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "album" ADD CONSTRAINT "album_portada_id_medio_id_fk" FOREIGN KEY ("portada_id") REFERENCES "public"."medio"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "album" ADD CONSTRAINT "album_pais_edicion_codigo_pais_codigo_fk" FOREIGN KEY ("pais_edicion_codigo") REFERENCES "public"."pais"("codigo") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "album_enlace" ADD CONSTRAINT "album_enlace_album_id_album_id_fk" FOREIGN KEY ("album_id") REFERENCES "public"."album"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "autoria" ADD CONSTRAINT "autoria_cancion_id_cancion_id_fk" FOREIGN KEY ("cancion_id") REFERENCES "public"."cancion"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "autoria" ADD CONSTRAINT "autoria_persona_id_persona_id_fk" FOREIGN KEY ("persona_id") REFERENCES "public"."persona"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cancion" ADD CONSTRAINT "cancion_estilo_id_estilo_id_fk" FOREIGN KEY ("estilo_id") REFERENCES "public"."estilo"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cancion" ADD CONSTRAINT "cancion_album_id_album_id_fk" FOREIGN KEY ("album_id") REFERENCES "public"."album"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cancion" ADD CONSTRAINT "cancion_audio_id_medio_id_fk" FOREIGN KEY ("audio_id") REFERENCES "public"."medio"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cancion" ADD CONSTRAINT "cancion_muestra_id_medio_id_fk" FOREIGN KEY ("muestra_id") REFERENCES "public"."medio"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cancion" ADD CONSTRAINT "cancion_portada_id_medio_id_fk" FOREIGN KEY ("portada_id") REFERENCES "public"."medio"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cancion_enlace" ADD CONSTRAINT "cancion_enlace_cancion_id_cancion_id_fk" FOREIGN KEY ("cancion_id") REFERENCES "public"."cancion"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cancion_etiqueta" ADD CONSTRAINT "cancion_etiqueta_cancion_id_cancion_id_fk" FOREIGN KEY ("cancion_id") REFERENCES "public"."cancion"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cancion_etiqueta" ADD CONSTRAINT "cancion_etiqueta_etiqueta_id_etiqueta_id_fk" FOREIGN KEY ("etiqueta_id") REFERENCES "public"."etiqueta"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cancion_persona" ADD CONSTRAINT "cancion_persona_cancion_id_cancion_id_fk" FOREIGN KEY ("cancion_id") REFERENCES "public"."cancion"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cancion_persona" ADD CONSTRAINT "cancion_persona_persona_id_persona_id_fk" FOREIGN KEY ("persona_id") REFERENCES "public"."persona"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "credito_album" ADD CONSTRAINT "credito_album_album_id_album_id_fk" FOREIGN KEY ("album_id") REFERENCES "public"."album"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "credito_album" ADD CONSTRAINT "credito_album_persona_id_persona_id_fk" FOREIGN KEY ("persona_id") REFERENCES "public"."persona"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "estilo" ADD CONSTRAINT "estilo_imagen_id_medio_id_fk" FOREIGN KEY ("imagen_id") REFERENCES "public"."medio"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "registro_obra" ADD CONSTRAINT "registro_obra_cancion_id_cancion_id_fk" FOREIGN KEY ("cancion_id") REFERENCES "public"."cancion"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "traduccion" ADD CONSTRAINT "traduccion_cancion_id_cancion_id_fk" FOREIGN KEY ("cancion_id") REFERENCES "public"."cancion"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "video" ADD CONSTRAINT "video_cancion_id_cancion_id_fk" FOREIGN KEY ("cancion_id") REFERENCES "public"."cancion"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "acepcion" ADD CONSTRAINT "acepcion_termino_id_termino_id_fk" FOREIGN KEY ("termino_id") REFERENCES "public"."termino"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cancion_genero" ADD CONSTRAINT "cancion_genero_cancion_id_cancion_id_fk" FOREIGN KEY ("cancion_id") REFERENCES "public"."cancion"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "cancion_genero" ADD CONSTRAINT "cancion_genero_genero_id_genero_id_fk" FOREIGN KEY ("genero_id") REFERENCES "public"."genero"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "capitulo" ADD CONSTRAINT "capitulo_genero_id_genero_id_fk" FOREIGN KEY ("genero_id") REFERENCES "public"."genero"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "capitulo_acepcion" ADD CONSTRAINT "capitulo_acepcion_capitulo_id_capitulo_id_fk" FOREIGN KEY ("capitulo_id") REFERENCES "public"."capitulo"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "capitulo_acepcion" ADD CONSTRAINT "capitulo_acepcion_acepcion_id_acepcion_id_fk" FOREIGN KEY ("acepcion_id") REFERENCES "public"."acepcion"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "genero_alias" ADD CONSTRAINT "genero_alias_genero_id_genero_id_fk" FOREIGN KEY ("genero_id") REFERENCES "public"."genero"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "genero_alias" ADD CONSTRAINT "genero_alias_pais_codigo_pais_codigo_fk" FOREIGN KEY ("pais_codigo") REFERENCES "public"."pais"("codigo") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "genero_compas" ADD CONSTRAINT "genero_compas_genero_id_genero_id_fk" FOREIGN KEY ("genero_id") REFERENCES "public"."genero"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "genero_compas" ADD CONSTRAINT "genero_compas_compas_id_compas_id_fk" FOREIGN KEY ("compas_id") REFERENCES "public"."compas"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "genero_instrumento" ADD CONSTRAINT "genero_instrumento_genero_id_genero_id_fk" FOREIGN KEY ("genero_id") REFERENCES "public"."genero"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "genero_instrumento" ADD CONSTRAINT "genero_instrumento_instrumento_id_instrumento_id_fk" FOREIGN KEY ("instrumento_id") REFERENCES "public"."instrumento"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "genero_metro" ADD CONSTRAINT "genero_metro_genero_id_genero_id_fk" FOREIGN KEY ("genero_id") REFERENCES "public"."genero"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "genero_metro" ADD CONSTRAINT "genero_metro_metro_id_metro_id_fk" FOREIGN KEY ("metro_id") REFERENCES "public"."metro"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "genero_origen" ADD CONSTRAINT "genero_origen_genero_id_genero_id_fk" FOREIGN KEY ("genero_id") REFERENCES "public"."genero"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "genero_origen" ADD CONSTRAINT "genero_origen_origen_id_genero_id_fk" FOREIGN KEY ("origen_id") REFERENCES "public"."genero"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "genero_pais" ADD CONSTRAINT "genero_pais_genero_id_genero_id_fk" FOREIGN KEY ("genero_id") REFERENCES "public"."genero"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "genero_pais" ADD CONSTRAINT "genero_pais_pais_codigo_pais_codigo_fk" FOREIGN KEY ("pais_codigo") REFERENCES "public"."pais"("codigo") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "genero_persona" ADD CONSTRAINT "genero_persona_genero_id_genero_id_fk" FOREIGN KEY ("genero_id") REFERENCES "public"."genero"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "genero_persona" ADD CONSTRAINT "genero_persona_persona_id_persona_id_fk" FOREIGN KEY ("persona_id") REFERENCES "public"."persona"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "genero_seccion" ADD CONSTRAINT "genero_seccion_genero_id_genero_id_fk" FOREIGN KEY ("genero_id") REFERENCES "public"."genero"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "genero_tema" ADD CONSTRAINT "genero_tema_genero_id_genero_id_fk" FOREIGN KEY ("genero_id") REFERENCES "public"."genero"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "genero_tema" ADD CONSTRAINT "genero_tema_etiqueta_id_etiqueta_id_fk" FOREIGN KEY ("etiqueta_id") REFERENCES "public"."etiqueta"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "instrumento" ADD CONSTRAINT "instrumento_pais_codigo_pais_codigo_fk" FOREIGN KEY ("pais_codigo") REFERENCES "public"."pais"("codigo") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "instrumento" ADD CONSTRAINT "instrumento_imagen_id_medio_id_fk" FOREIGN KEY ("imagen_id") REFERENCES "public"."medio"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "instrumento" ADD CONSTRAINT "instrumento_muestra_id_medio_id_fk" FOREIGN KEY ("muestra_id") REFERENCES "public"."medio"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "obra_referencia" ADD CONSTRAINT "obra_referencia_genero_id_genero_id_fk" FOREIGN KEY ("genero_id") REFERENCES "public"."genero"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "obra_referencia_persona" ADD CONSTRAINT "obra_referencia_persona_obra_id_obra_referencia_id_fk" FOREIGN KEY ("obra_id") REFERENCES "public"."obra_referencia"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "obra_referencia_persona" ADD CONSTRAINT "obra_referencia_persona_persona_id_persona_id_fk" FOREIGN KEY ("persona_id") REFERENCES "public"."persona"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "prompt" ADD CONSTRAINT "prompt_modelo_ia_id_modelo_ia_id_fk" FOREIGN KEY ("modelo_ia_id") REFERENCES "public"."modelo_ia"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "prompt" ADD CONSTRAINT "prompt_genero_id_genero_id_fk" FOREIGN KEY ("genero_id") REFERENCES "public"."genero"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "prompt" ADD CONSTRAINT "prompt_animo_id_animo_id_fk" FOREIGN KEY ("animo_id") REFERENCES "public"."animo"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "prompt" ADD CONSTRAINT "prompt_ejemplo_id_medio_id_fk" FOREIGN KEY ("ejemplo_id") REFERENCES "public"."medio"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "termino_variante" ADD CONSTRAINT "termino_variante_termino_id_termino_id_fk" FOREIGN KEY ("termino_id") REFERENCES "public"."termino"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "entrada" ADD CONSTRAINT "entrada_imagen_id_medio_id_fk" FOREIGN KEY ("imagen_id") REFERENCES "public"."medio"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "entrada_cancion" ADD CONSTRAINT "entrada_cancion_entrada_id_entrada_id_fk" FOREIGN KEY ("entrada_id") REFERENCES "public"."entrada"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "entrada_cancion" ADD CONSTRAINT "entrada_cancion_cancion_id_cancion_id_fk" FOREIGN KEY ("cancion_id") REFERENCES "public"."cancion"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "entrada_etiqueta" ADD CONSTRAINT "entrada_etiqueta_entrada_id_entrada_id_fk" FOREIGN KEY ("entrada_id") REFERENCES "public"."entrada"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "entrada_etiqueta" ADD CONSTRAINT "entrada_etiqueta_etiqueta_id_etiqueta_id_fk" FOREIGN KEY ("etiqueta_id") REFERENCES "public"."etiqueta"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "version_legal" ADD CONSTRAINT "version_legal_documento_id_documento_legal_id_fk" FOREIGN KEY ("documento_id") REFERENCES "public"."documento_legal"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "datos_facturacion" ADD CONSTRAINT "datos_facturacion_usuario_id_usuario_id_fk" FOREIGN KEY ("usuario_id") REFERENCES "public"."usuario"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "datos_facturacion" ADD CONSTRAINT "datos_facturacion_pais_codigo_pais_codigo_fk" FOREIGN KEY ("pais_codigo") REFERENCES "public"."pais"("codigo") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "descarga" ADD CONSTRAINT "descarga_usuario_id_usuario_id_fk" FOREIGN KEY ("usuario_id") REFERENCES "public"."usuario"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "factura" ADD CONSTRAINT "factura_pedido_id_pedido_id_fk" FOREIGN KEY ("pedido_id") REFERENCES "public"."pedido"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "factura" ADD CONSTRAINT "factura_pais_codigo_pais_codigo_fk" FOREIGN KEY ("pais_codigo") REFERENCES "public"."pais"("codigo") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "reproduccion" ADD CONSTRAINT "reproduccion_usuario_id_usuario_id_fk" FOREIGN KEY ("usuario_id") REFERENCES "public"."usuario"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "medio_clase" ON "medio" USING btree ("clase");--> statement-breakpoint
CREATE INDEX "medio_sha" ON "medio" USING btree ("sha256");--> statement-breakpoint
CREATE INDEX "persona_nombre" ON "persona" USING btree ("nombre");--> statement-breakpoint
CREATE INDEX "persona_pais" ON "persona" USING btree ("pais_codigo");--> statement-breakpoint
CREATE INDEX "album_estilo" ON "album" USING btree ("estilo_id");--> statement-breakpoint
CREATE INDEX "album_fecha" ON "album" USING btree ("fecha");--> statement-breakpoint
CREATE INDEX "cancion_estilo" ON "cancion" USING btree ("estilo_id");--> statement-breakpoint
CREATE INDEX "cancion_album" ON "cancion" USING btree ("album_id");--> statement-breakpoint
CREATE INDEX "cancion_version_de" ON "cancion" USING btree ("version_de_id");--> statement-breakpoint
CREATE INDEX "cancion_destacada" ON "cancion" USING btree ("destacada");--> statement-breakpoint
CREATE INDEX "credito_album_album" ON "credito_album" USING btree ("album_id");--> statement-breakpoint
CREATE UNIQUE INDEX "estilo_principal_unico" ON "estilo" USING btree ("principal") WHERE "estilo"."principal";--> statement-breakpoint
CREATE INDEX "estilo_orden" ON "estilo" USING btree ("orden");--> statement-breakpoint
CREATE INDEX "video_cancion" ON "video" USING btree ("cancion_id");--> statement-breakpoint
CREATE UNIQUE INDEX "acepcion_numero" ON "acepcion" USING btree ("termino_id","numero");--> statement-breakpoint
CREATE INDEX "capitulo_tipo" ON "capitulo" USING btree ("tipo","orden");--> statement-breakpoint
CREATE INDEX "genero_nombre" ON "genero" USING btree ("nombre");--> statement-breakpoint
CREATE INDEX "genero_origen_ano" ON "genero" USING btree ("origen_ano");--> statement-breakpoint
CREATE INDEX "genero_alias_nombre" ON "genero_alias" USING btree ("nombre");--> statement-breakpoint
CREATE INDEX "genero_origen_origen" ON "genero_origen" USING btree ("origen_id");--> statement-breakpoint
CREATE UNIQUE INDEX "genero_seccion_clave" ON "genero_seccion" USING btree ("genero_id","clave");--> statement-breakpoint
CREATE INDEX "instrumento_familia" ON "instrumento" USING btree ("familia");--> statement-breakpoint
CREATE INDEX "modelo_ia_familia" ON "modelo_ia" USING btree ("familia");--> statement-breakpoint
CREATE INDEX "obra_referencia_genero" ON "obra_referencia" USING btree ("genero_id");--> statement-breakpoint
CREATE INDEX "prompt_genero" ON "prompt" USING btree ("genero_id");--> statement-breakpoint
CREATE INDEX "prompt_modelo" ON "prompt" USING btree ("modelo_ia_id");--> statement-breakpoint
CREATE INDEX "prompt_animo" ON "prompt" USING btree ("animo_id");--> statement-breakpoint
CREATE INDEX "entrada_fecha" ON "entrada" USING btree ("fecha");--> statement-breakpoint
CREATE INDEX "redireccion_hacia" ON "redireccion" USING btree ("hacia");--> statement-breakpoint
CREATE INDEX "version_legal_documento" ON "version_legal" USING btree ("documento_id","vigente_desde");--> statement-breakpoint
CREATE INDEX "datos_facturacion_usuario" ON "datos_facturacion" USING btree ("usuario_id");--> statement-breakpoint
CREATE INDEX "descarga_usuario" ON "descarga" USING btree ("usuario_id");--> statement-breakpoint
CREATE INDEX "descarga_fecha" ON "descarga" USING btree ("bajada_en");--> statement-breakpoint
CREATE UNIQUE INDEX "factura_serie_numero" ON "factura" USING btree ("serie","numero");--> statement-breakpoint
CREATE INDEX "factura_pedido" ON "factura" USING btree ("pedido_id");--> statement-breakpoint
CREATE INDEX "factura_fecha" ON "factura" USING btree ("fecha");--> statement-breakpoint
CREATE INDEX "reproduccion_dia" ON "reproduccion" USING btree ("dia");--> statement-breakpoint
CREATE INDEX "reproduccion_cancion" ON "reproduccion" USING btree ("cancion_slug");--> statement-breakpoint
CREATE INDEX "reproduccion_usuario" ON "reproduccion" USING btree ("usuario_id");--> statement-breakpoint
-- ===========================================================================
-- Lo que Drizzle no sabe expresar, y que es justo lo que impide que el modelo
-- mienta. Añadido a mano; si algún día se regenera esta migración, esto se
-- vuelve a poner.
-- ===========================================================================
-- --- Las versiones de una canción -----------------------------------------
--
-- La clave foránea no se declara en el esquema porque Drizzle no deja
-- referenciar una tabla dentro de su propia definición.
ALTER TABLE "cancion" ADD CONSTRAINT "cancion_version_de_fk"
FOREIGN KEY ("version_de_id") REFERENCES "cancion"("id") ON DELETE cascade;
--> statement-breakpoint
-- Una canción no es versión de sí misma.
ALTER TABLE "cancion" ADD CONSTRAINT "cancion_no_es_su_propia_version"
CHECK ("version_de_id" IS NULL OR "version_de_id" <> "id");
--> statement-breakpoint
-- Y una versión no puede tener versiones: un solo nivel.
--
-- Se hace con una clave foránea compuesta contra una columna generada, no con
-- un disparador. `es_version` se calcula sola, la pareja (id, es_version) es
-- única, y la referencia exige que el padre tenga `es_version` a falso —lo que
-- garantiza la columna `padre_no_es_version`, fija en falso—. Con eso quedan
-- descartados a la vez el nivel tres y los ciclos, sin escribir procedimientos.
ALTER TABLE "cancion" ADD COLUMN "es_version" boolean
GENERATED ALWAYS AS ("version_de_id" IS NOT NULL) STORED;
--> statement-breakpoint
ALTER TABLE "cancion" ADD COLUMN "padre_no_es_version" boolean
GENERATED ALWAYS AS (CASE WHEN "version_de_id" IS NULL THEN NULL ELSE false END) STORED;
--> statement-breakpoint
ALTER TABLE "cancion" ADD CONSTRAINT "cancion_id_es_version_unica"
UNIQUE ("id", "es_version");
--> statement-breakpoint
ALTER TABLE "cancion" ADD CONSTRAINT "cancion_version_de_principal"
FOREIGN KEY ("version_de_id", "padre_no_es_version")
REFERENCES "cancion"("id", "es_version");
--> statement-breakpoint
-- --- La vista de lo que se lista y se cuenta -------------------------------
--
-- Cuatro funciones devuelven listas de canciones y siete rutas las pintan, más
-- los recuentos por estilo y por etiqueta. Si a una se le olvida el filtro, el
-- catálogo dice veinticinco temas donde hay dieciocho y nadie lo nota. Lo
-- normal es consultar esto; pedir versiones es lo que hay que escribir a
-- propósito.
CREATE VIEW "cancion_principal" AS
SELECT * FROM "cancion" WHERE "version_de_id" IS NULL;
--> statement-breakpoint
-- --- El reparto de autoría suma cien ---------------------------------------
--
-- Un reparto que no suma cien no es un dato incompleto: es dinero mal
-- repartido. Se comprueba al terminar la transacción y no en cada fila, porque
-- durante una inserción de tres autores los estados intermedios no suman.
CREATE FUNCTION "autoria_suma_cien"() RETURNS trigger AS $$
DECLARE
total numeric;
obra text;
BEGIN
obra := COALESCE(NEW."cancion_id", OLD."cancion_id");
SELECT COALESCE(SUM("porcentaje"), 0) INTO total
FROM "autoria" WHERE "cancion_id" = obra;
IF total <> 0 AND total <> 100 THEN
RAISE EXCEPTION 'El reparto de autoría de % suma %, y tiene que sumar 100', obra, total;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
--> statement-breakpoint
CREATE CONSTRAINT TRIGGER "autoria_suma_cien_trg"
AFTER INSERT OR UPDATE OR DELETE ON "autoria"
DEFERRABLE INITIALLY DEFERRED
FOR EACH ROW EXECUTE FUNCTION "autoria_suma_cien"();
--> statement-breakpoint
-- --- Un solo estilo principal ----------------------------------------------
--
-- Lo genera Drizzle a partir del `uniqueIndex(...).where(...)` del esquema, con
-- su índice parcial: solo entran las filas marcadas, así que la unicidad se
-- aplica entre ellas y las demás no estorban. Antes era un acuerdo escrito en
-- un comentario. Aquí no hay nada que añadir.
-- --- Una arista principal por género ----------------------------------------
CREATE UNIQUE INDEX "genero_origen_principal_unico" ON "genero_origen" ("genero_id")
WHERE "principal";
--> statement-breakpoint
-- Un género no deriva de sí mismo.
ALTER TABLE "genero_origen" ADD CONSTRAINT "genero_origen_no_reflexivo"
CHECK ("genero_id" <> "origen_id");
--> statement-breakpoint
-- --- Sin ciclos en el linaje de géneros -------------------------------------
--
-- Aquí sí hace falta un disparador: un grafo admite que A derive de B y B de A,
-- y una consulta recursiva sobre eso no termina. Con un catálogo de cien
-- géneros, comprobarlo al insertar no cuesta nada.
CREATE FUNCTION "genero_sin_ciclos"() RETURNS trigger AS $$
BEGIN
IF EXISTS (
WITH RECURSIVE "sube"("id") AS (
SELECT NEW."origen_id"
UNION
SELECT "o"."origen_id" FROM "genero_origen" "o"
JOIN "sube" ON "o"."genero_id" = "sube"."id"
)
SELECT 1 FROM "sube" WHERE "id" = NEW."genero_id"
) THEN
RAISE EXCEPTION 'Ese origen crearía un ciclo en el linaje de géneros';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
--> statement-breakpoint
CREATE TRIGGER "genero_sin_ciclos_trg"
BEFORE INSERT OR UPDATE ON "genero_origen"
FOR EACH ROW EXECUTE FUNCTION "genero_sin_ciclos"();
--> statement-breakpoint
-- --- La valoración va de uno a cinco ----------------------------------------
ALTER TABLE "valoracion" ADD CONSTRAINT "valoracion_de_uno_a_cinco"
CHECK ("puntuacion" BETWEEN 1 AND 5);
--> statement-breakpoint
-- --- El documento legal apunta a su versión vigente --------------------------
ALTER TABLE "documento_legal" ADD CONSTRAINT "documento_legal_version_vigente_fk"
FOREIGN KEY ("version_vigente_id") REFERENCES "version_legal"("id") ON DELETE set null;
--> statement-breakpoint
-- --- Una rectificativa apunta a la factura que rectifica ---------------------
ALTER TABLE "factura" ADD CONSTRAINT "factura_rectifica_fk"
FOREIGN KEY ("rectifica_a_id") REFERENCES "factura"("id");
--> statement-breakpoint
ALTER TABLE "factura" ADD CONSTRAINT "factura_rectificativa_apunta"
CHECK (("tipo" = 'rectificativa') = ("rectifica_a_id" IS NOT NULL));
--> statement-breakpoint
-- --- El metro enlaza con el glosario -----------------------------------------
ALTER TABLE "metro" ADD CONSTRAINT "metro_termino_fk"
FOREIGN KEY ("termino_id") REFERENCES "termino"("id") ON DELETE set null;

@ -0,0 +1,29 @@
ALTER TABLE "medio" ALTER COLUMN "creado_en" SET DEFAULT now();--> statement-breakpoint
ALTER TABLE "persona" ALTER COLUMN "creado_en" SET DEFAULT now();--> statement-breakpoint
ALTER TABLE "album" ALTER COLUMN "creado_en" SET DEFAULT now();--> statement-breakpoint
ALTER TABLE "cancion" ALTER COLUMN "creado_en" SET DEFAULT now();--> statement-breakpoint
ALTER TABLE "estilo" ALTER COLUMN "creado_en" SET DEFAULT now();--> statement-breakpoint
ALTER TABLE "capitulo" ALTER COLUMN "creado_en" SET DEFAULT now();--> statement-breakpoint
ALTER TABLE "genero" ALTER COLUMN "creado_en" SET DEFAULT now();--> statement-breakpoint
ALTER TABLE "prompt" ALTER COLUMN "creado_en" SET DEFAULT now();--> statement-breakpoint
ALTER TABLE "entrada" ALTER COLUMN "creado_en" SET DEFAULT now();--> statement-breakpoint
ALTER TABLE "pagina" ALTER COLUMN "creado_en" SET DEFAULT now();--> statement-breakpoint
ALTER TABLE "redireccion" ALTER COLUMN "creada_en" SET DEFAULT now();--> statement-breakpoint
ALTER TABLE "version_legal" ALTER COLUMN "vigente_desde" SET DEFAULT now();--> statement-breakpoint
ALTER TABLE "contador_factura" ALTER COLUMN "actualizado_en" SET DEFAULT now();--> statement-breakpoint
ALTER TABLE "datos_facturacion" ALTER COLUMN "creado_en" SET DEFAULT now();--> statement-breakpoint
ALTER TABLE "descarga" ALTER COLUMN "bajada_en" SET DEFAULT now();--> statement-breakpoint
ALTER TABLE "factura" ALTER COLUMN "fecha" SET DEFAULT now();--> statement-breakpoint
ALTER TABLE "codigo_acceso" ALTER COLUMN "creado_en" SET DEFAULT now();--> statement-breakpoint
ALTER TABLE "identidad" ALTER COLUMN "creada_en" SET DEFAULT now();--> statement-breakpoint
ALTER TABLE "sesion" ALTER COLUMN "creada_en" SET DEFAULT now();--> statement-breakpoint
ALTER TABLE "usuario" ALTER COLUMN "creado_en" SET DEFAULT now();--> statement-breakpoint
ALTER TABLE "carrito" ALTER COLUMN "creado_en" SET DEFAULT now();--> statement-breakpoint
ALTER TABLE "carrito" ALTER COLUMN "actualizado_en" SET DEFAULT now();--> statement-breakpoint
ALTER TABLE "carrito_item" ALTER COLUMN "anadido_en" SET DEFAULT now();--> statement-breakpoint
ALTER TABLE "compra" ALTER COLUMN "comprado_en" SET DEFAULT now();--> statement-breakpoint
ALTER TABLE "pedido" ALTER COLUMN "creado_en" SET DEFAULT now();--> statement-breakpoint
ALTER TABLE "favorito" ALTER COLUMN "marcado_en" SET DEFAULT now();--> statement-breakpoint
ALTER TABLE "reproduccion" ALTER COLUMN "creado_en" SET DEFAULT now();--> statement-breakpoint
ALTER TABLE "valoracion" ALTER COLUMN "creada_en" SET DEFAULT now();--> statement-breakpoint
ALTER TABLE "valoracion" ALTER COLUMN "actualizada_en" SET DEFAULT now();

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

@ -15,6 +15,20 @@
"when": 1788820606303,
"tag": "0001_identidades",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1788876298322,
"tag": "0002_catalogo_en_la_base",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1788878096657,
"tag": "0003_valores_por_defecto",
"breakpoints": true
}
]
}

@ -21,7 +21,9 @@
"db:studio": "drizzle-kit studio",
"medios": "node scripts/medios.mjs",
"predev": "node scripts/medios.mjs",
"prebuild": "node scripts/medios.mjs"
"prebuild": "node scripts/medios.mjs",
"db:guardas": "node --env-file=.env scripts/db/comprobar-guardas.mjs",
"db:probar": "node --env-file=.env scripts/db/probar-migracion.mjs"
},
"devDependencies": {
"@eslint/js": "^10.0.1",

@ -0,0 +1,223 @@
/**
* Comprueba que las guardas del esquema hacen lo que dicen.
*
* Que una restricción exista en `pg_constraint` no significa que impida nada:
* se prueba intentando lo que tiene que rechazar. Todo va dentro de una
* transacción que se deshace, así que no deja rastro.
*/
import fs from 'node:fs';
import pg from 'pg';
const DIARIO = '.diario-guardas.txt';
fs.writeFileSync(DIARIO, '');
const apuntar = (l) => fs.appendFileSync(DIARIO, l + '\n');
const c = new pg.Client({ connectionString: process.env.DATABASE_URL, query_timeout: 15_000 });
await c.connect();
await c.query('BEGIN');
const resumen = await c.query(`
select
(select count(*)::int from information_schema.tables
where table_schema = 'public' and table_type = 'BASE TABLE') as tablas,
(select count(*)::int from information_schema.views
where table_schema = 'public') as vistas,
(select count(*)::int from information_schema.table_constraints
where constraint_schema = 'public' and constraint_type = 'FOREIGN KEY') as fks,
(select count(*)::int from pg_trigger where not tgisinternal) as disparadores`);
const r = resumen.rows[0];
apuntar(
`tablas ${r.tablas} · vistas ${r.vistas} · claves foráneas ${r.fks} · disparadores ${r.disparadores}\n`
);
/** Prueba que algo se rechaza. Si pasa, es que la guarda no guarda. */
async function rechaza(nombre, sql, valores = []) {
await c.query('SAVEPOINT prueba');
try {
await c.query(sql, valores);
await c.query('ROLLBACK TO SAVEPOINT prueba');
apuntar(` ✗ ${nombre} — SE PERMITIÓ, y no debería`);
return false;
} catch (fallo) {
await c.query('ROLLBACK TO SAVEPOINT prueba');
const motivo = fallo.message.split('\n')[0].slice(0, 70);
apuntar(` ✓ ${nombre} — rechazado: ${motivo}`);
return true;
}
}
/**
* Como `rechaza`, pero forzando la comprobación de las restricciones
* aplazadas.
*
* El disparador del reparto de autoría es `DEFERRABLE INITIALLY DEFERRED`, y
* con razón: al insertar tres autores, los estados intermedios no suman cien y
* comprobarlo fila a fila haría imposible cualquier inserción. Pero eso
* significa que solo salta al cerrar la transacción, y una prueba que nunca
* cierra no lo despierta: la primera versión de esto daba por buena una guarda
* que no había llegado a ejecutarse.
*/
async function rechazaAlCerrar(nombre, preparar, sql) {
/*
* Conexión propia y transacción propia. Dentro de un savepoint,
* `SET CONSTRAINTS ALL IMMEDIATE` no despertaba los eventos aplazados y la
* prueba daba por bueno lo que el disparador sí rechaza —comprobado
* interrogándolo aparte—. Una guarda que solo se puede comprobar al cerrar
* necesita una transacción que se pueda cerrar.
*/
const otro = new pg.Client({ connectionString: process.env.DATABASE_URL, query_timeout: 15_000 });
await otro.connect();
await otro.query('BEGIN');
try {
for (const linea of preparar) await otro.query(linea);
await otro.query(sql);
await otro.query('SET CONSTRAINTS ALL IMMEDIATE');
apuntar(` ✗ ${nombre} — SE PERMITIÓ, y no debería`);
return false;
} catch (fallo) {
apuntar(
` ✓ ${nombre} — rechazado: ${fallo.message.split(String.fromCharCode(10))[0].slice(0, 70)}`
);
return true;
} finally {
await otro.query('ROLLBACK').catch(() => {});
await otro.end();
}
}
/** Y que lo legítimo sí pasa: una guarda que lo bloquea todo tampoco sirve. */
async function permite(nombre, sql, valores = []) {
await c.query('SAVEPOINT prueba');
try {
await c.query(sql, valores);
await c.query('RELEASE SAVEPOINT prueba');
apuntar(` ✓ ${nombre} — permitido, como debe`);
return true;
} catch (fallo) {
await c.query('ROLLBACK TO SAVEPOINT prueba');
apuntar(` ✗ ${nombre} — SE RECHAZÓ: ${fallo.message.split('\n')[0].slice(0, 70)}`);
return false;
}
}
/* --- Datos mínimos para poder probar ---------------------------------- */
await c.query(`insert into pais (codigo, nombre) values ('CU', 'Cuba')`);
await c.query(
`insert into estilo (id, slug, nombre, principal) values ('e1', 'baladas', 'Baladas', true)`
);
await c.query(
`insert into cancion (id, slug, titulo, estilo_id) values ('c1', 'como-si-nada', 'Como si nada', 'e1')`
);
await c.query(
`insert into cancion (id, slug, titulo, estilo_id, version_de_id, version_nombre)
values ('c2', 'como-si-nada-maqueta', 'Como si nada', 'e1', 'c1', 'Maqueta 2018')`
);
await c.query(`insert into persona (id, slug, nombre) values ('p1', 'juan', 'Juan')`);
await c.query(`insert into genero (id, slug, nombre) values ('g1', 'bolero', 'Bolero')`);
await c.query(`insert into genero (id, slug, nombre) values ('g2', 'son', 'Son cubano')`);
await c.query(`insert into genero (id, slug, nombre) values ('g3', 'bolero-son', 'Bolero-son')`);
apuntar('LAS VERSIONES');
await permite(
'una canción puede tener versiones',
`insert into cancion (id, slug, titulo, estilo_id, version_de_id)
values ('c3', 'orquestal', 'Como si nada', 'e1', 'c1')`
);
await rechaza(
'una versión no puede tener versiones',
`insert into cancion (id, slug, titulo, estilo_id, version_de_id)
values ('c4', 'x', 'X', 'e1', 'c2')`
);
await rechaza(
'una canción no es versión de sí misma',
`update cancion set version_de_id = 'c1' where id = 'c1'`
);
apuntar('\nLA VISTA DE PRINCIPALES');
const vista = await c.query('select count(*)::int n from cancion_principal');
const todas = await c.query('select count(*)::int n from cancion');
const principales = await c.query(
'select count(*)::int n from cancion where version_de_id is null'
);
const bien = vista.rows[0].n === principales.rows[0].n && vista.rows[0].n < todas.rows[0].n;
apuntar(
` ${bien ? '✓' : '✗'} cancion tiene ${todas.rows[0].n} filas y cancion_principal ${vista.rows[0].n}, que son las principales`
);
apuntar('\nEL LINAJE DE GÉNEROS');
await permite(
'el bolero-son deriva de dos géneros',
`insert into genero_origen (genero_id, origen_id, tipo, principal) values
('g3', 'g1', 'fusiona', true), ('g3', 'g2', 'fusiona', false)`
);
await rechaza(
'un género no deriva de sí mismo',
`insert into genero_origen (genero_id, origen_id, tipo) values ('g1', 'g1', 'deriva')`
);
await rechaza(
'no se admiten ciclos en el linaje',
`insert into genero_origen (genero_id, origen_id, tipo) values ('g1', 'g3', 'deriva')`
);
await rechaza(
'solo una arista principal por género',
`update genero_origen set principal = true where genero_id = 'g3' and origen_id = 'g2'`
);
apuntar('\nEL REPARTO DE AUTORÍA');
await permite(
'un reparto que suma cien pasa',
`insert into autoria (cancion_id, persona_id, rol, porcentaje)
values ('c1', 'p1', 'letra', 60), ('c1', 'p1', 'musica', 40)`
);
/*
* Sobre otra canción y con un rol libre, para que el rechazo venga de la suma y
* no de la clave primaria. La primera versión de esta prueba pasaba por el
* motivo equivocado —clave duplicada— y dejaba el disparador sin comprobar, que
* es peor que no tener prueba.
*/
await rechazaAlCerrar(
'un reparto que suma noventa, no',
[
`insert into estilo (id, slug, nombre) values ('z1', 'z', 'Z')`,
`insert into cancion (id, slug, titulo, estilo_id) values ('z2', 'z', 'Z', 'z1')`,
`insert into persona (id, slug, nombre) values ('z3', 'z', 'Z')`
],
`insert into autoria (cancion_id, persona_id, rol, porcentaje)
values ('z2', 'z3', 'letra', 90)`
);
await permite(
'y el de c3 sí, si llega a cien',
`insert into autoria (cancion_id, persona_id, rol, porcentaje)
values ('c3', 'p1', 'letra', 90), ('c3', 'p1', 'musica', 10)`
);
apuntar('\nEL ESTILO PRINCIPAL');
await rechaza(
'no puede haber dos estilos principales',
`insert into estilo (id, slug, nombre, principal) values ('e2', 'rock', 'Rock', true)`
);
await permite(
'los no principales, los que hagan falta',
`insert into estilo (id, slug, nombre) values ('e3', 'clasica', 'Clásica')`
);
apuntar('\nLAS FACTURAS');
await rechaza(
'una rectificativa tiene que decir a cuál rectifica',
`insert into factura (id, pedido_id, serie, numero, tipo, base_centimos, iva_tipo,
iva_cuota_centimos, total_centimos, datos_congelados, pais_codigo)
values ('f1', 'inexistente', 'A', 1, 'rectificativa', 100, 21, 21, 121, '{}', 'CU')`
);
apuntar('\nLA VALORACIÓN');
await rechaza(
'no se puede puntuar con un seis',
`insert into valoracion (usuario_id, cancion_slug, puntuacion)
values ('inexistente', 'x', 6)`
);
await c.query('ROLLBACK');
apuntar('\n(todo deshecho: la base queda como estaba)');
await c.end();

@ -0,0 +1,74 @@
/**
* Ejecuta la migración en una transacción y la deshace.
*
* `drizzle-kit migrate` se queda colgado sin decir por qué, así que hay que
* preguntarle directamente a Postgres.
*
* El progreso se escribe a disco de forma SÍNCRONA. La salida normal de Node
* se acumula en un buffer cuando va a un pipe o a un archivo, y si el proceso
* se mata por tiempo ese buffer se pierde entero: es justo lo que hacía que
* pareciera que no llegaba a arrancar.
*/
import fs from 'node:fs';
import pg from 'pg';
const ARCHIVO = 'drizzle/0002_catalogo_en_la_base.sql';
const DIARIO = '.diario-migracion.txt';
const APLICAR = process.argv.includes('--aplicar');
fs.writeFileSync(DIARIO, '');
const apuntar = (linea) => fs.appendFileSync(DIARIO, linea + '\n');
const sql = fs.readFileSync(ARCHIVO, 'utf8');
const sentencias = sql
.split('--> statement-breakpoint')
.map((s) => s.trim())
// Sin expresión regular. `(--[^\n]*\n?)+` tarda catorce segundos en un
// comentario de quinientos caracteres: cuando no encaja, puede repartir el
// mismo texto de exponencialmente muchas formas antes de rendirse. Línea a
// línea es lineal, y además se lee.
.filter((s) => s && s.split('\n').some((l) => l.trim() && !l.trim().startsWith('--')));
apuntar(`${sentencias.length} sentencias`);
const cliente = new pg.Client({
connectionString: process.env.DATABASE_URL,
// Que no se quede esperando eternamente si el servidor no contesta.
connectionTimeoutMillis: 10_000,
query_timeout: 30_000
});
apuntar('conectando...');
await cliente.connect();
apuntar('conectado');
await cliente.query('BEGIN');
// Que falle rápido en vez de colgarse esperando un bloqueo que no llega.
await cliente.query("SET LOCAL lock_timeout = '5s'");
await cliente.query("SET LOCAL statement_timeout = '30s'");
apuntar('transacción abierta');
let hechas = 0;
let rota = null;
for (const [i, sentencia] of sentencias.entries()) {
const resumen = sentencia.split('\n').find((l) => l.trim() && !l.trim().startsWith('--')) ?? '';
apuntar(`[${i + 1}/${sentencias.length}] ${resumen.trim().slice(0, 80)}`);
try {
await cliente.query(sentencia);
hechas++;
} catch (fallo) {
rota = { i, sentencia, fallo };
apuntar(`\n FALLA: ${fallo.message}`);
if (fallo.hint) apuntar(` pista: ${fallo.hint}`);
if (fallo.position) apuntar(` posición: ${fallo.position}`);
apuntar('\n' + sentencia.slice(0, 600));
break;
}
}
const completa = !rota && hechas === sentencias.length;
await cliente.query(APLICAR && completa ? 'COMMIT' : 'ROLLBACK');
apuntar(completa ? `\nLas ${hechas} sentencias pasan.` : `\nParó en la ${hechas + 1}.`);
apuntar(APLICAR && completa ? 'APLICADA.' : '(deshecha)');
await cliente.end();

@ -1,347 +0,0 @@
/**
* Esquema de la base de datos.
*
* Aqui no vive el catalogo: los estilos, albumes y canciones siguen siendo
* archivos Markdown del repositorio. La base de datos guarda solo lo que no
* puede estar en el codigo porque lo generan las personas que visitan la web:
* cuentas, carritos, pedidos y compras.
*
* Las canciones se referencian por su `slug`, no por una clave foranea: la
* fuente de verdad del catalogo es el contenido, y una compra tiene que
* sobrevivir aunque un tema se renombre o se retire de la web.
*/
import { relations } from 'drizzle-orm';
import {
bigint,
index,
integer,
pgTable,
primaryKey,
text,
timestamp,
uniqueIndex
} from 'drizzle-orm/pg-core';
const id = () =>
text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID());
/**
* Marca de tiempo obligatoria que se rellena sola al insertar.
*
* Con zona horaria siempre: el servidor puede estar en una y quien mira en
* otra, y un instante sin zona es un instante que no se sabe cual es. Y en
* modo `date` porque el codigo trabaja con `Date`; el modo por defecto de
* Postgres en Drizzle devuelve cadenas.
*/
const ahora = (columna: string) =>
timestamp(columna, { withTimezone: true, mode: 'date' })
.notNull()
.$defaultFn(() => new Date());
/** Instante opcional, con el mismo criterio. */
const instante = (columna: string) => timestamp(columna, { withTimezone: true, mode: 'date' });
/* -------------------------------------------------------------------------
* Cuentas
* ---------------------------------------------------------------------- */
export const usuario = pgTable('usuario', {
id: id(),
/** Siempre normalizado en minusculas y sin espacios. */
email: text('email').notNull().unique(),
nombre: text('nombre'),
creadoEn: ahora('creado_en'),
ultimoAccesoEn: instante('ultimo_acceso_en')
});
/**
* Una cuenta de un proveedor externo enlazada a un usuario de aqui.
*
* El par (proveedor, sujeto) es la clave: `sujeto` es el identificador estable
* que da el proveedor —el `sub` de Google, el `id` de Facebook—, y NO el correo.
* Un correo se puede cambiar, y en algunos proveedores se puede reutilizar
* cuando se libera una cuenta; el identificador no. Enlazar por correo dejaria
* la puerta abierta a entrar en la cuenta de otro el dia que un proveedor
* reasignara una direccion.
*
* Aqui NO se guarda ningun token del proveedor. Se usa una vez, en el momento
* de entrar, para preguntar quien es, y se tira: no hacemos nada mas con esa
* cuenta —ni leer contactos, ni publicar— asi que guardarlo solo anadiria algo
* mas que puede filtrarse.
*/
export const identidad = pgTable(
'identidad',
{
id: id(),
proveedor: text('proveedor', { enum: ['google', 'facebook'] }).notNull(),
/** Identificador de la persona en ese proveedor. Estable y opaco. */
sujeto: text('sujeto').notNull(),
usuarioId: text('usuario_id')
.notNull()
.references(() => usuario.id, { onDelete: 'cascade' }),
/** El correo que dio el proveedor al enlazar. Informativo. */
email: text('email'),
creadaEn: ahora('creada_en'),
ultimoAccesoEn: instante('ultimo_acceso_en')
},
(t) => [
// Una cuenta de Google no puede apuntar a dos usuarios de aqui.
uniqueIndex('identidad_proveedor_sujeto').on(t.proveedor, t.sujeto),
index('identidad_usuario').on(t.usuarioId)
]
);
/**
* Codigo de un solo uso enviado por correo.
*
* Se guarda el hash, no el codigo: quien lea la base de datos no debe poder
* entrar en una cuenta ajena. Es la misma razon por la que las sesiones
* guardan el hash del token.
*/
export const codigoAcceso = pgTable(
'codigo_acceso',
{
id: id(),
email: text('email').notNull(),
hash: text('hash').notNull(),
expiraEn: instante('expira_en').notNull(),
intentos: integer('intentos').notNull().default(0),
usadoEn: instante('usado_en'),
creadoEn: ahora('creado_en')
},
(t) => [index('codigo_acceso_email').on(t.email)]
);
export const sesion = pgTable(
'sesion',
{
/** Hash del token que viaja en la cookie. */
id: text('id').primaryKey(),
usuarioId: text('usuario_id')
.notNull()
.references(() => usuario.id, { onDelete: 'cascade' }),
expiraEn: instante('expira_en').notNull(),
creadaEn: ahora('creada_en')
},
(t) => [index('sesion_usuario').on(t.usuarioId)]
);
/**
* Contadores del limitador de envios.
*
* Esta en la base de datos y no en memoria a proposito: un limite que se borra
* al reiniciar el proceso no frena a quien insiste, y sobre todo no protege a
* un tercero. Aqui el limite existe para que nadie pueda usar el formulario de
* acceso para bombardear el buzon de otra persona.
*
* `expiraEn` va en milisegundos y no como fecha porque las ventanas son
* aritmetica pura, y algunas duran menos de un minuto.
*
* Y va en `bigint`, no en `integer`: en SQLite un entero son ocho bytes, pero
* en Postgres son cuatro y el maximo se queda en 2.147.483.647. Un instante en
* milisegundos ronda el billon y siete, asi que en `integer` desbordaria y el
* limitador dejaria de limitar.
*/
export const limite = pgTable(
'limite',
{
/** Que se esta limitando: "acceso:ip:1.2.3.4", "acceso:correo:..." */
clave: text('clave').primaryKey(),
intentos: integer('intentos').notNull(),
expiraEn: bigint('expira_en', { mode: 'number' }).notNull()
},
(t) => [index('limite_expira').on(t.expiraEn)]
);
/* -------------------------------------------------------------------------
* Carrito
* ---------------------------------------------------------------------- */
/**
* Un carrito existe antes que la cuenta: se puede ir llenando sin haber
* entrado, identificado por una cookie, y al iniciar sesion se fusiona con el
* del usuario.
*/
export const carrito = pgTable(
'carrito',
{
id: id(),
usuarioId: text('usuario_id').references(() => usuario.id, { onDelete: 'cascade' }),
creadoEn: ahora('creado_en'),
actualizadoEn: ahora('actualizado_en')
},
(t) => [index('carrito_usuario').on(t.usuarioId)]
);
export const carritoItem = pgTable(
'carrito_item',
{
carritoId: text('carrito_id')
.notNull()
.references(() => carrito.id, { onDelete: 'cascade' }),
cancionSlug: text('cancion_slug').notNull(),
anadidoEn: ahora('anadido_en')
},
(t) => [primaryKey({ columns: [t.carritoId, t.cancionSlug] })]
);
/* -------------------------------------------------------------------------
* Pedidos y compras
* ---------------------------------------------------------------------- */
export const pedido = pgTable(
'pedido',
{
id: id(),
usuarioId: text('usuario_id')
.notNull()
.references(() => usuario.id, { onDelete: 'cascade' }),
/** Identificador de la sesion de pago de Stripe. */
sesionPagoId: text('sesion_pago_id').notNull().unique(),
estado: text('estado', { enum: ['pendiente', 'pagado', 'cancelado'] })
.notNull()
.default('pendiente'),
/** Numero de packs de 5 canciones. */
packs: integer('packs').notNull(),
/** Importe en centimos, congelado en el momento del pedido. */
totalCentimos: integer('total_centimos').notNull(),
moneda: text('moneda').notNull().default('eur'),
creadoEn: ahora('creado_en'),
pagadoEn: instante('pagado_en')
},
(t) => [index('pedido_usuario').on(t.usuarioId)]
);
export const pedidoItem = pgTable(
'pedido_item',
{
pedidoId: text('pedido_id')
.notNull()
.references(() => pedido.id, { onDelete: 'cascade' }),
cancionSlug: text('cancion_slug').notNull(),
/** Titulo en el momento de la compra, para que la factura no cambie. */
titulo: text('titulo').notNull()
},
(t) => [primaryKey({ columns: [t.pedidoId, t.cancionSlug] })]
);
/**
* Lo que da derecho a descargar. Es una tabla aparte de `pedidoItem` a
* proposito: responde a "puede esta persona descargar este tema" en una sola
* consulta, sin recorrer pedidos.
*/
export const compra = pgTable(
'compra',
{
usuarioId: text('usuario_id')
.notNull()
.references(() => usuario.id, { onDelete: 'cascade' }),
cancionSlug: text('cancion_slug').notNull(),
pedidoId: text('pedido_id')
.notNull()
.references(() => pedido.id, { onDelete: 'cascade' }),
compradoEn: ahora('comprado_en')
},
(t) => [primaryKey({ columns: [t.usuarioId, t.cancionSlug] })]
);
/* -------------------------------------------------------------------------
* Favoritos
* ---------------------------------------------------------------------- */
/**
* Los temas que una cuenta ha marcado.
*
* Es una lista personal, no una compra ni un voto: se puede marcar cualquier
* tema, este a la venta o no, y no dice nada publico sobre el. Por eso no lleva
* ningun contador agregado.
*
* La clave primaria es el par cuenta-tema, asi que marcar dos veces lo mismo no
* duplica nada. El indice por cuenta es para pintar la lista, que es la
* consulta que se hace de verdad.
*/
export const favorito = pgTable(
'favorito',
{
usuarioId: text('usuario_id')
.notNull()
.references(() => usuario.id, { onDelete: 'cascade' }),
cancionSlug: text('cancion_slug').notNull(),
marcadoEn: ahora('marcado_en')
},
(t) => [
primaryKey({ columns: [t.usuarioId, t.cancionSlug] }),
index('idx_favorito_usuario').on(t.usuarioId)
]
);
/* -------------------------------------------------------------------------
* Valoraciones
* ---------------------------------------------------------------------- */
/**
* Lo que una cuenta le pone a un tema, de 1 a 5.
*
* La clave primaria es el par cuenta-tema, asi que nadie puede votar dos veces
* lo mismo: volver a votar reemplaza el voto anterior. Eso lo garantiza la base
* de datos, no el codigo de la accion, que es donde tiene que estar.
*
* El indice por `cancion_slug` es para la media: se calcula por tema, y sin el
* habria que recorrer la tabla entera en cada ficha.
*/
export const valoracion = pgTable(
'valoracion',
{
usuarioId: text('usuario_id')
.notNull()
.references(() => usuario.id, { onDelete: 'cascade' }),
cancionSlug: text('cancion_slug').notNull(),
/** De 1 a 5. El rango lo comprueba tambien la aplicacion al guardar. */
puntuacion: integer('puntuacion').notNull(),
creadaEn: ahora('creada_en'),
actualizadaEn: ahora('actualizada_en')
},
(t) => [
primaryKey({ columns: [t.usuarioId, t.cancionSlug] }),
index('idx_valoracion_cancion').on(t.cancionSlug)
]
);
/* -------------------------------------------------------------------------
* Relaciones
* ---------------------------------------------------------------------- */
export const relacionesUsuario = relations(usuario, ({ many }) => ({
sesiones: many(sesion),
pedidos: many(pedido),
compras: many(compra),
valoraciones: many(valoracion),
favoritos: many(favorito)
}));
export const relacionesCarrito = relations(carrito, ({ many, one }) => ({
items: many(carritoItem),
usuario: one(usuario, { fields: [carrito.usuarioId], references: [usuario.id] })
}));
export const relacionesCarritoItem = relations(carritoItem, ({ one }) => ({
carrito: one(carrito, { fields: [carritoItem.carritoId], references: [carrito.id] })
}));
export const relacionesPedido = relations(pedido, ({ many, one }) => ({
items: many(pedidoItem),
usuario: one(usuario, { fields: [pedido.usuarioId], references: [usuario.id] })
}));
export const relacionesPedidoItem = relations(pedidoItem, ({ one }) => ({
pedido: one(pedido, { fields: [pedidoItem.pedidoId], references: [pedido.id] })
}));
export type Usuario = typeof usuario.$inferSelect;
export type Pedido = typeof pedido.$inferSelect;
export type Compra = typeof compra.$inferSelect;
export type Valoracion = typeof valoracion.$inferSelect;
export type Favorito = typeof favorito.$inferSelect;

@ -0,0 +1,118 @@
/**
* Las tablas de las que cuelga todo lo demás: países, archivos y personas.
*
* Van juntas y primero porque no dependen de nadie y casi todo depende de
* ellas. Un país lo necesita un género para decir de dónde es y una factura
* para saber qué IVA lleva; un archivo lo necesitan una canción, una portada y
* el sonido de muestra de un instrumento; y una persona firma una canción, es
* figura de un género o ambas cosas.
*/
import { boolean, char, index, integer, pgTable, text } from 'drizzle-orm/pg-core';
import { ahora, estado, id, instante } from './comun';
/**
* Países, por su código ISO 3166-1 de dos letras.
*
* El código ES la clave: es estable, universal y ya lo conoce todo el mundo,
* así que no hace falta inventar otro. Es la única tabla del modelo donde el
* identificador legible sirve de clave primaria, y es porque este no lo
* elegimos nosotros ni lo vamos a cambiar.
*/
export const pais = pgTable('pais', {
codigo: char('codigo', { length: 2 }).primaryKey(),
nombre: text('nombre').notNull(),
/** «cubano». Se usa al redactar, no solo al listar. */
gentilicio: text('gentilicio'),
/** caribe · andina · cono-sur · mesoamerica · iberica · otra */
region: text('region')
});
/**
* Un archivo: audio, imagen, muestra o documento.
*
* Antes de esto, un audio era una cadena de texto y una portada una ruta bajo
* `/static`. Nada garantizaba que el archivo existiera, nada sabía cuánto
* pesaba, y para que la administración pueda subir audio hace falta que un
* archivo sea una fila con la que se pueda hablar.
*/
export const medio = pgTable(
'medio',
{
id: id(),
/** audio · imagen · muestra · documento */
clase: text('clase', { enum: ['audio', 'imagen', 'muestra', 'documento'] }).notNull(),
/** Ruta dentro del almacén. Sin barra inicial: no es una URL. */
clave: text('clave').notNull().unique(),
/**
* Si se puede pedir por URL sin más.
*
* Los fragmentos de muestra sí; el audio completo no, nunca. Hasta ahora
* eso lo garantizaba la carpeta en la que estaba el archivo —un directorio
* privado fuera de `build/`—, que es una garantía que depende de que nadie
* mueva nada. Aquí es un dato, y el que sirve los archivos lo comprueba.
*
* Con los medios en su propio dominio esto se vuelve más importante, no
* menos: allí la cookie de sesión no viaja, así que lo privado se entrega
* con URL firmada y lo público directo.
*/
publico: boolean('publico').notNull().default(false),
mime: text('mime').notNull(),
bytes: integer('bytes').notNull(),
/** Medida al subir con ffprobe, no escrita a mano. */
duracionS: integer('duracion_s'),
/** De las imágenes: evita el salto de maquetación al cargar. */
ancho: integer('ancho'),
alto: integer('alto'),
/**
* Huella del contenido. Detecta el archivo repetido antes de guardarlo dos
* veces, y sirve para comprobar que lo que hay en el disco sigue siendo lo
* que se subió.
*/
sha256: text('sha256').notNull(),
/** Descripción de la imagen, donde tiene que estar: con la imagen. */
alt: text('alt'),
creadoEn: ahora('creado_en')
},
(t) => [index('medio_clase').on(t.clase), index('medio_sha').on(t.sha256)]
);
/**
* Quien escribe, compone, canta, produce o es figura de un género.
*
* Una sola tabla para todas esas cosas, y no una de autores y otra de
* letristas históricos. Un letrista es una persona; con dos tablas, la misma
* acabaría existiendo dos veces con distinta grafía el día que se colabore con
* alguien que además sea figura de un género.
*/
export const persona = pgTable(
'persona',
{
id: id(),
slug: text('slug').notNull().unique(),
/** Como se acredita. */
nombre: text('nombre').notNull(),
/** Si difiere del anterior. Es el que va en el reparto de derechos. */
nombreLegal: text('nombre_legal'),
/** Código de autor en la entidad de gestión. */
ipi: text('ipi'),
/* --- Biografía: para las figuras de un género ----------------------- */
nacimiento: integer('nacimiento'),
defuncion: integer('defuncion'),
paisCodigo: char('pais_codigo', { length: 2 }).references(() => pais.codigo),
retratoId: text('retrato_id').references(() => medio.id, { onDelete: 'set null' }),
semblanza: text('semblanza'),
estado: estado(),
creadoEn: ahora('creado_en'),
actualizadoEn: instante('actualizado_en')
},
(t) => [index('persona_nombre').on(t.nombre), index('persona_pais').on(t.paisCodigo)]
);

@ -0,0 +1,353 @@
/**
* El catálogo: estilos, discos, canciones y todo lo que cuelga de una canción.
*
* Hasta ahora esto vivía en `src/content/**` como Markdown con frontmatter. No
* eran documentos: una canción tenía veinte campos estructurados en YAML y la
* letra entera también, y el cuerpo del Markdown eran cuatro líneas de
* comentario. Eran registros disfrazados de documento.
*/
import { sql } from 'drizzle-orm';
import {
boolean,
char,
index,
integer,
numeric,
pgTable,
primaryKey,
text,
uniqueIndex
} from 'drizzle-orm/pg-core';
import { ahora, estado, id, instante } from './comun';
import { medio, pais, persona } from './base';
/**
* Una sección del catálogo: baladas románticas, clásica, rock, pop melódico.
*
* NO es lo mismo que un género del taller. Aquello —bolero, tango, cumbia— es
* música hispanoamericana descrita para poder escribirla; esto es cómo el
* titular ordena su propia obra. Compartían la palabra «estilo» y por eso
* compartían nombre en el código, que es de donde venía la confusión.
*/
export const estilo = pgTable(
'estilo',
{
id: id(),
slug: text('slug').notNull().unique(),
nombre: text('nombre').notNull(),
resumen: text('resumen').notNull().default(''),
/** Menor, más arriba. */
orden: integer('orden').notNull().default(0),
/** El estilo principal del artista. Solo puede haber uno: ver el índice. */
principal: boolean('principal').notNull().default(false),
imagenId: text('imagen_id').references(() => medio.id, { onDelete: 'set null' }),
estado: estado(),
creadoEn: ahora('creado_en'),
actualizadoEn: instante('actualizado_en')
},
(t) => [
/*
* Índice parcial: solo entran las filas con `principal` a cierto, así que
* la unicidad se aplica únicamente entre ellas y las demás no estorban.
* Hasta ahora, que solo hubiera un estilo principal era un acuerdo escrito
* en un comentario; ahora es la base la que no deja poner dos.
*/
uniqueIndex('estilo_principal_unico')
.on(t.principal)
.where(sql`${t.principal}`),
index('estilo_orden').on(t.orden)
]
);
/** Formatos en los que existe una edición. */
export const FORMATOS = ['digital', 'cd', 'vinilo', 'casete'] as const;
export const album = pgTable(
'album',
{
id: id(),
slug: text('slug').notNull().unique(),
titulo: text('titulo').notNull(),
estiloId: text('estilo_id')
.notNull()
.references(() => estilo.id),
tipo: text('tipo', { enum: ['album', 'ep', 'single', 'coleccion'] })
.notNull()
.default('album'),
/** Fecha de publicación, ISO `YYYY-MM-DD`. */
fecha: text('fecha').notNull(),
portadaId: text('portada_id').references(() => medio.id, { onDelete: 'set null' }),
sello: text('sello'),
resumen: text('resumen').notNull().default(''),
destacado: boolean('destacado').notNull().default(false),
/* --- Datos de edición: lo que iría en la contraportada --------------- */
numeroCatalogo: text('numero_catalogo'),
/** Se pintan, no se filtran: array y no tabla puente. */
formatos: text('formatos').array().$type<(typeof FORMATOS)[number][]>(),
paisEdicionCodigo: char('pais_edicion_codigo', { length: 2 }).references(() => pais.codigo),
estado: estado(),
creadoEn: ahora('creado_en'),
actualizadoEn: instante('actualizado_en')
},
(t) => [index('album_estilo').on(t.estiloId), index('album_fecha').on(t.fecha)]
);
/**
* Un tema. Y también cada una de sus versiones.
*
* **Una versión es una fila de esta misma tabla**, con `versionDeId` apuntando
* a la principal. No es una tabla aparte, y no es un `obra`/`grabacion` partido
* en dos: en ese reparto el estilo y las etiquetas colgarían de la obra, y una
* versión no podría tener los suyos —una balada con arreglo de bolero no
* cabría—. Aquí cualquier campo puede cambiar de una versión a otra.
*
* Eso arregla de paso algo que estaba mal: **el disco es de la fila**. Antes
* `album` y `numero` colgaban del tema, así que la «Maqueta 2018» era el corte
* 1 del álbum igual que la versión de estudio, y es falso.
*
* Al crear una versión sus campos se **copian** de la principal; no se heredan
* al leer. Un nulo heredado nunca se sabe si significa «no lo sé» o «lo mismo
* de arriba». Y aquí copiar es además lo correcto: la maqueta de 2018 no tiene
* la misma letra, le falta el puente.
*/
export const cancion = pgTable(
'cancion',
{
id: id(),
slug: text('slug').notNull().unique(),
titulo: text('titulo').notNull(),
/**
* Nula en las principales. Una versión apunta aquí a su tema de
* referencia.
*
* La clave foránea se declara en la migración y no aquí: Drizzle no deja
* referenciar una tabla dentro de su propia definición. Allí va también la
* guarda que impide la versión de una versión y los ciclos.
*
* Ojo al consultar: casi todo lo que lista o cuenta canciones quiere solo
* las principales. Para eso está la vista `cancion_principal`; pedir
* versiones tiene que ser lo que se escribe a propósito.
*/
versionDeId: text('version_de_id'),
/** Nombre del arreglo: «Versión orquestal», «Maqueta 2018». */
versionNombre: text('version_nombre'),
/** Orden entre las versiones de un mismo tema. */
versionOrden: integer('version_orden').notNull().default(0),
estiloId: text('estilo_id')
.notNull()
.references(() => estilo.id),
albumId: text('album_id').references(() => album.id, { onDelete: 'set null' }),
numero: integer('numero'),
audioId: text('audio_id').references(() => medio.id, { onDelete: 'set null' }),
muestraId: text('muestra_id').references(() => medio.id, { onDelete: 'set null' }),
duracionS: integer('duracion_s'),
ano: integer('ano'),
/** Con sus saltos de línea: es verso, no prosa. */
letra: text('letra'),
/** ISO 639. Decide si la ficha enseña una columna o dos. */
idioma: text('idioma').notNull().default('es'),
/** El cuerpo del Markdown de antes. */
nota: text('nota'),
inspiracion: text('inspiracion'),
instrumental: boolean('instrumental').notNull().default(false),
destacada: boolean('destacada').notNull().default(false),
portadaId: text('portada_id').references(() => medio.id, { onDelete: 'set null' }),
/** Solo en las principales: comprar una entrega todas sus versiones. */
alaVenta: boolean('ala_venta').notNull().default(false),
estado: estado(),
creadoEn: ahora('creado_en'),
actualizadoEn: instante('actualizado_en')
},
(t) => [
index('cancion_estilo').on(t.estiloId),
index('cancion_album').on(t.albumId),
index('cancion_version_de').on(t.versionDeId),
index('cancion_destacada').on(t.destacada)
]
);
/** La letra en otro idioma. Antes solo cabía una, al español. */
export const traduccion = pgTable(
'traduccion',
{
cancionId: text('cancion_id')
.notNull()
.references(() => cancion.id, { onDelete: 'cascade' }),
idioma: text('idioma').notNull(),
texto: text('texto').notNull()
},
(t) => [primaryKey({ columns: [t.cancionId, t.idioma] })]
);
/** Quién hizo qué en un tema. El orden en que se acredita importa. */
export const cancionPersona = pgTable(
'cancion_persona',
{
cancionId: text('cancion_id')
.notNull()
.references(() => cancion.id, { onDelete: 'cascade' }),
personaId: text('persona_id')
.notNull()
.references(() => persona.id),
rol: text('rol', {
enum: ['letra', 'musica', 'interprete', 'arreglos', 'produccion']
}).notNull(),
orden: integer('orden').notNull().default(0)
},
(t) => [primaryKey({ columns: [t.cancionId, t.personaId, t.rol] })]
);
/**
* Registro de la obra ante una entidad de gestión.
*
* El `iswc` identifica la COMPOSICIÓN, así que se repite entre un tema y sus
* versiones: es la misma obra. El `isrc` identifica una GRABACIÓN, y por eso va
* en la fila de cada versión. Tenerlos juntos en la canción era un fallo del
* modelo anterior, que se veía en cuanto un tema tenía dos grabaciones.
*/
export const registroObra = pgTable('registro_obra', {
cancionId: text('cancion_id')
.primaryKey()
.references(() => cancion.id, { onDelete: 'cascade' }),
iswc: text('iswc'),
isrc: text('isrc'),
entidad: text('entidad'),
numero: text('numero'),
/** No es lo mismo que el sello discográfico. */
editorial: text('editorial')
});
/**
* El reparto de autoría, que es como lo liquidan las entidades de gestión.
*
* `numeric(5,2)` y no entero: los repartos reales llevan decimales. Y suma 100
* por obra, comprobado con una restricción en la migración y no solo al validar
* el formulario, porque un reparto que no suma cien no es un dato incompleto:
* es dinero mal repartido.
*/
export const autoria = pgTable(
'autoria',
{
cancionId: text('cancion_id')
.notNull()
.references(() => cancion.id, { onDelete: 'cascade' }),
personaId: text('persona_id')
.notNull()
.references(() => persona.id),
rol: text('rol', { enum: ['letra', 'musica', 'letra y musica'] }).notNull(),
porcentaje: numeric('porcentaje', { precision: 5, scale: 2 }).notNull()
},
(t) => [primaryKey({ columns: [t.cancionId, t.personaId, t.rol] })]
);
/** Un vídeo del tema en YouTube. Se guarda el id, no la URL: la URL cambia. */
export const video = pgTable(
'video',
{
id: id(),
cancionId: text('cancion_id')
.notNull()
.references(() => cancion.id, { onDelete: 'cascade' }),
youtubeId: text('youtube_id').notNull(),
titulo: text('titulo').notNull(),
orden: integer('orden').notNull().default(0)
},
(t) => [index('video_cancion').on(t.cancionId)]
);
export const PLATAFORMAS = [
'spotify',
'appleMusic',
'youtube',
'youtubeMusic',
'bandcamp',
'soundcloud',
'deezer',
'tidal',
'amazonMusic'
] as const;
/*
* Dos tablas de enlaces y no una polimórfica con (tipo, id): una clave foránea
* de verdad vale más que ahorrarse una tabla.
*/
export const cancionEnlace = pgTable(
'cancion_enlace',
{
cancionId: text('cancion_id')
.notNull()
.references(() => cancion.id, { onDelete: 'cascade' }),
plataforma: text('plataforma', { enum: PLATAFORMAS }).notNull(),
url: text('url').notNull()
},
(t) => [primaryKey({ columns: [t.cancionId, t.plataforma] })]
);
export const albumEnlace = pgTable(
'album_enlace',
{
albumId: text('album_id')
.notNull()
.references(() => album.id, { onDelete: 'cascade' }),
plataforma: text('plataforma', { enum: PLATAFORMAS }).notNull(),
url: text('url').notNull()
},
(t) => [primaryKey({ columns: [t.albumId, t.plataforma] })]
);
/** Alguien acreditado en la edición de un disco: producción, portada, mezcla. */
export const creditoAlbum = pgTable(
'credito_album',
{
id: id(),
albumId: text('album_id')
.notNull()
.references(() => album.id, { onDelete: 'cascade' }),
personaId: text('persona_id').references(() => persona.id),
/** Si no hay ficha de persona, al menos el nombre. */
nombre: text('nombre'),
rol: text('rol').notNull(),
orden: integer('orden').notNull().default(0)
},
(t) => [index('credito_album_album').on(t.albumId)]
);
/**
* Etiquetas: lo que cruza el catálogo por donde no lo hacen el estilo ni el
* disco.
*
* Antes se deducían del texto libre de cada archivo, así que «desamor» y
* «Desamor» eran dos etiquetas distintas y nadie lo veía. Ahora el vocabulario
* es una tabla, y lo comparten las canciones y la temática de los géneros: eso
* es lo que permite cruzar «el desamor: nueve temas míos y cuatro géneros que
* viven de él».
*/
export const etiqueta = pgTable('etiqueta', {
id: id(),
slug: text('slug').notNull().unique(),
/** Como se escribió la primera vez; es lo que se lee en pantalla. */
nombre: text('nombre').notNull()
});
export const cancionEtiqueta = pgTable(
'cancion_etiqueta',
{
cancionId: text('cancion_id')
.notNull()
.references(() => cancion.id, { onDelete: 'cascade' }),
etiquetaId: text('etiqueta_id')
.notNull()
.references(() => etiqueta.id, { onDelete: 'cascade' })
},
(t) => [primaryKey({ columns: [t.cancionId, t.etiquetaId] })]
);

@ -0,0 +1,104 @@
/**
* Carrito, pedidos y compras.
*
* Aquí es donde más se nota la migración del contenido: estas tablas
* apuntaban a una canción por una cadena de texto, sin clave foránea, porque
* el destino vivía en un archivo. Renombrar un slug dejaba huérfana una compra
* sin que saltara ningún error.
*/
import { index, integer, pgTable, primaryKey, text } from 'drizzle-orm/pg-core';
import { ahora, id, instante } from './comun';
import { usuario } from './cuentas';
/* -------------------------------------------------------------------------
* Carrito
* ---------------------------------------------------------------------- */
/**
* Un carrito existe antes que la cuenta: se puede ir llenando sin haber
* entrado, identificado por una cookie, y al iniciar sesion se fusiona con el
* del usuario.
*/
export const carrito = pgTable(
'carrito',
{
id: id(),
usuarioId: text('usuario_id').references(() => usuario.id, { onDelete: 'cascade' }),
creadoEn: ahora('creado_en'),
actualizadoEn: ahora('actualizado_en')
},
(t) => [index('carrito_usuario').on(t.usuarioId)]
);
export const carritoItem = pgTable(
'carrito_item',
{
carritoId: text('carrito_id')
.notNull()
.references(() => carrito.id, { onDelete: 'cascade' }),
cancionSlug: text('cancion_slug').notNull(),
anadidoEn: ahora('anadido_en')
},
(t) => [primaryKey({ columns: [t.carritoId, t.cancionSlug] })]
);
/* -------------------------------------------------------------------------
* Pedidos y compras
* ---------------------------------------------------------------------- */
export const pedido = pgTable(
'pedido',
{
id: id(),
usuarioId: text('usuario_id')
.notNull()
.references(() => usuario.id, { onDelete: 'cascade' }),
/** Identificador de la sesion de pago de Stripe. */
sesionPagoId: text('sesion_pago_id').notNull().unique(),
estado: text('estado', { enum: ['pendiente', 'pagado', 'cancelado'] })
.notNull()
.default('pendiente'),
/** Numero de packs de 5 canciones. */
packs: integer('packs').notNull(),
/** Importe en centimos, congelado en el momento del pedido. */
totalCentimos: integer('total_centimos').notNull(),
moneda: text('moneda').notNull().default('eur'),
creadoEn: ahora('creado_en'),
pagadoEn: instante('pagado_en')
},
(t) => [index('pedido_usuario').on(t.usuarioId)]
);
export const pedidoItem = pgTable(
'pedido_item',
{
pedidoId: text('pedido_id')
.notNull()
.references(() => pedido.id, { onDelete: 'cascade' }),
cancionSlug: text('cancion_slug').notNull(),
/** Titulo en el momento de la compra, para que la factura no cambie. */
titulo: text('titulo').notNull()
},
(t) => [primaryKey({ columns: [t.pedidoId, t.cancionSlug] })]
);
/**
* Lo que da derecho a descargar. Es una tabla aparte de `pedidoItem` a
* proposito: responde a "puede esta persona descargar este tema" en una sola
* consulta, sin recorrer pedidos.
*/
export const compra = pgTable(
'compra',
{
usuarioId: text('usuario_id')
.notNull()
.references(() => usuario.id, { onDelete: 'cascade' }),
cancionSlug: text('cancion_slug').notNull(),
pedidoId: text('pedido_id')
.notNull()
.references(() => pedido.id, { onDelete: 'cascade' }),
compradoEn: ahora('comprado_en')
},
(t) => [primaryKey({ columns: [t.usuarioId, t.cancionSlug] })]
);

@ -0,0 +1,76 @@
/**
* Piezas que se repiten en todas las tablas.
*
* Están aquí y no copiadas en cada archivo porque son decisiones, no atajos:
* qué forma tiene una clave, con qué precisión se guarda un instante y qué
* estados admite algo publicable. Cambiarlas debe ser un sitio, no cuarenta.
*/
import { text, timestamp } from 'drizzle-orm/pg-core';
/**
* Clave primaria opaca.
*
* No se usa el slug: un slug es texto que se lee y que algún día se querrá
* corregir, y una clave primaria no debería cambiar nunca. El día que un tema
* se renombre, su identidad sigue siendo la misma.
*/
export const id = () =>
text('id')
.primaryKey()
.$defaultFn(() => crypto.randomUUID());
/**
* Marca de tiempo obligatoria que se rellena sola al insertar.
*
* Con zona horaria siempre: el servidor puede estar en una y quien mira en
* otra, y un instante sin zona es un instante que no se sabe cuál es. Y en
* modo `date` porque el código trabaja con `Date`; el modo por defecto de
* Postgres en Drizzle devuelve cadenas.
*
* `defaultNow()` y no `$defaultFn(() => new Date())`, que era lo que había: lo
* segundo pone el valor desde JavaScript, así que la columna queda obligatoria
* **sin valor por defecto en la base**. Un `INSERT` escrito a mano falla, y la
* invariante que la tabla dice garantizar en realidad la sostenía el código de
* la aplicación. Se vio al probar las guardas con SQL directo.
*/
export const ahora = (columna: string) =>
timestamp(columna, { withTimezone: true, mode: 'date' }).notNull().defaultNow();
/** Instante opcional, con el mismo criterio. */
export const instante = (columna: string) =>
timestamp(columna, { withTimezone: true, mode: 'date' });
/**
* Estado de publicación de cualquier contenido.
*
* `borrador` por defecto y no `publicado`: escribir una ficha a medias no puede
* significar publicarla, y el error de que algo salga antes de tiempo es peor
* que el de que tarde en salir.
*
* `retirado` existe para lo que no se puede borrar. Una canción que alguien ha
* comprado tiene que seguir existiendo aunque desaparezca del catálogo: la
* compra apunta a ella.
*/
export const ESTADOS = ['borrador', 'publicado', 'retirado'] as const;
export const estado = () => text('estado', { enum: ESTADOS }).notNull().default('borrador');
/**
* De dónde salió una fila.
*
* Las fichas de género se generan fuera con un modelo grande y entran en JSON.
* Esto es lo que distingue lo que escribió una persona de lo que propuso una
* máquina, y lo que hace visible lo que la máquina se inventó por el camino:
* musicología publicada con el nombre del titular no puede depender de que
* alguien se acuerde de cuál era cuál.
*/
export const ORIGENES = ['manual', 'importado'] as const;
export const procedencia = () => ({
origen: text('origen', { enum: ORIGENES }).notNull().default('manual'),
modeloOrigen: text('modelo_origen'),
importadoEn: instante('importado_en'),
revisadoPor: text('revisado_por'),
revisadoEn: instante('revisado_en')
});

@ -0,0 +1,121 @@
/**
* Cuentas, sesiones y el freno de los envíos.
*
* Es la parte que ya estaba modelada como toca antes de la migración del
* contenido, y no cambia con ella.
*/
import { index, integer, pgTable, text, uniqueIndex, bigint } from 'drizzle-orm/pg-core';
import { ahora, id, instante } from './comun';
/* -------------------------------------------------------------------------
* Cuentas
* ---------------------------------------------------------------------- */
export const usuario = pgTable('usuario', {
id: id(),
/** Siempre normalizado en minusculas y sin espacios. */
email: text('email').notNull().unique(),
nombre: text('nombre'),
creadoEn: ahora('creado_en'),
ultimoAccesoEn: instante('ultimo_acceso_en')
});
/**
* Una cuenta de un proveedor externo enlazada a un usuario de aqui.
*
* El par (proveedor, sujeto) es la clave: `sujeto` es el identificador estable
* que da el proveedor —el `sub` de Google, el `id` de Facebook—, y NO el correo.
* Un correo se puede cambiar, y en algunos proveedores se puede reutilizar
* cuando se libera una cuenta; el identificador no. Enlazar por correo dejaria
* la puerta abierta a entrar en la cuenta de otro el dia que un proveedor
* reasignara una direccion.
*
* Aqui NO se guarda ningun token del proveedor. Se usa una vez, en el momento
* de entrar, para preguntar quien es, y se tira: no hacemos nada mas con esa
* cuenta —ni leer contactos, ni publicar— asi que guardarlo solo anadiria algo
* mas que puede filtrarse.
*/
export const identidad = pgTable(
'identidad',
{
id: id(),
proveedor: text('proveedor', { enum: ['google', 'facebook'] }).notNull(),
/** Identificador de la persona en ese proveedor. Estable y opaco. */
sujeto: text('sujeto').notNull(),
usuarioId: text('usuario_id')
.notNull()
.references(() => usuario.id, { onDelete: 'cascade' }),
/** El correo que dio el proveedor al enlazar. Informativo. */
email: text('email'),
creadaEn: ahora('creada_en'),
ultimoAccesoEn: instante('ultimo_acceso_en')
},
(t) => [
// Una cuenta de Google no puede apuntar a dos usuarios de aqui.
uniqueIndex('identidad_proveedor_sujeto').on(t.proveedor, t.sujeto),
index('identidad_usuario').on(t.usuarioId)
]
);
/**
* Codigo de un solo uso enviado por correo.
*
* Se guarda el hash, no el codigo: quien lea la base de datos no debe poder
* entrar en una cuenta ajena. Es la misma razon por la que las sesiones
* guardan el hash del token.
*/
export const codigoAcceso = pgTable(
'codigo_acceso',
{
id: id(),
email: text('email').notNull(),
hash: text('hash').notNull(),
expiraEn: instante('expira_en').notNull(),
intentos: integer('intentos').notNull().default(0),
usadoEn: instante('usado_en'),
creadoEn: ahora('creado_en')
},
(t) => [index('codigo_acceso_email').on(t.email)]
);
export const sesion = pgTable(
'sesion',
{
/** Hash del token que viaja en la cookie. */
id: text('id').primaryKey(),
usuarioId: text('usuario_id')
.notNull()
.references(() => usuario.id, { onDelete: 'cascade' }),
expiraEn: instante('expira_en').notNull(),
creadaEn: ahora('creada_en')
},
(t) => [index('sesion_usuario').on(t.usuarioId)]
);
/**
* Contadores del limitador de envios.
*
* Esta en la base de datos y no en memoria a proposito: un limite que se borra
* al reiniciar el proceso no frena a quien insiste, y sobre todo no protege a
* un tercero. Aqui el limite existe para que nadie pueda usar el formulario de
* acceso para bombardear el buzon de otra persona.
*
* `expiraEn` va en milisegundos y no como fecha porque las ventanas son
* aritmetica pura, y algunas duran menos de un minuto.
*
* Y va en `bigint`, no en `integer`: en SQLite un entero son ocho bytes, pero
* en Postgres son cuatro y el maximo se queda en 2.147.483.647. Un instante en
* milisegundos ronda el billon y siete, asi que en `integer` desbordaria y el
* limitador dejaria de limitar.
*/
export const limite = pgTable(
'limite',
{
/** Que se esta limitando: "acceso:ip:1.2.3.4", "acceso:correo:..." */
clave: text('clave').primaryKey(),
intentos: integer('intentos').notNull(),
expiraEn: bigint('expira_en', { mode: 'number' }).notNull()
},
(t) => [index('limite_expira').on(t.expiraEn)]
);

@ -0,0 +1,137 @@
/**
* Blog, páginas sueltas, textos legales y las direcciones que ya no existen.
*/
import { boolean, index, integer, pgTable, primaryKey, text } from 'drizzle-orm/pg-core';
import { ahora, estado, id, instante } from './comun';
import { medio } from './base';
import { cancion, etiqueta } from './catalogo';
/**
* Una entrada del blog. Lo único del sitio que se fecha: el catálogo no caduca,
* pero un apunte sobre cómo se escribió un tema pertenece al día en que se
* escribió.
*/
export const entrada = pgTable(
'entrada',
{
id: id(),
slug: text('slug').notNull().unique(),
titulo: text('titulo').notNull(),
/** ISO `YYYY-MM-DD`. */
fecha: text('fecha').notNull(),
resumen: text('resumen').notNull().default(''),
cuerpo: text('cuerpo').notNull().default(''),
imagenId: text('imagen_id').references(() => medio.id, { onDelete: 'set null' }),
/** Sustituye al `borrador` de antes, que era un booleano suelto. */
estado: estado(),
creadoEn: ahora('creado_en'),
actualizadoEn: instante('actualizado_en')
},
(t) => [index('entrada_fecha').on(t.fecha)]
);
/**
* De qué temas habla una entrada.
*
* Antes era una lista de slugs sin comprobar: si el tema no existía, la entrada
* enlazaba a un 404 y nadie se enteraba hasta que alguien pinchaba.
*/
export const entradaCancion = pgTable(
'entrada_cancion',
{
entradaId: text('entrada_id')
.notNull()
.references(() => entrada.id, { onDelete: 'cascade' }),
cancionId: text('cancion_id')
.notNull()
.references(() => cancion.id, { onDelete: 'cascade' })
},
(t) => [primaryKey({ columns: [t.entradaId, t.cancionId] })]
);
export const entradaEtiqueta = pgTable(
'entrada_etiqueta',
{
entradaId: text('entrada_id')
.notNull()
.references(() => entrada.id, { onDelete: 'cascade' }),
etiquetaId: text('etiqueta_id')
.notNull()
.references(() => etiqueta.id, { onDelete: 'cascade' })
},
(t) => [primaryKey({ columns: [t.entradaId, t.etiquetaId] })]
);
/** Texto libre: la biografía y lo que venga. */
export const pagina = pgTable('pagina', {
id: id(),
slug: text('slug').notNull().unique(),
titulo: text('titulo').notNull(),
descripcion: text('descripcion').notNull().default(''),
cuerpo: text('cuerpo').notNull().default(''),
estado: estado(),
creadoEn: ahora('creado_en'),
actualizadoEn: instante('actualizado_en')
});
/**
* Aviso legal, condiciones, privacidad, cookies, condiciones de venta.
*
* El texto no vive aquí: vive en sus versiones. Esta tabla dice cuál está en
* vigor.
*/
export const documentoLegal = pgTable('documento_legal', {
id: id(),
slug: text('slug').notNull().unique(),
titulo: text('titulo').notNull(),
resumen: text('resumen').notNull().default(''),
orden: integer('orden').notNull().default(0),
/** Apunta a la versión vigente. La clave foránea va en la migración:
las dos tablas se referencian mutuamente. */
versionVigenteId: text('version_vigente_id')
});
/**
* El historial de un documento legal.
*
* No es burocracia: es prueba. Quien compró en marzo se acogió a las
* condiciones de marzo, y si hay una reclamación hay que poder demostrar qué
* decían entonces, no qué dicen ahora. Una versión, una vez publicada, no se
* toca.
*/
export const versionLegal = pgTable(
'version_legal',
{
id: id(),
documentoId: text('documento_id')
.notNull()
.references(() => documentoLegal.id, { onDelete: 'cascade' }),
cuerpo: text('cuerpo').notNull(),
vigenteDesde: ahora('vigente_desde'),
/** Qué cambió respecto de la anterior. */
resumenCambios: text('resumen_cambios')
},
(t) => [index('version_legal_documento').on(t.documentoId, t.vigenteDesde)]
);
/**
* Direcciones que ya no existen y a dónde llevan ahora.
*
* Es lo que hace que renombrar un slug deje de romper enlaces publicados. Sin
* esto, corregir el nombre de un tema significa que cualquiera que lo hubiera
* compartido acaba en un 404, y por eso los slugs no se corrigen nunca aunque
* estén mal.
*/
export const redireccion = pgTable(
'redireccion',
{
desde: text('desde').primaryKey(),
hacia: text('hacia').notNull(),
/** 301 permanente por defecto; 302 si algún día hace falta temporal. */
permanente: boolean('permanente').notNull().default(true),
motivo: text('motivo'),
creadaEn: ahora('creada_en')
},
(t) => [index('redireccion_hacia').on(t.hacia)]
);

@ -0,0 +1,136 @@
/**
* Facturación: datos fiscales, facturas y el registro de entregas.
*
* Esto no existía. Hasta ahora se cobraba con Stripe y se daba acceso a la
* descarga, pero no se emitía nada; y vender exige facturar.
*/
import {
boolean,
char,
index,
integer,
jsonb,
pgTable,
text,
uniqueIndex
} from 'drizzle-orm/pg-core';
import { ahora, id, instante } from './comun';
import { pais } from './base';
import { usuario } from './cuentas';
import { pedido } from './comercio';
/**
* Los datos fiscales de alguien. Puede tener varios y uno predeterminado.
*
* **El país decide el IVA, y no es el nuestro.** Una venta digital a un
* consumidor de otro país de la UE lleva el IVA de *su* país; fuera de la UE,
* normalmente ninguno. Por eso el tipo se guarda por línea de factura y no como
* una constante del sitio.
*/
export const datosFacturacion = pgTable(
'datos_facturacion',
{
id: id(),
usuarioId: text('usuario_id')
.notNull()
.references(() => usuario.id, { onDelete: 'cascade' }),
tipo: text('tipo', { enum: ['particular', 'empresa'] })
.notNull()
.default('particular'),
nombre: text('nombre').notNull(),
nif: text('nif'),
direccion: text('direccion'),
cp: text('cp'),
ciudad: text('ciudad'),
provincia: text('provincia'),
paisCodigo: char('pais_codigo', { length: 2 })
.notNull()
.references(() => pais.codigo),
predeterminada: boolean('predeterminada').notNull().default(false),
creadoEn: ahora('creado_en'),
actualizadoEn: instante('actualizado_en')
},
(t) => [index('datos_facturacion_usuario').on(t.usuarioId)]
);
/**
* El contador de cada serie de facturación.
*
* **El correlativo no puede salir de `max(numero) + 1`.** Dos compras a la vez
* leerían el mismo máximo y emitirían el mismo número, y eso en una factura no
* es un fallo de programación: es un problema con Hacienda. Se toma bloqueando
* esta fila, que serializa las emisiones de una serie sin bloquear nada más.
*/
export const contadorFactura = pgTable('contador_factura', {
serie: text('serie').primaryKey(),
ultimoNumero: integer('ultimo_numero').notNull().default(0),
actualizadoEn: ahora('actualizado_en')
});
/**
* Una factura emitida.
*
* **Una factura emitida no se toca nunca.** Si una venta se anula, no se borra
* ni se corrige: se emite una rectificativa que apunta a ella. Por eso hay
* `tipo` y `rectificaAId`, y por eso los datos del cliente van congelados: si
* mañana cambia su dirección, la factura de ayer no puede cambiar con él.
*/
export const factura = pgTable(
'factura',
{
id: id(),
pedidoId: text('pedido_id')
.notNull()
.references(() => pedido.id),
serie: text('serie').notNull(),
numero: integer('numero').notNull(),
tipo: text('tipo', { enum: ['normal', 'rectificativa'] })
.notNull()
.default('normal'),
rectificaAId: text('rectifica_a_id'),
fecha: ahora('fecha'),
/* Todo en céntimos y congelado en el momento de emitir. */
baseCentimos: integer('base_centimos').notNull(),
ivaTipo: integer('iva_tipo').notNull(),
ivaCuotaCentimos: integer('iva_cuota_centimos').notNull(),
totalCentimos: integer('total_centimos').notNull(),
moneda: text('moneda').notNull().default('eur'),
/** Nombre, NIF y dirección tal como estaban ese día. */
datosCongelados: jsonb('datos_congelados').notNull(),
/** El país que decidió el tipo, congelado también. */
paisCodigo: char('pais_codigo', { length: 2 })
.notNull()
.references(() => pais.codigo)
},
(t) => [
uniqueIndex('factura_serie_numero').on(t.serie, t.numero),
index('factura_pedido').on(t.pedidoId),
index('factura_fecha').on(t.fecha)
]
);
/**
* Cada entrega de un archivo a quien lo compró.
*
* Sirve para dos cosas concretas: poder demostrar la entrega si hay una
* reclamación, y ver si una cuenta se está compartiendo.
*/
export const descarga = pgTable(
'descarga',
{
id: id(),
usuarioId: text('usuario_id')
.notNull()
.references(() => usuario.id, { onDelete: 'cascade' }),
/** La clave foránea a `cancion` entra en la fase 3, con las demás. */
cancionSlug: text('cancion_slug').notNull(),
bajadaEn: ahora('bajada_en'),
ip: text('ip'),
agente: text('agente')
},
(t) => [index('descarga_usuario').on(t.usuarioId), index('descarga_fecha').on(t.bajadaEn)]
);

@ -0,0 +1,19 @@
/**
* El esquema entero, en un solo sitio.
*
* Era un archivo y ahora es una carpeta: con el catálogo dentro de la base, un
* único archivo pasaría de las dos mil líneas y dejaría de poder leerse. Esto
* reexporta todo, así que quien importaba de `$lib/server/db/schema` no se
* entera del cambio.
*/
export * from './comun';
export * from './base';
export * from './catalogo';
export * from './taller';
export * from './editorial';
export * from './facturacion';
export * from './cuentas';
export * from './comercio';
export * from './interaccion';
export * from './relaciones';

@ -0,0 +1,104 @@
/**
* Lo que hacen quienes han entrado: marcar favoritos y puntuar.
*/
import { index, integer, pgTable, primaryKey, text } from 'drizzle-orm/pg-core';
import { ahora, id } from './comun';
import { usuario } from './cuentas';
/* -------------------------------------------------------------------------
* Favoritos
* ---------------------------------------------------------------------- */
/**
* Los temas que una cuenta ha marcado.
*
* Es una lista personal, no una compra ni un voto: se puede marcar cualquier
* tema, este a la venta o no, y no dice nada publico sobre el. Por eso no lleva
* ningun contador agregado.
*
* La clave primaria es el par cuenta-tema, asi que marcar dos veces lo mismo no
* duplica nada. El indice por cuenta es para pintar la lista, que es la
* consulta que se hace de verdad.
*/
export const favorito = pgTable(
'favorito',
{
usuarioId: text('usuario_id')
.notNull()
.references(() => usuario.id, { onDelete: 'cascade' }),
cancionSlug: text('cancion_slug').notNull(),
marcadoEn: ahora('marcado_en')
},
(t) => [
primaryKey({ columns: [t.usuarioId, t.cancionSlug] }),
index('idx_favorito_usuario').on(t.usuarioId)
]
);
/* -------------------------------------------------------------------------
* Valoraciones
* ---------------------------------------------------------------------- */
/**
* Lo que una cuenta le pone a un tema, de 1 a 5.
*
* La clave primaria es el par cuenta-tema, asi que nadie puede votar dos veces
* lo mismo: volver a votar reemplaza el voto anterior. Eso lo garantiza la base
* de datos, no el codigo de la accion, que es donde tiene que estar.
*
* El indice por `cancion_slug` es para la media: se calcula por tema, y sin el
* habria que recorrer la tabla entera en cada ficha.
*/
export const valoracion = pgTable(
'valoracion',
{
usuarioId: text('usuario_id')
.notNull()
.references(() => usuario.id, { onDelete: 'cascade' }),
cancionSlug: text('cancion_slug').notNull(),
/** De 1 a 5. El rango lo comprueba tambien la aplicacion al guardar. */
puntuacion: integer('puntuacion').notNull(),
creadaEn: ahora('creada_en'),
actualizadaEn: ahora('actualizada_en')
},
(t) => [
primaryKey({ columns: [t.usuarioId, t.cancionSlug] }),
index('idx_valoracion_cancion').on(t.cancionSlug)
]
);
/**
* Cada escucha.
*
* Se conservan por persona: decisión tomada, y con consecuencias fuera de esta
* tabla —la política de privacidad afirmaba que no se hacía seguimiento y hay
* que reescribirla antes de que esto se publique—.
*
* `segundos` no es un adorno: una reproducción de tres segundos no es una
* escucha, y contarla igual falsea la cifra que se quiere mirar.
*
* Esta tabla crece sin parar, así que su mantenimiento es parte del diseño y no
* un apaño para más adelante: se agrega por día y el detalle se borra a los
* noventa. `dia` está aparte del instante justamente para poder agregar sin
* recorrer millones de filas.
*/
export const reproduccion = pgTable(
'reproduccion',
{
id: id(),
/** La clave foránea a `cancion` entra en la fase 3, con las demás. */
cancionSlug: text('cancion_slug').notNull(),
/** Nulo si no había entrado. */
usuarioId: text('usuario_id').references(() => usuario.id, { onDelete: 'set null' }),
segundos: integer('segundos').notNull().default(0),
/** ISO `YYYY-MM-DD`, para agregar. */
dia: text('dia').notNull(),
creadoEn: ahora('creado_en')
},
(t) => [
index('reproduccion_dia').on(t.dia),
index('reproduccion_cancion').on(t.cancionSlug),
index('reproduccion_usuario').on(t.usuarioId)
]
);

@ -0,0 +1,44 @@
/**
* Relaciones para el cliente de consultas de Drizzle.
*
* Van aparte de las tablas: describen cómo se navegan, no cómo se guardan, y
* mezclarlas obliga a que cada archivo conozca a los demás.
*/
import { relations } from 'drizzle-orm';
import { usuario } from './cuentas';
import { carrito, carritoItem, compra, pedido, pedidoItem } from './comercio';
import { favorito, valoracion } from './interaccion';
import { sesion } from './cuentas';
export const relacionesUsuario = relations(usuario, ({ many }) => ({
sesiones: many(sesion),
pedidos: many(pedido),
compras: many(compra),
valoraciones: many(valoracion),
favoritos: many(favorito)
}));
export const relacionesCarrito = relations(carrito, ({ many, one }) => ({
items: many(carritoItem),
usuario: one(usuario, { fields: [carrito.usuarioId], references: [usuario.id] })
}));
export const relacionesCarritoItem = relations(carritoItem, ({ one }) => ({
carrito: one(carrito, { fields: [carritoItem.carritoId], references: [carrito.id] })
}));
export const relacionesPedido = relations(pedido, ({ many, one }) => ({
items: many(pedidoItem),
usuario: one(usuario, { fields: [pedido.usuarioId], references: [usuario.id] })
}));
export const relacionesPedidoItem = relations(pedidoItem, ({ one }) => ({
pedido: one(pedido, { fields: [pedidoItem.pedidoId], references: [pedido.id] })
}));
export type Usuario = typeof usuario.$inferSelect;
export type Pedido = typeof pedido.$inferSelect;
export type Compra = typeof compra.$inferSelect;
export type Valoracion = typeof valoracion.$inferSelect;
export type Favorito = typeof favorito.$inferSelect;

@ -0,0 +1,624 @@
/**
* El taller: géneros hispanoamericanos, sus fichas, el glosario y los prompts.
*
* Es el área que más creció al modelarla. La ficha de género era una tarjeta
* con cinco campos y ha acabado siendo la pieza más rica del sitio, porque el
* catálogo de géneros aspira a ser completo y se llena poco a poco.
*
* La distinción que sostiene todo esto: un `genero` NO es un `estilo`. El
* estilo es cómo el titular ordena su obra; el género es música
* hispanoamericana descrita para poder escribirla. Compartían palabra y por eso
* compartían nombre en el código.
*/
import {
boolean,
char,
index,
integer,
jsonb,
pgTable,
primaryKey,
text,
uniqueIndex
} from 'drizzle-orm/pg-core';
import { ahora, estado, id, instante, procedencia } from './comun';
import { medio, pais, persona } from './base';
import { cancion, etiqueta } from './catalogo';
/* -------------------------------------------------------------------------
* Géneros
* ---------------------------------------------------------------------- */
/**
* Bolero, tango, cumbia, vallenato.
*
* En columnas va solo lo suyo y lo que no cabe en una tabla auxiliar. Lo
* comparable —metro, compás, temática— sale a vocabularios propios: con ocho
* géneros se lee en prosa, con cien hay que poder preguntar.
*/
export const genero = pgTable(
'genero',
{
id: id(),
slug: text('slug').notNull().unique(),
nombre: text('nombre').notNull(),
resumen: text('resumen').notNull().default(''),
origenLugar: text('origen_lugar'),
/** Ordenable, que en el texto libre de antes no lo era. */
origenAno: integer('origen_ano'),
tempo: text('tempo'),
rima: text('rima'),
/**
* El matiz que no cabe en la tabla auxiliar y es lo que hace útil la
* ficha: «a menudo alternados», «también octosílabo en la milonga».
*/
metricaNota: text('metrica_nota'),
compasNota: text('compas_nota'),
/**
* Rasgos de escritura, en array y no en tabla: son frases de verdad
* —«Tratamiento de usted o de tú, nunca coloquial»— y convertirlas en
* vocabulario sería inventar etiquetas que nadie va a reutilizar.
*/
rasgos: text('rasgos').array().$type<string[]>(),
estado: estado(),
...procedencia(),
creadoEn: ahora('creado_en'),
actualizadoEn: instante('actualizado_en')
},
(t) => [index('genero_nombre').on(t.nombre), index('genero_origen_ano').on(t.origenAno)]
);
/**
* De qué deriva un género, y qué deriva de él.
*
* **Sustituye a un `padre_id`, que no aguantaba.** Un género no deriva de uno:
* el bolero-son es bolero *y* son; la salsa viene del son, el mambo y la
* guaracha; la bachata, del bolero, el son y el merengue. Con un solo padre, la
* primera fusión obliga a elegir cuál de los dos mentir.
*
* `influye` es el tercer tipo, más débil que derivar, y ya estaba escrito en el
* contenido dentro del campo equivocado: `son-cubano.md` mete «herencia de la
* décima y la copla españolas» dentro de `metrica` porque no había dónde
* ponerlo.
*
* El grafo guarda la verdad; la arista marcada como `principal` —única por
* género— da el árbol de navegación. Así no hay que elegir entre las dos cosas.
*/
export const generoOrigen = pgTable(
'genero_origen',
{
generoId: text('genero_id')
.notNull()
.references(() => genero.id, { onDelete: 'cascade' }),
origenId: text('origen_id')
.notNull()
.references(() => genero.id, { onDelete: 'cascade' }),
tipo: text('tipo', { enum: ['deriva', 'fusiona', 'influye'] }).notNull(),
principal: boolean('principal').notNull().default(false),
nota: text('nota'),
orden: integer('orden').notNull().default(0)
},
(t) => [
primaryKey({ columns: [t.generoId, t.origenId, t.tipo] }),
index('genero_origen_origen').on(t.origenId)
]
);
/**
* En qué países vive un género.
*
* `cuna` o `adoptado`, porque un género no es de un país: nace en uno y se
* canta en varios. La cumbia nace colombiana y se hace mexicana, peruana y
* argentina; el tango es de las dos orillas del Plata. Un solo país sería
* mentira.
*/
export const generoPais = pgTable(
'genero_pais',
{
generoId: text('genero_id')
.notNull()
.references(() => genero.id, { onDelete: 'cascade' }),
paisCodigo: char('pais_codigo', { length: 2 })
.notNull()
.references(() => pais.codigo),
relacion: text('relacion', { enum: ['cuna', 'adoptado'] })
.notNull()
.default('adoptado'),
nota: text('nota')
},
(t) => [primaryKey({ columns: [t.generoId, t.paisCodigo] })]
);
/**
* Toda la prosa de la ficha, en secciones ordenadas.
*
* Introducción, historia, estructura de las canciones, motivación y temática. Y
* lo que surja mañana: con una columna por bloque, en un mes hay quince
* columnas y la mitad vacías; siendo secciones, un apartado nuevo no cuesta una
* migración.
*/
export const generoSeccion = pgTable(
'genero_seccion',
{
id: id(),
generoId: text('genero_id')
.notNull()
.references(() => genero.id, { onDelete: 'cascade' }),
/** introduccion · historia · estructura · motivacion · … */
clave: text('clave').notNull(),
titulo: text('titulo').notNull(),
cuerpo: text('cuerpo').notNull(),
orden: integer('orden').notNull().default(0)
},
(t) => [uniqueIndex('genero_seccion_clave').on(t.generoId, t.clave)]
);
/**
* Cómo se llama ese género donde se toca.
*
* *Son cubano*, *son jarocho*, *son huasteco*, *son montuno*: cuatro cosas que
* no se parecen y comparten palabra. Con cien fichas, buscar «son» tiene que
* llevar a la que corresponde.
*/
export const generoAlias = pgTable(
'genero_alias',
{
id: id(),
generoId: text('genero_id')
.notNull()
.references(() => genero.id, { onDelete: 'cascade' }),
nombre: text('nombre').notNull(),
paisCodigo: char('pais_codigo', { length: 2 }).references(() => pais.codigo),
nota: text('nota')
},
(t) => [index('genero_alias_nombre').on(t.nombre)]
);
/** De qué habla un género. Comparte vocabulario con las etiquetas del catálogo. */
export const generoTema = pgTable(
'genero_tema',
{
generoId: text('genero_id')
.notNull()
.references(() => genero.id, { onDelete: 'cascade' }),
etiquetaId: text('etiqueta_id')
.notNull()
.references(() => etiqueta.id, { onDelete: 'cascade' }),
orden: integer('orden').notNull().default(0)
},
(t) => [primaryKey({ columns: [t.generoId, t.etiquetaId] })]
);
/** Un tema propio escrito en ese género, aunque el catálogo lo ordene aparte. */
export const cancionGenero = pgTable(
'cancion_genero',
{
cancionId: text('cancion_id')
.notNull()
.references(() => cancion.id, { onDelete: 'cascade' }),
generoId: text('genero_id')
.notNull()
.references(() => genero.id, { onDelete: 'cascade' })
},
(t) => [primaryKey({ columns: [t.cancionId, t.generoId] })]
);
/* -------------------------------------------------------------------------
* Metro y compás: lo comparable, fuera de la prosa
* ---------------------------------------------------------------------- */
/**
* Medidas del verso.
*
* El octosílabo aparece en el bolero, el son y el tango; el endecasílabo en el
* bolero y el tango. Escritos en prosa no se pueden preguntar, y «enséñame los
* géneros en octosílabo» es exactamente lo que un taller de métrica necesita.
*
* `silabas` nulo en la fila `libre`, para que «sin métrica fija» —la bachata:
* «Libre, cercana al habla»— también sea una consulta y no una ausencia.
*/
export const metro = pgTable('metro', {
id: id(),
codigo: text('codigo').notNull().unique(),
nombre: text('nombre').notNull(),
silabas: integer('silabas'),
/** Al glosario: «octosílabo» es también una entrada suya. */
terminoId: text('termino_id'),
nota: text('nota')
});
export const generoMetro = pgTable(
'genero_metro',
{
generoId: text('genero_id')
.notNull()
.references(() => genero.id, { onDelete: 'cascade' }),
metroId: text('metro_id')
.notNull()
.references(() => metro.id),
predominante: boolean('predominante').notNull().default(false)
},
(t) => [primaryKey({ columns: [t.generoId, t.metroId] })]
);
/**
* Compases.
*
* Numerador y denominador aparte del código para poder pedir los binarios o los
* ternarios sin analizar una cadena.
*/
export const compas = pgTable('compas', {
id: id(),
codigo: text('codigo').notNull().unique(),
nombre: text('nombre'),
numerador: integer('numerador').notNull(),
denominador: integer('denominador').notNull(),
nota: text('nota')
});
export const generoCompas = pgTable(
'genero_compas',
{
generoId: text('genero_id')
.notNull()
.references(() => genero.id, { onDelete: 'cascade' }),
compasId: text('compas_id')
.notNull()
.references(() => compas.id),
/** El bolero lleva dos: 2/4 en el clásico, 4/4 en el moderno. */
principal: boolean('principal').notNull().default(false),
nota: text('nota')
},
(t) => [primaryKey({ columns: [t.generoId, t.compasId] })]
);
/* -------------------------------------------------------------------------
* Instrumentos
* ---------------------------------------------------------------------- */
export const FAMILIAS = ['cuerda', 'viento', 'percusion', 'teclado', 'electrofono', 'voz'] as const;
export const instrumento = pgTable(
'instrumento',
{
id: id(),
slug: text('slug').notNull().unique(),
nombre: text('nombre').notNull(),
familia: text('familia', { enum: FAMILIAS }).notNull(),
/** «cuerda pulsada», «membranófono». Libre: la clasificación fina varía. */
subfamilia: text('subfamilia'),
paisCodigo: char('pais_codigo', { length: 2 }).references(() => pais.codigo),
descripcion: text('descripcion'),
/** «la · re · sol · do · mi · la». Dato de oficio, no adorno. */
afinacion: text('afinacion'),
imagenId: text('imagen_id').references(() => medio.id, { onDelete: 'set null' }),
/** El sonido de muestra es un archivo como cualquier otro. */
muestraId: text('muestra_id').references(() => medio.id, { onDelete: 'set null' }),
estado: estado(),
...procedencia()
},
(t) => [index('instrumento_familia').on(t.familia)]
);
/**
* Qué instrumentos lleva un género y con qué peso.
*
* `importancia` no es un adorno: sin ella, el tres cubano y una trompeta
* ocasional pesarían igual en la ficha, y no es verdad.
*/
export const generoInstrumento = pgTable(
'genero_instrumento',
{
generoId: text('genero_id')
.notNull()
.references(() => genero.id, { onDelete: 'cascade' }),
instrumentoId: text('instrumento_id')
.notNull()
.references(() => instrumento.id),
importancia: text('importancia', { enum: ['esencial', 'habitual', 'ocasional'] })
.notNull()
.default('habitual'),
/** «marca la clave», «lleva el bajo», «hace las introducciones». */
papel: text('papel'),
orden: integer('orden').notNull().default(0)
},
(t) => [primaryKey({ columns: [t.generoId, t.instrumentoId] })]
);
/* -------------------------------------------------------------------------
* Personas y obras de referencia
* ---------------------------------------------------------------------- */
/** Figuras de un género: letristas, compositores, intérpretes, fundadores. */
export const generoPersona = pgTable(
'genero_persona',
{
generoId: text('genero_id')
.notNull()
.references(() => genero.id, { onDelete: 'cascade' }),
personaId: text('persona_id')
.notNull()
.references(() => persona.id),
papel: text('papel', {
enum: ['letrista', 'compositor', 'interprete', 'fundador']
}).notNull(),
nota: text('nota'),
orden: integer('orden').notNull().default(0)
},
(t) => [primaryKey({ columns: [t.generoId, t.personaId, t.papel] })]
);
/**
* Obras ajenas que definen un género.
*
* «Letras de referencia de un letrista» y «canciones del género» son la misma
* entidad, mirada desde un lado o desde el otro.
*
* **Aquí no se alojan letras completas.** «Bésame mucho» tiene dueño y lo
* tendrá durante décadas. Se cita título, autor y año, se enlaza a dónde
* escucharla y se reproduce un fragmento breve al amparo del derecho de cita
* —el mismo que invoca nuestro propio aviso legal—. Por eso el campo se llama
* `cita` y es corto, y no `letra`.
*/
export const obraReferencia = pgTable(
'obra_referencia',
{
id: id(),
slug: text('slug').notNull().unique(),
titulo: text('titulo').notNull(),
ano: integer('ano'),
generoId: text('genero_id').references(() => genero.id, { onDelete: 'set null' }),
/** Por qué importa esta obra. */
nota: text('nota'),
/** Dónde escucharla. */
enlace: text('enlace'),
/** Fragmento breve, no la letra. */
cita: text('cita'),
estado: estado(),
...procedencia()
},
(t) => [index('obra_referencia_genero').on(t.generoId)]
);
export const obraReferenciaPersona = pgTable(
'obra_referencia_persona',
{
obraId: text('obra_id')
.notNull()
.references(() => obraReferencia.id, { onDelete: 'cascade' }),
personaId: text('persona_id')
.notNull()
.references(() => persona.id),
papel: text('papel', { enum: ['letra', 'musica', 'interprete'] }).notNull(),
orden: integer('orden').notNull().default(0)
},
(t) => [primaryKey({ columns: [t.obraId, t.personaId, t.papel] })]
);
/* -------------------------------------------------------------------------
* Capítulos y glosario
* ---------------------------------------------------------------------- */
/** El curso —los principios— y la ficha de cada género. */
export const capitulo = pgTable(
'capitulo',
{
id: id(),
slug: text('slug').notNull().unique(),
titulo: text('titulo').notNull(),
tipo: text('tipo', { enum: ['principio', 'ficha'] }).notNull(),
/** Solo en las fichas. */
generoId: text('genero_id').references(() => genero.id, { onDelete: 'set null' }),
orden: integer('orden').notNull().default(0),
resumen: text('resumen').notNull().default(''),
cuerpo: text('cuerpo').notNull().default(''),
estado: estado(),
creadoEn: ahora('creado_en'),
actualizadoEn: instante('actualizado_en')
},
(t) => [index('capitulo_tipo').on(t.tipo, t.orden)]
);
/** La palabra del glosario. Sus sentidos van aparte. */
export const termino = pgTable('termino', {
id: id(),
slug: text('slug').notNull().unique(),
termino: text('termino').notNull().unique()
});
/**
* Un sentido del término, numerado.
*
* «Clave» es instrumento de percusión en la primera acepción, patrón rítmico en
* la segunda y armadura de tonalidad en la tercera. Con una sola definición por
* palabra, dos de las tres serían falsas. Por eso la categoría vive aquí y no
* en el término.
*/
export const acepcion = pgTable(
'acepcion',
{
id: id(),
terminoId: text('termino_id')
.notNull()
.references(() => termino.id, { onDelete: 'cascade' }),
numero: integer('numero').notNull(),
/** metrica · rima · musica · estructura · interpretacion · instrumento */
categoria: text('categoria').notNull(),
/** Una o dos frases: lo que cabe en un globo sin tapar el texto. */
definicion: text('definicion').notNull(),
/** Lo que se lee en la página del glosario. */
ampliacion: text('ampliacion'),
/** Un verso donde se vea. Un término de métrica sin ejemplo no se entiende. */
ejemplo: text('ejemplo')
},
(t) => [uniqueIndex('acepcion_numero').on(t.terminoId, t.numero)]
);
/**
* Las formas en que aparece escrito un término.
*
* «sinalefas», «heptasílabos», «rimas asonantes». Al nivel del término y no de
* la acepción: el plural no cambia de sentido. Sin esto, el marcado solo
* acierta cuando la palabra sale en singular, que es casi nunca.
*/
export const terminoVariante = pgTable(
'termino_variante',
{
terminoId: text('termino_id')
.notNull()
.references(() => termino.id, { onDelete: 'cascade' }),
forma: text('forma').notNull()
},
(t) => [primaryKey({ columns: [t.terminoId, t.forma] })]
);
/**
* Dónde se marca cada acepción.
*
* Apunta a una **acepción**, no a un término: en un texto sobre el bolero,
* «clave» es el patrón rítmico y no la armadura. Eso resuelve la duda del
* marcado automático: sabe encontrar la palabra pero no elegir el sentido, así
* que propone y una persona confirma.
*
* Se calcula al guardar el capítulo, no al pintarlo: buscar veinte términos en
* cada texto en cada petición es trabajo repetido. Y siendo tabla se puede
* desmarcar una aparición suelta cuando la palabra esté usada en otro sentido.
*/
export const capituloAcepcion = pgTable(
'capitulo_acepcion',
{
capituloId: text('capitulo_id')
.notNull()
.references(() => capitulo.id, { onDelete: 'cascade' }),
acepcionId: text('acepcion_id')
.notNull()
.references(() => acepcion.id, { onDelete: 'cascade' }),
/** Propuesto por el marcado automático y aún sin confirmar. */
automatico: boolean('automatico').notNull().default(true),
confirmado: boolean('confirmado').notNull().default(false)
},
(t) => [primaryKey({ columns: [t.capituloId, t.acepcionId] })]
);
/* -------------------------------------------------------------------------
* Prompts para IA generativa
* ---------------------------------------------------------------------- */
/**
* Un modelo concreto, no una marca.
*
* **El prompt depende del modelo, no de la IA.** Uno de Suno v3 no es uno de
* v4: cambian la sintaxis, las etiquetas y lo que el modelo ignora. Si esto
* apuntara a «Suno» a secas, el día que salga la versión siguiente quedaría una
* biblioteca de prompts sin saber para qué servía cada uno.
*/
export const modeloIa = pgTable(
'modelo_ia',
{
id: id(),
codigo: text('codigo').notNull().unique(),
familia: text('familia').notNull(),
version: text('version').notNull(),
nombre: text('nombre').notNull(),
vigente: boolean('vigente').notNull().default(true),
notaSintaxis: text('nota_sintaxis'),
/**
* JSON Schema con la forma que toman los prompts de este modelo. Se validan
* contra él al guardar, y el panel puede pintar el formulario a partir de
* él sin una pantalla por cada IA.
*/
esquema: jsonb('esquema')
},
(t) => [index('modelo_ia_familia').on(t.familia)]
);
/**
* Ánimo, codificado.
*
* La escala es la **GEMS** (*Geneva Emotional Music Scale*), hecha para emoción
* musical y no para emoción en general: asombro, trascendencia, ternura,
* nostalgia, serenidad, fuerza, alegría vital, tensión y tristeza. Más los dos
* ejes del modelo circumplejo de Russell —valencia y energía—, que sirven para
* ordenar e interpolar y que además son redactables: «alta energía, valencia
* baja» es una instrucción; «tensión» a secas, no.
*
* Se llama `animo` y no `version` a propósito: `version` ya significa otra cosa
* en este modelo y con las dos compartiendo nombre ninguna consulta se leería
* sola.
*/
export const animo = pgTable('animo', {
id: id(),
codigo: text('codigo').notNull().unique(),
nombre: text('nombre').notNull(),
/** El factor GEMS del que sale, si sale de uno. */
gems: text('gems'),
/** −1 a 1. */
valencia: integer('valencia'),
/** 0 a 100. */
energia: integer('energia'),
descripcion: text('descripcion')
});
/**
* Un prompt de referencia para un género y un ánimo.
*
* El contenido va abierto **y aquí sí toca**: la forma la define un tercero y
* cambia sin avisarnos. Es el caso contrario al del contenido del sitio, donde
* el esquema es nuestro y por eso ahí sí se modela.
*
* Abierto no es sin validar: se comprueba contra el `esquema` de su modelo. Y
* lo que se consulta —género, ánimo, modelo, valoración, idioma— se queda en
* columnas, para que «los prompts que funcionaron para el bolero» siga siendo
* una consulta trivial.
*
* La versión del modelo NO se repite dentro del JSON: vive en
* `modelo_ia.version`. Si una API la exige en el payload, se compone al enviar.
*/
export const prompt = pgTable(
'prompt',
{
id: id(),
modeloIaId: text('modelo_ia_id')
.notNull()
.references(() => modeloIa.id),
generoId: text('genero_id').references(() => genero.id, { onDelete: 'cascade' }),
animoId: text('animo_id').references(() => animo.id),
/** En inglés suelen funcionar mejor, incluso para música en español. */
idioma: text('idioma').notNull().default('en'),
contenido: jsonb('contenido').notNull(),
notas: text('notas'),
/** Si funcionó. Sin esto se acumulan prompts sin saber cuáles servían. */
valoracion: integer('valoracion'),
/** El resultado, para poder oírlo. */
ejemploId: text('ejemplo_id').references(() => medio.id, { onDelete: 'set null' }),
/** Varios por combinación; uno marcado. Esto se itera, no se acierta. */
preferido: boolean('preferido').notNull().default(false),
/**
* Nacen privados. No porque publicarlos esté mal, sino porque es una
* decisión de posicionamiento sin tomar y esta es la reversible:
* publicarlos mañana es cambiar una columna; despublicarlos cuando ya
* están indexados, no.
*/
visibilidad: text('visibilidad', { enum: ['privado', 'taller', 'publico'] })
.notNull()
.default('privado'),
estado: estado(),
creadoEn: ahora('creado_en'),
actualizadoEn: instante('actualizado_en')
},
(t) => [
index('prompt_genero').on(t.generoId),
index('prompt_modelo').on(t.modeloIaId),
index('prompt_animo').on(t.animoId)
]
);
Loading…
Cancel
Save

Powered by TurnKey Linux.