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
parent
91ac23f31e
commit
1a8c904ae8
@ -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
@ -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;
|
||||
Loading…
Reference in new issue