From 88218b3567dbedc0c3b5835ae162ff87e0c22e44 Mon Sep 17 00:00:00 2001 From: Yuri Tatishchev Date: Thu, 5 Feb 2026 19:19:25 -0800 Subject: [PATCH] db: split schema into separate files --- src/lib/db/schema/index.ts | 521 +----------------- src/lib/db/schema/relations.ts | 87 +++ src/lib/db/schema/tables/anime-genres.ts | 34 ++ src/lib/db/schema/tables/anime-names.ts | 40 ++ src/lib/db/schema/tables/anime-song-links.ts | 45 ++ src/lib/db/schema/tables/anime-tags.ts | 34 ++ src/lib/db/schema/tables/anime.ts | 45 ++ src/lib/db/schema/tables/artist-alt-names.ts | 55 ++ src/lib/db/schema/tables/artist-groups.ts | 35 ++ src/lib/db/schema/tables/artists.ts | 18 + src/lib/db/schema/tables/genres.ts | 17 + src/lib/db/schema/tables/group-alt-names.ts | 51 ++ .../db/schema/tables/group-artist-members.ts | 35 ++ .../db/schema/tables/group-group-members.ts | 34 ++ src/lib/db/schema/tables/groups.ts | 18 + src/lib/db/schema/tables/songs.ts | 80 +++ src/lib/db/schema/tables/tags.ts | 17 + 17 files changed, 665 insertions(+), 501 deletions(-) create mode 100644 src/lib/db/schema/relations.ts create mode 100644 src/lib/db/schema/tables/anime-genres.ts create mode 100644 src/lib/db/schema/tables/anime-names.ts create mode 100644 src/lib/db/schema/tables/anime-song-links.ts create mode 100644 src/lib/db/schema/tables/anime-tags.ts create mode 100644 src/lib/db/schema/tables/anime.ts create mode 100644 src/lib/db/schema/tables/artist-alt-names.ts create mode 100644 src/lib/db/schema/tables/artist-groups.ts create mode 100644 src/lib/db/schema/tables/artists.ts create mode 100644 src/lib/db/schema/tables/genres.ts create mode 100644 src/lib/db/schema/tables/group-alt-names.ts create mode 100644 src/lib/db/schema/tables/group-artist-members.ts create mode 100644 src/lib/db/schema/tables/group-group-members.ts create mode 100644 src/lib/db/schema/tables/groups.ts create mode 100644 src/lib/db/schema/tables/songs.ts create mode 100644 src/lib/db/schema/tables/tags.ts diff --git a/src/lib/db/schema/index.ts b/src/lib/db/schema/index.ts index fbc38ba..b3afa6a 100644 --- a/src/lib/db/schema/index.ts +++ b/src/lib/db/schema/index.ts @@ -1,502 +1,21 @@ -import { relations } from "drizzle-orm"; -import { - index, - integer, - primaryKey, - real, - sqliteTable, - text, - uniqueIndex, -} from "drizzle-orm/sqlite-core"; +// Re-export all schema tables + relations from their dedicated modules. +// +// This replaces the previous monolithic schema file while keeping the +// public import surface stable (e.g. `import { animeTable } from "$lib/db/schema"`). -/** - * Normalized schema for AMQ source data (imported from JSON). - * - * Source Zod types (src/lib/types/amq): - * - AmqAnimeSchema (anime + names + genres + tags + songLinks) - * - AmqSongSchema (song + category + artist/group ids for roles) - * - AmqArtistSchema (artist + memberships + altNames) - * - AmqGroupSchema (group + members + altNames) - * - * Notes: - * - We keep original AMQ integer identifiers where present (annId, songId, annSongId, songArtistId, songGroupId, etc.) - * - Arrays (genres, tags, names, songLinks) are normalized to separate tables. - * - Artists and groups are stored in separate tables: `artists` and `groups`. - * - Membership and alt-names are normalized to join tables. - * - Anime-song linkage from `songLinks` is stored in `anime_song_links` (unique per anime+song). - */ - -// ---------------------- -// Core tables -// ---------------------- - -export const animeTable = sqliteTable( - "anime", - { - /** AMQ anime ID */ - annId: integer("ann_id").notNull().primaryKey(), - - // External IDs from the source - aniListId: integer("anilist_id"), - malId: integer("mal_id").notNull(), - kitsuId: integer("kitsu_id"), - - // Category object (name + number that can be number|string|null in source) - categoryName: text("category_name").notNull(), - categoryNumber: text("category_number"), - - // Names - mainName: text("main_name").notNull(), - mainNameEn: text("main_name_en"), - mainNameJa: text("main_name_ja"), - - // Season/year - year: integer("year").notNull(), - seasonId: integer("season_id").notNull(), // 0..3 from Season enum - - // Counts - opCount: integer("op_count").notNull(), - edCount: integer("ed_count").notNull(), - insertCount: integer("insert_count").notNull(), - }, - (t) => ({ - aniListIndex: index("anime_anilist_id_uq").on(t.aniListId), - malIndex: index("anime_mal_id_uq").on(t.malId), - kitsuIndex: index("anime_kitsu_id_uq").on(t.kitsuId), - }), -); - -export const songsTable = sqliteTable( - "songs", - { - /** AMQ annSongId (ANN song id) */ - annSongId: integer("ann_song_id").notNull().primaryKey(), - - /** AMQ songId */ - songId: integer("song_id").notNull(), - - name: text("name").notNull(), - - /** - * AmqSongCategory enum from the source: - * none(0), instrumental(1), chanting(2), character(3), standard(4) - */ - category: integer("category").notNull(), - - /** - * Song audio filename used by the AMQ CDN: - * https://nawdist.animemusicquiz.com/{fileName} - */ - fileName: text("file_name"), - fileName480: text("file_name_480"), - fileName720: text("file_name_720"), - meanVolume: real("mean_volume"), - - /** - * Primary artist/group ids for this song (nullable in source). - * These reference existing `artists` / `groups` rows. - */ - songArtistId: integer("song_artist_id").references( - () => artistsTable.songArtistId, - { onDelete: "set null" }, - ), - songGroupId: integer("song_group_id").references( - () => groupsTable.songGroupId, - { onDelete: "set null" }, - ), - - /** - * Additional contributor ids (nullable in source) - */ - composerArtistId: integer("composer_artist_id").references( - () => artistsTable.songArtistId, - { onDelete: "set null" }, - ), - composerGroupId: integer("composer_group_id").references( - () => groupsTable.songGroupId, - { onDelete: "set null" }, - ), - arrangerArtistId: integer("arranger_artist_id").references( - () => artistsTable.songArtistId, - { onDelete: "set null" }, - ), - arrangerGroupId: integer("arranger_group_id").references( - () => groupsTable.songGroupId, - { onDelete: "set null" }, - ), - }, - (t) => ({ - songIdIndex: index("songs_song_id_idx").on(t.songId), - songArtistIdIndex: index("songs_song_artist_id_idx").on(t.songArtistId), - songGroupIdIndex: index("songs_song_group_id_idx").on(t.songGroupId), - }), -); - -export const artistsTable = sqliteTable( - "artists", - { - /** AMQ songArtistId */ - songArtistId: integer("song_artist_id").notNull().primaryKey(), - name: text("name").notNull(), - }, - (t) => ({ - nameIndex: index("artists_name_idx").on(t.name), - }), -); - -export const groupsTable = sqliteTable( - "groups", - { - /** AMQ songGroupId */ - songGroupId: integer("song_group_id").notNull().primaryKey(), - name: text("name").notNull(), - }, - (t) => ({ - nameIndex: index("groups_name_idx").on(t.name), - }), -); - -/** - * Artist -> Groups membership (from AmqArtistSchema.inGroups) - */ -export const artistGroupsTable = sqliteTable( - "artist_groups", - { - songArtistId: integer("song_artist_id") - .notNull() - .references(() => artistsTable.songArtistId, { - onDelete: "cascade", - }), - songGroupId: integer("song_group_id") - .notNull() - .references(() => groupsTable.songGroupId, { onDelete: "cascade" }), - }, - (t) => ({ - pk: primaryKey({ - name: "artist_groups_pk", - columns: [t.songArtistId, t.songGroupId], - }), - artistIndex: index("artist_groups_artist_id_idx").on(t.songArtistId), - groupIndex: index("artist_groups_group_id_idx").on(t.songGroupId), - }), -); - -/** - * Group -> Artist members (from AmqGroupSchema.artistMembers) - */ -export const groupArtistMembersTable = sqliteTable( - "group_artist_members", - { - songGroupId: integer("song_group_id") - .notNull() - .references(() => groupsTable.songGroupId, { onDelete: "cascade" }), - songArtistId: integer("song_artist_id") - .notNull() - .references(() => artistsTable.songArtistId, { - onDelete: "cascade", - }), - }, - (t) => ({ - pk: primaryKey({ - name: "group_artist_members_pk", - columns: [t.songGroupId, t.songArtistId], - }), - groupIndex: index("group_artist_members_group_id_idx").on(t.songGroupId), - artistIndex: index("group_artist_members_artist_id_idx").on(t.songArtistId), - }), -); - -/** - * Group -> Group members (from AmqGroupSchema.groupMembers) - */ -export const groupGroupMembersTable = sqliteTable( - "group_group_members", - { - songGroupId: integer("song_group_id") - .notNull() - .references(() => groupsTable.songGroupId, { onDelete: "cascade" }), - memberSongGroupId: integer("member_song_group_id") - .notNull() - .references(() => groupsTable.songGroupId, { onDelete: "cascade" }), - }, - (t) => ({ - pk: primaryKey({ - name: "group_group_members_pk", - columns: [t.songGroupId, t.memberSongGroupId], - }), - groupIndex: index("group_group_members_group_id_idx").on(t.songGroupId), - memberIndex: index("group_group_members_member_group_id_idx").on( - t.memberSongGroupId, - ), - }), -); - -/** - * Alternative names for artists and groups (both schemas use: - * { songGroupId, name } - * - * Interpreted as: "the alias `name` used in/for group `songGroupId`". - */ -export const artistAltNamesTable = sqliteTable( - "artist_alt_names", - { - id: integer("id").notNull().primaryKey({ autoIncrement: true }), - /** - * The "owning" artist. - */ - songArtistId: integer("song_artist_id") - .notNull() - .references(() => artistsTable.songArtistId, { - onDelete: "cascade", - }), - - /** - * The alternate-name entry is keyed by an artist id in the source: - * { songArtistId, name } - * - * Interpreted as: artist `songArtistId` is also known as `name`. - */ - altSongArtistId: integer("alt_song_artist_id") - .notNull() - .references(() => artistsTable.songArtistId, { - onDelete: "cascade", - }), - - name: text("name").notNull(), - }, - (t) => ({ - artistIndex: index("artist_alt_names_artist_id_idx").on(t.songArtistId), - altArtistIndex: index("artist_alt_names_alt_artist_id_idx").on( - t.altSongArtistId, - ), - nameIndex: index("artist_alt_names_name_idx").on(t.name), - uniquePerArtistAltArtistName: uniqueIndex( - "artist_alt_names_artist_alt_artist_name_uq", - ).on(t.songArtistId, t.altSongArtistId, t.name), - }), -); - -export const groupAltNamesTable = sqliteTable( - "group_alt_names", - { - id: integer("id").notNull().primaryKey({ autoIncrement: true }), - songGroupId: integer("song_group_id") - .notNull() - .references(() => groupsTable.songGroupId, { onDelete: "cascade" }), - /** - * In the source shape, this is also called `songGroupId` inside the altName object. - * Here we store it as the "context group" the alias is associated with. - */ - contextSongGroupId: integer("context_song_group_id") - .notNull() - .references(() => groupsTable.songGroupId, { onDelete: "cascade" }), - name: text("name").notNull(), - }, - (t) => ({ - groupIndex: index("group_alt_names_group_id_idx").on(t.songGroupId), - contextGroupIndex: index("group_alt_names_context_group_id_idx").on( - t.contextSongGroupId, - ), - nameIndex: index("group_alt_names_name_idx").on(t.name), - uniquePerGroupContextName: uniqueIndex( - "group_alt_names_group_context_name_uq", - ).on(t.songGroupId, t.contextSongGroupId, t.name), - }), -); - -// ---------------------- -// Anime: names / genres / tags -// ---------------------- - -export const animeNamesTable = sqliteTable( - "anime_names", - { - id: integer("id").notNull().primaryKey({ autoIncrement: true }), - annId: integer("ann_id") - .notNull() - .references(() => animeTable.annId, { onDelete: "cascade" }), - - /** "EN" | "JA" per source */ - language: text("language").notNull(), - - name: text("name").notNull(), - }, - (t) => ({ - animeIndex: index("anime_names_ann_id_idx").on(t.annId), - nameIndex: index("anime_names_name_idx").on(t.name), - uniquePerAnime: uniqueIndex("anime_names_ann_lang_name_uq").on( - t.annId, - t.language, - t.name, - ), - }), -); - -export const genresTable = sqliteTable( - "genres", - { - /** Primary key is the genre string itself */ - name: text("name").notNull().primaryKey(), - }, - (t) => ({ - nameIndex: index("genres_name_idx").on(t.name), - }), -); - -export const animeGenresTable = sqliteTable( - "anime_genres", - { - annId: integer("ann_id") - .notNull() - .references(() => animeTable.annId, { onDelete: "cascade" }), - genreName: text("genre_name") - .notNull() - .references(() => genresTable.name, { onDelete: "cascade" }), - }, - (t) => ({ - pk: primaryKey({ - name: "anime_genres_pk", - columns: [t.annId, t.genreName], - }), - animeIndex: index("anime_genres_ann_id_idx").on(t.annId), - genreIndex: index("anime_genres_genre_name_idx").on(t.genreName), - }), -); - -export const tagsTable = sqliteTable( - "tags", - { - /** Primary key is the tag string itself */ - name: text("name").notNull().primaryKey(), - }, - (t) => ({ - nameIndex: index("tags_name_idx").on(t.name), - }), -); - -export const animeTagsTable = sqliteTable( - "anime_tags", - { - annId: integer("ann_id") - .notNull() - .references(() => animeTable.annId, { onDelete: "cascade" }), - tagName: text("tag_name") - .notNull() - .references(() => tagsTable.name, { onDelete: "cascade" }), - }, - (t) => ({ - pk: primaryKey({ - name: "anime_tags_pk", - columns: [t.annId, t.tagName], - }), - animeIndex: index("anime_tags_ann_id_idx").on(t.annId), - tagIndex: index("anime_tags_tag_name_idx").on(t.tagName), - }), -); - -// ---------------------- -// Anime <-> Songs links (from AmqSongLink) -// ---------------------- - -export const animeSongLinksTable = sqliteTable( - "anime_song_links", - { - annId: integer("ann_id") - .notNull() - .references(() => animeTable.annId, { onDelete: "cascade" }), - - annSongId: integer("ann_song_id") - .notNull() - .references(() => songsTable.annSongId, { onDelete: "cascade" }), - - /** 1(OP) | 2(ED) | 3(INS) per SongLinkType */ - type: integer("type").notNull(), - - /** link number within type (1-based in source) */ - number: integer("number").notNull(), - - /** boolean ints 0/1 in source */ - uploaded: integer("uploaded").notNull(), - rebroadcast: integer("rebroadcast").notNull(), - dub: integer("dub").notNull(), - }, - (t) => ({ - pk: primaryKey({ - name: "anime_songs_pk", - columns: [t.annId, t.annSongId], - }), - annIdIndex: index("anime_songs_ann_id_idx").on(t.annId), - songIdIndex: index("anime_songs_song_id_idx").on(t.annSongId), - }), -); - -// ---------------------- -// Relations (optional but helpful for Drizzle queries) -// ---------------------- - -export const animeRelations = relations(animeTable, ({ many }) => ({ - names: many(animeNamesTable), - genres: many(animeGenresTable), - tags: many(animeTagsTable), - songLinks: many(animeSongLinksTable), -})); - -export const songRelations = relations(songsTable, ({ many }) => ({ - animeLinks: many(animeSongLinksTable), -})); - -export const artistRelations = relations(artistsTable, ({ many }) => ({ - inGroups: many(artistGroupsTable), - altNames: many(artistAltNamesTable), - groupMemberships: many(groupArtistMembersTable), -})); - -export const groupRelations = relations(groupsTable, ({ many }) => ({ - artists: many(artistGroupsTable), - artistMembers: many(groupArtistMembersTable), - groupMembers: many(groupGroupMembersTable), - altNames: many(groupAltNamesTable), -})); - -export const animeNamesRelations = relations(animeNamesTable, ({ one }) => ({ - anime: one(animeTable, { - fields: [animeNamesTable.annId], - references: [animeTable.annId], - }), -})); - -export const animeGenresRelations = relations(animeGenresTable, ({ one }) => ({ - anime: one(animeTable, { - fields: [animeGenresTable.annId], - references: [animeTable.annId], - }), - genre: one(genresTable, { - fields: [animeGenresTable.genreName], - references: [genresTable.name], - }), -})); - -export const animeTagsRelations = relations(animeTagsTable, ({ one }) => ({ - anime: one(animeTable, { - fields: [animeTagsTable.annId], - references: [animeTable.annId], - }), - tag: one(tagsTable, { - fields: [animeTagsTable.tagName], - references: [tagsTable.name], - }), -})); - -export const animeSongLinksRelations = relations( - animeSongLinksTable, - ({ one }) => ({ - anime: one(animeTable, { - fields: [animeSongLinksTable.annId], - references: [animeTable.annId], - }), - song: one(songsTable, { - fields: [animeSongLinksTable.annSongId], - references: [songsTable.annSongId], - }), - }), -); +export * from "./relations"; +export * from "./tables/anime"; +export * from "./tables/anime-genres"; +export * from "./tables/anime-names"; +export * from "./tables/anime-song-links"; +export * from "./tables/anime-tags"; +export * from "./tables/artist-alt-names"; +export * from "./tables/artist-groups"; +export * from "./tables/artists"; +export * from "./tables/genres"; +export * from "./tables/group-alt-names"; +export * from "./tables/group-artist-members"; +export * from "./tables/group-group-members"; +export * from "./tables/groups"; +export * from "./tables/songs"; +export * from "./tables/tags"; diff --git a/src/lib/db/schema/relations.ts b/src/lib/db/schema/relations.ts new file mode 100644 index 0000000..ddafb17 --- /dev/null +++ b/src/lib/db/schema/relations.ts @@ -0,0 +1,87 @@ +import { relations } from "drizzle-orm"; +import { animeTable } from "./tables/anime"; +import { animeGenresTable } from "./tables/anime-genres"; +import { animeNamesTable } from "./tables/anime-names"; +import { animeSongLinksTable } from "./tables/anime-song-links"; +import { animeTagsTable } from "./tables/anime-tags"; +import { artistAltNamesTable } from "./tables/artist-alt-names"; +import { artistGroupsTable } from "./tables/artist-groups"; +import { artistsTable } from "./tables/artists"; +import { genresTable } from "./tables/genres"; +import { groupAltNamesTable } from "./tables/group-alt-names"; +import { groupArtistMembersTable } from "./tables/group-artist-members"; +import { groupGroupMembersTable } from "./tables/group-group-members"; +import { groupsTable } from "./tables/groups"; +import { songsTable } from "./tables/songs"; +import { tagsTable } from "./tables/tags"; + +// ---------------------- +// Relations (optional but helpful for Drizzle queries) +// ---------------------- + +export const animeRelations = relations(animeTable, ({ many }) => ({ + names: many(animeNamesTable), + genres: many(animeGenresTable), + tags: many(animeTagsTable), + songLinks: many(animeSongLinksTable), +})); + +export const songRelations = relations(songsTable, ({ many }) => ({ + animeLinks: many(animeSongLinksTable), +})); + +export const artistRelations = relations(artistsTable, ({ many }) => ({ + inGroups: many(artistGroupsTable), + altNames: many(artistAltNamesTable), + groupMemberships: many(groupArtistMembersTable), +})); + +export const groupRelations = relations(groupsTable, ({ many }) => ({ + artists: many(artistGroupsTable), + artistMembers: many(groupArtistMembersTable), + groupMembers: many(groupGroupMembersTable), + altNames: many(groupAltNamesTable), +})); + +export const animeNamesRelations = relations(animeNamesTable, ({ one }) => ({ + anime: one(animeTable, { + fields: [animeNamesTable.annId], + references: [animeTable.annId], + }), +})); + +export const animeGenresRelations = relations(animeGenresTable, ({ one }) => ({ + anime: one(animeTable, { + fields: [animeGenresTable.annId], + references: [animeTable.annId], + }), + genre: one(genresTable, { + fields: [animeGenresTable.genreName], + references: [genresTable.name], + }), +})); + +export const animeTagsRelations = relations(animeTagsTable, ({ one }) => ({ + anime: one(animeTable, { + fields: [animeTagsTable.annId], + references: [animeTable.annId], + }), + tag: one(tagsTable, { + fields: [animeTagsTable.tagName], + references: [tagsTable.name], + }), +})); + +export const animeSongLinksRelations = relations( + animeSongLinksTable, + ({ one }) => ({ + anime: one(animeTable, { + fields: [animeSongLinksTable.annId], + references: [animeTable.annId], + }), + song: one(songsTable, { + fields: [animeSongLinksTable.annSongId], + references: [songsTable.annSongId], + }), + }), +); diff --git a/src/lib/db/schema/tables/anime-genres.ts b/src/lib/db/schema/tables/anime-genres.ts new file mode 100644 index 0000000..6b6baea --- /dev/null +++ b/src/lib/db/schema/tables/anime-genres.ts @@ -0,0 +1,34 @@ +import { + index, + integer, + primaryKey, + sqliteTable, + text, +} from "drizzle-orm/sqlite-core"; +import { animeTable } from "./anime"; +import { genresTable } from "./genres"; + +/** + * Join table: Anime -> Genres + * + * Source: AmqAnimeSchema.genres (string[]) + */ +export const animeGenresTable = sqliteTable( + "anime_genres", + { + annId: integer("ann_id") + .notNull() + .references(() => animeTable.annId, { onDelete: "cascade" }), + genreName: text("genre_name") + .notNull() + .references(() => genresTable.name, { onDelete: "cascade" }), + }, + (t) => ({ + pk: primaryKey({ + name: "anime_genres_pk", + columns: [t.annId, t.genreName], + }), + animeIndex: index("anime_genres_ann_id_idx").on(t.annId), + genreIndex: index("anime_genres_genre_name_idx").on(t.genreName), + }), +); diff --git a/src/lib/db/schema/tables/anime-names.ts b/src/lib/db/schema/tables/anime-names.ts new file mode 100644 index 0000000..580f85a --- /dev/null +++ b/src/lib/db/schema/tables/anime-names.ts @@ -0,0 +1,40 @@ +import { + index, + integer, + sqliteTable, + text, + uniqueIndex, +} from "drizzle-orm/sqlite-core"; +import { animeTable } from "./anime"; + +/** + * Additional localized/alternative names for an anime. + * + * Source: AmqAnimeSchema.names + * - language: "EN" | "JA" (per source) + * - name: string + */ +export const animeNamesTable = sqliteTable( + "anime_names", + { + id: integer("id").notNull().primaryKey({ autoIncrement: true }), + + annId: integer("ann_id") + .notNull() + .references(() => animeTable.annId, { onDelete: "cascade" }), + + /** "EN" | "JA" per source */ + language: text("language").notNull(), + + name: text("name").notNull(), + }, + (t) => ({ + animeIndex: index("anime_names_ann_id_idx").on(t.annId), + nameIndex: index("anime_names_name_idx").on(t.name), + uniquePerAnime: uniqueIndex("anime_names_ann_lang_name_uq").on( + t.annId, + t.language, + t.name, + ), + }), +); diff --git a/src/lib/db/schema/tables/anime-song-links.ts b/src/lib/db/schema/tables/anime-song-links.ts new file mode 100644 index 0000000..b41b2af --- /dev/null +++ b/src/lib/db/schema/tables/anime-song-links.ts @@ -0,0 +1,45 @@ +import { + index, + integer, + primaryKey, + sqliteTable, +} from "drizzle-orm/sqlite-core"; +import { animeTable } from "./anime"; +import { songsTable } from "./songs"; + +/** + * Join table: Anime <-> Songs links + * + * Source: AmqAnimeSchema.songLinks (AmqSongLink[]) + */ +export const animeSongLinksTable = sqliteTable( + "anime_song_links", + { + annId: integer("ann_id") + .notNull() + .references(() => animeTable.annId, { onDelete: "cascade" }), + + annSongId: integer("ann_song_id") + .notNull() + .references(() => songsTable.annSongId, { onDelete: "cascade" }), + + /** 1(OP) | 2(ED) | 3(INS) per SongLinkType */ + type: integer("type").notNull(), + + /** link number within type (1-based in source) */ + number: integer("number").notNull(), + + /** boolean ints 0/1 in source */ + uploaded: integer("uploaded").notNull(), + rebroadcast: integer("rebroadcast").notNull(), + dub: integer("dub").notNull(), + }, + (t) => ({ + pk: primaryKey({ + name: "anime_songs_pk", + columns: [t.annId, t.annSongId], + }), + annIdIndex: index("anime_songs_ann_id_idx").on(t.annId), + annSongIdIndex: index("anime_songs_song_id_idx").on(t.annSongId), + }), +); diff --git a/src/lib/db/schema/tables/anime-tags.ts b/src/lib/db/schema/tables/anime-tags.ts new file mode 100644 index 0000000..5a0caf4 --- /dev/null +++ b/src/lib/db/schema/tables/anime-tags.ts @@ -0,0 +1,34 @@ +import { + index, + integer, + primaryKey, + sqliteTable, + text, +} from "drizzle-orm/sqlite-core"; +import { animeTable } from "./anime"; +import { tagsTable } from "./tags"; + +/** + * Join table: Anime -> Tags + * + * Source: AmqAnimeSchema.tags (string[]) + */ +export const animeTagsTable = sqliteTable( + "anime_tags", + { + annId: integer("ann_id") + .notNull() + .references(() => animeTable.annId, { onDelete: "cascade" }), + tagName: text("tag_name") + .notNull() + .references(() => tagsTable.name, { onDelete: "cascade" }), + }, + (t) => ({ + pk: primaryKey({ + name: "anime_tags_pk", + columns: [t.annId, t.tagName], + }), + animeIndex: index("anime_tags_ann_id_idx").on(t.annId), + tagIndex: index("anime_tags_tag_name_idx").on(t.tagName), + }), +); diff --git a/src/lib/db/schema/tables/anime.ts b/src/lib/db/schema/tables/anime.ts new file mode 100644 index 0000000..4b2e699 --- /dev/null +++ b/src/lib/db/schema/tables/anime.ts @@ -0,0 +1,45 @@ +import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; + +/** + * Core `anime` table. + * + * Normalized schema for AMQ source data (imported from JSON). + * Keeps original AMQ integer identifiers where present (annId, etc.). + * + * Source: AmqAnimeSchema + */ +export const animeTable = sqliteTable( + "anime", + { + /** AMQ anime ID */ + annId: integer("ann_id").notNull().primaryKey(), + + // External IDs from the source + aniListId: integer("anilist_id"), + malId: integer("mal_id").notNull(), + kitsuId: integer("kitsu_id"), + + // Category object (name + number that can be number|string|null in source) + categoryName: text("category_name").notNull(), + categoryNumber: text("category_number"), + + // Names + mainName: text("main_name").notNull(), + mainNameEn: text("main_name_en"), + mainNameJa: text("main_name_ja"), + + // Season/year + year: integer("year").notNull(), + seasonId: integer("season_id").notNull(), // 0..3 from Season enum + + // Counts + opCount: integer("op_count").notNull(), + edCount: integer("ed_count").notNull(), + insertCount: integer("insert_count").notNull(), + }, + (t) => ({ + aniListIndex: index("anime_anilist_id_uq").on(t.aniListId), + malIndex: index("anime_mal_id_uq").on(t.malId), + kitsuIndex: index("anime_kitsu_id_uq").on(t.kitsuId), + }), +); diff --git a/src/lib/db/schema/tables/artist-alt-names.ts b/src/lib/db/schema/tables/artist-alt-names.ts new file mode 100644 index 0000000..28d7304 --- /dev/null +++ b/src/lib/db/schema/tables/artist-alt-names.ts @@ -0,0 +1,55 @@ +import { + index, + integer, + sqliteTable, + text, + uniqueIndex, +} from "drizzle-orm/sqlite-core"; +import { artistsTable } from "./artists"; + +/** + * Alternative names for artists. + * + * Source: AmqArtistSchema.altNames + * + * Interpreted as: artist `songArtistId` is also known as `name` (optionally via altSongArtistId link). + */ +export const artistAltNamesTable = sqliteTable( + "artist_alt_names", + { + id: integer("id").notNull().primaryKey({ autoIncrement: true }), + + /** + * The "owning" artist. + */ + songArtistId: integer("song_artist_id") + .notNull() + .references(() => artistsTable.songArtistId, { + onDelete: "cascade", + }), + + /** + * The alternate-name entry is keyed by an artist id in the source: + * { songArtistId, name } + * + * Store as a linked artist row for referential integrity. + */ + altSongArtistId: integer("alt_song_artist_id") + .notNull() + .references(() => artistsTable.songArtistId, { + onDelete: "cascade", + }), + + name: text("name").notNull(), + }, + (t) => ({ + artistIndex: index("artist_alt_names_artist_id_idx").on(t.songArtistId), + altArtistIndex: index("artist_alt_names_alt_artist_id_idx").on( + t.altSongArtistId, + ), + nameIndex: index("artist_alt_names_name_idx").on(t.name), + uniquePerArtistAltArtistName: uniqueIndex( + "artist_alt_names_artist_alt_artist_name_uq", + ).on(t.songArtistId, t.altSongArtistId, t.name), + }), +); diff --git a/src/lib/db/schema/tables/artist-groups.ts b/src/lib/db/schema/tables/artist-groups.ts new file mode 100644 index 0000000..bfce034 --- /dev/null +++ b/src/lib/db/schema/tables/artist-groups.ts @@ -0,0 +1,35 @@ +import { + index, + integer, + primaryKey, + sqliteTable, +} from "drizzle-orm/sqlite-core"; +import { artistsTable } from "./artists"; +import { groupsTable } from "./groups"; + +/** + * Join table: Artist -> Groups membership + * + * Source: AmqArtistSchema.inGroups + */ +export const artistGroupsTable = sqliteTable( + "artist_groups", + { + songArtistId: integer("song_artist_id") + .notNull() + .references(() => artistsTable.songArtistId, { + onDelete: "cascade", + }), + songGroupId: integer("song_group_id") + .notNull() + .references(() => groupsTable.songGroupId, { onDelete: "cascade" }), + }, + (t) => ({ + pk: primaryKey({ + name: "artist_groups_pk", + columns: [t.songArtistId, t.songGroupId], + }), + artistIndex: index("artist_groups_artist_id_idx").on(t.songArtistId), + groupIndex: index("artist_groups_group_id_idx").on(t.songGroupId), + }), +); diff --git a/src/lib/db/schema/tables/artists.ts b/src/lib/db/schema/tables/artists.ts new file mode 100644 index 0000000..57d4780 --- /dev/null +++ b/src/lib/db/schema/tables/artists.ts @@ -0,0 +1,18 @@ +import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; + +/** + * Core `artists` table. + * + * Source: AmqArtistSchema + */ +export const artistsTable = sqliteTable( + "artists", + { + /** AMQ songArtistId */ + songArtistId: integer("song_artist_id").notNull().primaryKey(), + name: text("name").notNull(), + }, + (t) => ({ + nameIndex: index("artists_name_idx").on(t.name), + }), +); diff --git a/src/lib/db/schema/tables/genres.ts b/src/lib/db/schema/tables/genres.ts new file mode 100644 index 0000000..fbc0862 --- /dev/null +++ b/src/lib/db/schema/tables/genres.ts @@ -0,0 +1,17 @@ +import { index, sqliteTable, text } from "drizzle-orm/sqlite-core"; + +/** + * Core `genres` lookup table. + * + * Source: AmqAnimeSchema.genres (string[]) + */ +export const genresTable = sqliteTable( + "genres", + { + /** Primary key is the genre string itself */ + name: text("name").notNull().primaryKey(), + }, + (t) => ({ + nameIndex: index("genres_name_idx").on(t.name), + }), +); diff --git a/src/lib/db/schema/tables/group-alt-names.ts b/src/lib/db/schema/tables/group-alt-names.ts new file mode 100644 index 0000000..b2eb58a --- /dev/null +++ b/src/lib/db/schema/tables/group-alt-names.ts @@ -0,0 +1,51 @@ +import { + index, + integer, + sqliteTable, + text, + uniqueIndex, +} from "drizzle-orm/sqlite-core"; +import { groupsTable } from "./groups"; + +/** + * Alternative names for groups. + * + * Source: AmqGroupSchema.altNames + * + * The source uses objects shaped like `{ songGroupId, name }`, but that nested + * `songGroupId` is effectively the *context group* the alias is associated with. + * We persist it as `contextSongGroupId` to make the meaning explicit. + */ +export const groupAltNamesTable = sqliteTable( + "group_alt_names", + { + id: integer("id").notNull().primaryKey({ autoIncrement: true }), + + /** + * The "owning" group. + */ + songGroupId: integer("song_group_id") + .notNull() + .references(() => groupsTable.songGroupId, { onDelete: "cascade" }), + + /** + * Context group the alias is associated with. + * (This is the nested `songGroupId` value in the source.) + */ + contextSongGroupId: integer("context_song_group_id") + .notNull() + .references(() => groupsTable.songGroupId, { onDelete: "cascade" }), + + name: text("name").notNull(), + }, + (t) => ({ + groupIndex: index("group_alt_names_group_id_idx").on(t.songGroupId), + contextGroupIndex: index("group_alt_names_context_group_id_idx").on( + t.contextSongGroupId, + ), + nameIndex: index("group_alt_names_name_idx").on(t.name), + uniquePerGroupContextName: uniqueIndex( + "group_alt_names_group_context_name_uq", + ).on(t.songGroupId, t.contextSongGroupId, t.name), + }), +); diff --git a/src/lib/db/schema/tables/group-artist-members.ts b/src/lib/db/schema/tables/group-artist-members.ts new file mode 100644 index 0000000..d00cb33 --- /dev/null +++ b/src/lib/db/schema/tables/group-artist-members.ts @@ -0,0 +1,35 @@ +import { + index, + integer, + primaryKey, + sqliteTable, +} from "drizzle-orm/sqlite-core"; +import { artistsTable } from "./artists"; +import { groupsTable } from "./groups"; + +/** + * Join table: Group -> Artist members + * + * Source: AmqGroupSchema.artistMembers + */ +export const groupArtistMembersTable = sqliteTable( + "group_artist_members", + { + songGroupId: integer("song_group_id") + .notNull() + .references(() => groupsTable.songGroupId, { onDelete: "cascade" }), + songArtistId: integer("song_artist_id") + .notNull() + .references(() => artistsTable.songArtistId, { + onDelete: "cascade", + }), + }, + (t) => ({ + pk: primaryKey({ + name: "group_artist_members_pk", + columns: [t.songGroupId, t.songArtistId], + }), + groupIndex: index("group_artist_members_group_id_idx").on(t.songGroupId), + artistIndex: index("group_artist_members_artist_id_idx").on(t.songArtistId), + }), +); diff --git a/src/lib/db/schema/tables/group-group-members.ts b/src/lib/db/schema/tables/group-group-members.ts new file mode 100644 index 0000000..397c07f --- /dev/null +++ b/src/lib/db/schema/tables/group-group-members.ts @@ -0,0 +1,34 @@ +import { + index, + integer, + primaryKey, + sqliteTable, +} from "drizzle-orm/sqlite-core"; +import { groupsTable } from "./groups"; + +/** + * Join table: Group -> Group members + * + * Source: AmqGroupSchema.groupMembers + */ +export const groupGroupMembersTable = sqliteTable( + "group_group_members", + { + songGroupId: integer("song_group_id") + .notNull() + .references(() => groupsTable.songGroupId, { onDelete: "cascade" }), + memberSongGroupId: integer("member_song_group_id") + .notNull() + .references(() => groupsTable.songGroupId, { onDelete: "cascade" }), + }, + (t) => ({ + pk: primaryKey({ + name: "group_group_members_pk", + columns: [t.songGroupId, t.memberSongGroupId], + }), + groupIndex: index("group_group_members_group_id_idx").on(t.songGroupId), + memberIndex: index("group_group_members_member_group_id_idx").on( + t.memberSongGroupId, + ), + }), +); diff --git a/src/lib/db/schema/tables/groups.ts b/src/lib/db/schema/tables/groups.ts new file mode 100644 index 0000000..20afd8f --- /dev/null +++ b/src/lib/db/schema/tables/groups.ts @@ -0,0 +1,18 @@ +import { index, integer, sqliteTable, text } from "drizzle-orm/sqlite-core"; + +/** + * Core `groups` table. + * + * Source: AmqGroupSchema + */ +export const groupsTable = sqliteTable( + "groups", + { + /** AMQ songGroupId */ + songGroupId: integer("song_group_id").notNull().primaryKey(), + name: text("name").notNull(), + }, + (t) => ({ + nameIndex: index("groups_name_idx").on(t.name), + }), +); diff --git a/src/lib/db/schema/tables/songs.ts b/src/lib/db/schema/tables/songs.ts new file mode 100644 index 0000000..eec584b --- /dev/null +++ b/src/lib/db/schema/tables/songs.ts @@ -0,0 +1,80 @@ +import { + index, + integer, + real, + sqliteTable, + text, +} from "drizzle-orm/sqlite-core"; +import { artistsTable } from "./artists"; +import { groupsTable } from "./groups"; + +/** + * Core `songs` table. + * + * Source: AmqSongSchema + */ +export const songsTable = sqliteTable( + "songs", + { + /** AMQ annSongId (ANN song id) */ + annSongId: integer("ann_song_id").notNull().primaryKey(), + + /** AMQ songId */ + songId: integer("song_id").notNull(), + + name: text("name").notNull(), + + /** + * AmqSongCategory enum from the source: + * none(0), instrumental(1), chanting(2), character(3), standard(4) + */ + category: integer("category").notNull(), + + /** + * Song audio filename used by the AMQ CDN: + * https://nawdist.animemusicquiz.com/{fileName} + */ + fileName: text("file_name"), + fileName480: text("file_name_480"), + fileName720: text("file_name_720"), + meanVolume: real("mean_volume"), + + /** + * Primary artist/group ids for this song (nullable in source). + * These reference existing `artists` / `groups` rows. + */ + songArtistId: integer("song_artist_id").references( + () => artistsTable.songArtistId, + { onDelete: "set null" }, + ), + songGroupId: integer("song_group_id").references( + () => groupsTable.songGroupId, + { onDelete: "set null" }, + ), + + /** + * Additional contributor ids (nullable in source) + */ + composerArtistId: integer("composer_artist_id").references( + () => artistsTable.songArtistId, + { onDelete: "set null" }, + ), + composerGroupId: integer("composer_group_id").references( + () => groupsTable.songGroupId, + { onDelete: "set null" }, + ), + arrangerArtistId: integer("arranger_artist_id").references( + () => artistsTable.songArtistId, + { onDelete: "set null" }, + ), + arrangerGroupId: integer("arranger_group_id").references( + () => groupsTable.songGroupId, + { onDelete: "set null" }, + ), + }, + (t) => ({ + songIdIndex: index("songs_song_id_idx").on(t.songId), + songArtistIdIndex: index("songs_song_artist_id_idx").on(t.songArtistId), + songGroupIdIndex: index("songs_song_group_id_idx").on(t.songGroupId), + }), +); diff --git a/src/lib/db/schema/tables/tags.ts b/src/lib/db/schema/tables/tags.ts new file mode 100644 index 0000000..d0145a6 --- /dev/null +++ b/src/lib/db/schema/tables/tags.ts @@ -0,0 +1,17 @@ +import { index, sqliteTable, text } from "drizzle-orm/sqlite-core"; + +/** + * Core `tags` lookup table. + * + * Source: AmqAnimeSchema.tags (string[]) + */ +export const tagsTable = sqliteTable( + "tags", + { + /** Primary key is the tag string itself */ + name: text("name").notNull().primaryKey(), + }, + (t) => ({ + nameIndex: index("tags_name_idx").on(t.name), + }), +);