diff --git a/src/lib/db/schema/index.ts b/src/lib/db/schema/index.ts index 8d5bc6a..0d0d559 100644 --- a/src/lib/db/schema/index.ts +++ b/src/lib/db/schema/index.ts @@ -1,17 +1,411 @@ -import { sqliteTable } from "drizzle-orm/sqlite-core"; +import { relations } from "drizzle-orm"; +import { + index, + integer, + primaryKey, + sqliteTable, + text, + uniqueIndex, +} from "drizzle-orm/sqlite-core"; -export const animeTable = sqliteTable("anime", { - // TODO -}); +/** + * 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). + */ -export const songsTable = sqliteTable("songs", { - // TODO -}); +// ---------------------- +// Core tables +// ---------------------- -export const animeSongsTable = sqliteTable("anime_songs", { - // TODO -}); +export const animeTable = sqliteTable( + "anime", + { + /** AMQ anime ID */ + annId: integer("ann_id").notNull().primaryKey(), -export const artistsTable = sqliteTable("artists", { - // TODO -}); + // 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: uniqueIndex("anime_anilist_id_uq").on(t.aniListId), + malIndex: uniqueIndex("anime_mal_id_uq").on(t.malId), + kitsuIndex: uniqueIndex("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(), + }, + (t) => ({ + songIdIndex: index("songs_song_id_idx").on(t.songId), + }), +); + +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 animeGenresTable = sqliteTable( + "anime_genres", + { + id: integer("id").notNull().primaryKey({ autoIncrement: true }), + annId: integer("ann_id") + .notNull() + .references(() => animeTable.annId, { onDelete: "cascade" }), + genre: text("genre").notNull(), + }, + (t) => ({ + animeIndex: index("anime_genres_ann_id_idx").on(t.annId), + genreIndex: index("anime_genres_genre_idx").on(t.genre), + uniquePerAnime: uniqueIndex("anime_genres_ann_genre_uq").on( + t.annId, + t.genre, + ), + }), +); + +export const animeTagsTable = sqliteTable( + "anime_tags", + { + id: integer("id").notNull().primaryKey({ autoIncrement: true }), + annId: integer("ann_id") + .notNull() + .references(() => animeTable.annId, { onDelete: "cascade" }), + tag: text("tag").notNull(), + }, + (t) => ({ + animeIndex: index("anime_tags_ann_id_idx").on(t.annId), + tagIndex: index("anime_tags_tag_idx").on(t.tag), + uniquePerAnime: uniqueIndex("anime_tags_ann_tag_uq").on(t.annId, t.tag), + }), +); + +// ---------------------- +// 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], + }), +})); + +export const animeTagsRelations = relations(animeTagsTable, ({ one }) => ({ + anime: one(animeTable, { + fields: [animeTagsTable.annId], + references: [animeTable.annId], + }), +})); + +export const animeSongLinksRelations = relations( + animeSongLinksTable, + ({ one }) => ({ + anime: one(animeTable, { + fields: [animeSongLinksTable.annId], + references: [animeTable.annId], + }), + song: one(songsTable, { + fields: [animeSongLinksTable.annSongId], + references: [songsTable.annSongId], + }), + }), +);