import { relations, sql } from "drizzle-orm"; import { bigint, boolean, index, integer, jsonb, numeric, pgSchema, primaryKey, text, timestamp, uniqueIndex, uuid, varchar, } from "drizzle-orm/pg-core"; import type { CatalogConstellation, CatalogTalent } from "@/lib/catalog/lunaris"; const authSchema = pgSchema("auth"); const mediaSchema = pgSchema("media"); const editorSchema = pgSchema("editor"); const catalogSchema = pgSchema("catalog"); const guidesSchema = pgSchema("guides"); const operationsSchema = pgSchema("operations"); const timestamps = { createdAt: timestamp("created_at", { withTimezone: true }) .defaultNow() .notNull(), updatedAt: timestamp("updated_at", { withTimezone: true }) .defaultNow() .$onUpdate(() => new Date()) .notNull(), }; // Better Auth core tables. Credential registration is restricted to // ADMIN_EMAIL, which is rechecked at every protected application boundary. export const users = authSchema.table( "user", { id: text("id").primaryKey(), name: text("name").notNull(), email: text("email").notNull(), emailVerified: boolean("email_verified").default(false).notNull(), image: text("image"), ...timestamps, }, (table) => [uniqueIndex("user_email_unique").on(table.email)], ); export const sessions = authSchema.table( "session", { id: text("id").primaryKey(), expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), token: text("token").notNull(), ipAddress: text("ip_address"), userAgent: text("user_agent"), userId: text("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), ...timestamps, }, (table) => [ uniqueIndex("session_token_unique").on(table.token), index("session_user_id_idx").on(table.userId), ], ); export const accounts = authSchema.table( "account", { id: text("id").primaryKey(), issuer: text("issuer").notNull(), accountId: text("account_id").notNull(), providerId: text("provider_id").notNull(), userId: text("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), accessToken: text("access_token"), refreshToken: text("refresh_token"), idToken: text("id_token"), accessTokenExpiresAt: timestamp("access_token_expires_at", { withTimezone: true, }), refreshTokenExpiresAt: timestamp("refresh_token_expires_at", { withTimezone: true, }), scope: text("scope"), password: text("password"), ...timestamps, }, (table) => [ uniqueIndex("account_issuer_unique").on(table.issuer, table.accountId), index("account_user_id_idx").on(table.userId), ], ); export const verifications = authSchema.table( "verification", { id: text("id").primaryKey(), identifier: text("identifier").notNull(), value: text("value").notNull(), expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), ...timestamps, }, (table) => [index("verification_identifier_idx").on(table.identifier)], ); export const media = mediaSchema.table( "media", { id: uuid("id").defaultRandom().primaryKey(), objectKey: text("object_key").notNull(), fileName: text("file_name").notNull(), mimeType: varchar("mime_type", { length: 64 }).notNull(), byteSize: bigint("byte_size", { mode: "number" }).notNull(), checksum: varchar("checksum", { length: 128 }), width: integer("width"), height: integer("height"), status: varchar("status", { length: 24 }).default("pending").notNull(), currentReferenceCount: integer("current_reference_count") .default(0) .notNull(), revisionReferenceCount: integer("revision_reference_count") .default(0) .notNull(), createdById: text("created_by_id").references(() => users.id, { onDelete: "set null", }), ...timestamps, }, (table) => [ uniqueIndex("media_object_key_unique").on(table.objectKey), index("media_visibility_idx").on( table.status, table.currentReferenceCount, ), ], ); export const characters = editorSchema.table( "character", { id: uuid("id").defaultRandom().primaryKey(), slug: varchar("slug", { length: 120 }).notNull(), nameTh: text("name_th").notNull(), nameEn: text("name_en"), description: text("description"), portraitMediaId: uuid("portrait_media_id").references(() => media.id, { onDelete: "set null", }), element: varchar("element", { length: 48 }), role: varchar("role", { length: 80 }), rarity: integer("rarity"), sortOrder: integer("sort_order").default(0).notNull(), visible: boolean("visible").default(false).notNull(), ...timestamps, }, (table) => [ uniqueIndex("character_slug_unique").on(table.slug), index("character_directory_idx").on(table.visible, table.sortOrder), ], ); export const pages = editorSchema.table( "page", { id: uuid("id").defaultRandom().primaryKey(), characterId: uuid("character_id") .notNull() .references(() => characters.id, { onDelete: "cascade" }), slug: varchar("slug", { length: 120 }).notNull(), title: text("title").notNull(), navOrder: integer("nav_order").default(0).notNull(), visible: boolean("visible").default(false).notNull(), publicNote: text("public_note"), version: integer("version").default(1).notNull(), ...timestamps, }, (table) => [ uniqueIndex("page_character_slug_unique").on( table.characterId, table.slug, ), index("page_navigation_idx").on( table.characterId, table.visible, table.navOrder, ), ], ); export const blocks = editorSchema.table( "block", { id: uuid("id").primaryKey(), pageId: uuid("page_id") .notNull() .references(() => pages.id, { onDelete: "cascade" }), type: varchar("type", { length: 64 }).notNull(), schemaVersion: integer("schema_version").default(1).notNull(), config: jsonb("config").$type>().notNull(), responsiveLayout: jsonb("responsive_layout") .$type>() .default({}) .notNull(), sortOrder: integer("sort_order").default(0).notNull(), ...timestamps, }, (table) => [index("block_page_order_idx").on(table.pageId, table.sortOrder)], ); export const dataSources = editorSchema.table( "data_source", { id: uuid("id").defaultRandom().primaryKey(), name: text("name").notNull(), description: text("description"), fields: jsonb("fields").$type().default([]).notNull(), currentVersion: integer("current_version").default(1).notNull(), ...timestamps, }, (table) => [index("data_source_name_idx").on(table.name)], ); export const dataSourceVersions = editorSchema.table( "data_source_version", { id: uuid("id").defaultRandom().primaryKey(), dataSourceId: uuid("data_source_id") .notNull() .references(() => dataSources.id, { onDelete: "cascade" }), version: integer("version").notNull(), rows: jsonb("rows").$type().default([]).notNull(), evaluation: jsonb("evaluation").$type>(), createdById: text("created_by_id").references(() => users.id, { onDelete: "set null", }), createdAt: timestamp("created_at", { withTimezone: true }) .defaultNow() .notNull(), }, (table) => [ uniqueIndex("data_source_version_unique").on( table.dataSourceId, table.version, ), ], ); export const pageDataSources = editorSchema.table( "page_data_source", { pageId: uuid("page_id") .notNull() .references(() => pages.id, { onDelete: "cascade" }), dataSourceId: uuid("data_source_id") .notNull() .references(() => dataSources.id, { onDelete: "restrict" }), pinnedVersion: integer("pinned_version"), }, (table) => [ primaryKey({ columns: [table.pageId, table.dataSourceId] }), index("page_data_source_dependency_idx").on(table.dataSourceId), ], ); export const templates = editorSchema.table( "template", { id: uuid("id").defaultRandom().primaryKey(), slug: varchar("slug", { length: 120 }).notNull(), name: text("name").notNull(), description: text("description"), category: varchar("category", { length: 64 }).notNull(), structure: jsonb("structure").$type>().notNull(), builtIn: boolean("built_in").default(false).notNull(), ...timestamps, }, (table) => [uniqueIndex("template_slug_unique").on(table.slug)], ); export const revisions = editorSchema.table( "revision", { id: uuid("id").defaultRandom().primaryKey(), pageId: uuid("page_id") .notNull() .references(() => pages.id, { onDelete: "cascade" }), pageVersion: integer("page_version").notNull(), kind: varchar("kind", { length: 24 }).notNull(), label: text("label"), authorId: text("author_id").references(() => users.id, { onDelete: "set null", }), sourceRevisionId: uuid("source_revision_id"), checkpointHour: timestamp("checkpoint_hour", { withTimezone: true }), snapshot: jsonb("snapshot").$type>().notNull(), dataSourceVersions: jsonb("data_source_versions") .$type>() .default({}) .notNull(), mediaIds: jsonb("media_ids").$type().default([]).notNull(), createdAt: timestamp("created_at", { withTimezone: true }) .defaultNow() .notNull(), expiresAt: timestamp("expires_at", { withTimezone: true }), }, (table) => [ index("revision_page_created_idx").on(table.pageId, table.createdAt), uniqueIndex("revision_checkpoint_hour_unique") .on(table.pageId, table.checkpointHour) .where(sql`${table.kind} = 'checkpoint'`), ], ); export const publicSnapshots = editorSchema.table( "public_snapshot", { id: uuid("id").defaultRandom().primaryKey(), pageId: uuid("page_id") .notNull() .references(() => pages.id, { onDelete: "cascade" }), pageVersion: integer("page_version").notNull(), versionVector: jsonb("version_vector") .$type>() .default({}) .notNull(), vectorHash: varchar("vector_hash", { length: 64 }).notNull(), dto: jsonb("dto").$type>().notNull(), visible: boolean("visible").default(false).notNull(), createdAt: timestamp("created_at", { withTimezone: true }) .defaultNow() .notNull(), }, (table) => [ uniqueIndex("public_snapshot_version_unique").on( table.pageId, table.pageVersion, table.vectorHash, ), index("public_snapshot_lookup_idx").on( table.pageId, table.visible, table.createdAt, ), ], ); export const slugAliases = editorSchema.table( "slug_alias", { id: uuid("id").defaultRandom().primaryKey(), kind: varchar("kind", { length: 24 }).notNull(), oldCharacterSlug: varchar("old_character_slug", { length: 120 }).notNull(), oldPageSlug: varchar("old_page_slug", { length: 120 }), targetCharacterId: uuid("target_character_id") .notNull() .references(() => characters.id, { onDelete: "cascade" }), targetPageId: uuid("target_page_id").references(() => pages.id, { onDelete: "cascade", }), createdAt: timestamp("created_at", { withTimezone: true }) .defaultNow() .notNull(), }, (table) => [ uniqueIndex("slug_alias_path_unique").on( table.oldCharacterSlug, table.oldPageSlug, ), ], ); export const mediaReferences = mediaSchema.table( "media_reference", { mediaId: uuid("media_id") .notNull() .references(() => media.id, { onDelete: "cascade" }), ownerType: varchar("owner_type", { length: 24 }).notNull(), ownerId: uuid("owner_id").notNull(), referenceKind: varchar("reference_kind", { length: 24 }).notNull(), createdAt: timestamp("created_at", { withTimezone: true }) .defaultNow() .notNull(), }, (table) => [ primaryKey({ columns: [ table.mediaId, table.ownerType, table.ownerId, table.referenceKind, ], }), index("media_reference_owner_idx").on(table.ownerType, table.ownerId), ], ); export const outboxEvents = operationsSchema.table( "outbox_event", { id: bigint("id", { mode: "number" }) .primaryKey() .generatedAlwaysAsIdentity(), topic: varchar("topic", { length: 160 }).notNull(), aggregateId: uuid("aggregate_id").notNull(), eventType: varchar("event_type", { length: 64 }).notNull(), payload: jsonb("payload").$type>().notNull(), status: varchar("status", { length: 24 }).default("pending").notNull(), attempts: integer("attempts").default(0).notNull(), availableAt: timestamp("available_at", { withTimezone: true }) .defaultNow() .notNull(), processedAt: timestamp("processed_at", { withTimezone: true }), lastError: text("last_error"), createdAt: timestamp("created_at", { withTimezone: true }) .defaultNow() .notNull(), }, (table) => [ index("outbox_pending_idx").on( table.status, table.availableAt, table.id, ), ], ); export const catalogVersions = catalogSchema.table( "catalog_version", { version: varchar("version", { length: 32 }).primaryKey(), active: boolean("active").default(false).notNull(), sourceTimestamp: bigint("source_timestamp", { mode: "number" }), syncedAt: timestamp("synced_at", { withTimezone: true }).defaultNow().notNull(), }, (table) => [uniqueIndex("catalog_active_unique").on(table.active).where(sql`${table.active} = true`)], ); export const catalogCharacters = catalogSchema.table( "catalog_character", { version: varchar("version", { length: 32 }) .notNull() .references(() => catalogVersions.version, { onDelete: "cascade" }), key: varchar("key", { length: 64 }).notNull(), name: text("name").notNull(), element: varchar("element", { length: 48 }), rarity: integer("rarity"), weaponType: varchar("weapon_type", { length: 64 }), imageKey: text("image_key").notNull(), elementIconKey: text("element_icon_key"), constellations: jsonb("constellations").$type().default([]).notNull(), talents: jsonb("talents").$type().default([]).notNull(), }, (table) => [ primaryKey({ columns: [table.version, table.key] }), index("catalog_character_name_idx").on(table.version, table.name), ], ); export const catalogWeapons = catalogSchema.table( "catalog_weapon", { version: varchar("version", { length: 32 }) .notNull() .references(() => catalogVersions.version, { onDelete: "cascade" }), key: varchar("key", { length: 64 }).notNull(), name: text("name").notNull(), rarity: integer("rarity"), weaponType: varchar("weapon_type", { length: 64 }), imageKey: text("image_key").notNull(), }, (table) => [ primaryKey({ columns: [table.version, table.key] }), index("catalog_weapon_name_idx").on(table.version, table.name), ], ); export const catalogArtifacts = catalogSchema.table( "catalog_artifact", { version: varchar("version", { length: 32 }) .notNull() .references(() => catalogVersions.version, { onDelete: "cascade" }), key: varchar("key", { length: 64 }).notNull(), name: text("name").notNull(), rarity: integer("rarity"), imageKey: text("image_key").notNull(), }, (table) => [ primaryKey({ columns: [table.version, table.key] }), index("catalog_artifact_name_idx").on(table.version, table.name), ], ); export const catalogSyncJobs = catalogSchema.table( "catalog_sync_job", { id: uuid("id").defaultRandom().primaryKey(), status: varchar("status", { length: 24 }).default("pending").notNull(), version: varchar("version", { length: 32 }), progress: integer("progress").default(0).notNull(), total: integer("total").default(0).notNull(), error: text("error"), triggeredById: text("triggered_by_id").references(() => users.id, { onDelete: "set null" }), startedAt: timestamp("started_at", { withTimezone: true }), completedAt: timestamp("completed_at", { withTimezone: true }), ...timestamps, }, (table) => [index("catalog_sync_status_idx").on(table.status, table.createdAt)], ); export const guides = guidesSchema.table( "guide", { id: uuid("id").defaultRandom().primaryKey(), characterKey: varchar("character_key", { length: 64 }).notNull(), catalogVersion: varchar("catalog_version", { length: 32 }) .notNull() .references(() => catalogVersions.version, { onDelete: "restrict" }), slug: varchar("slug", { length: 120 }).notNull(), name: text("name").notNull(), overview: text("overview").notNull(), coverMediaId: uuid("cover_media_id") .notNull() .references(() => media.id, { onDelete: "restrict" }), version: integer("version").default(1).notNull(), sortOrder: integer("sort_order").default(0).notNull(), trashedAt: timestamp("trashed_at", { withTimezone: true }), ...timestamps, }, (table) => [ uniqueIndex("guide_character_key_unique").on(table.characterKey), uniqueIndex("guide_slug_unique").on(table.slug), index("guide_directory_idx").on(table.trashedAt, table.sortOrder), ], ); export const guideSections = guidesSchema.table( "guide_section", { id: uuid("id").defaultRandom().primaryKey(), guideId: uuid("guide_id").notNull().references(() => guides.id, { onDelete: "cascade" }), kind: varchar("kind", { length: 32 }).notNull(), slug: varchar("slug", { length: 120 }).notNull(), title: text("title").notNull(), enabled: boolean("enabled").default(true).notNull(), sortOrder: integer("sort_order").default(0).notNull(), }, (table) => [ uniqueIndex("guide_section_slug_unique").on(table.guideId, table.slug), index("guide_section_order_idx").on(table.guideId, table.sortOrder), ], ); export const weaponRecommendations = guidesSchema.table( "weapon_recommendation", { id: uuid("id").defaultRandom().primaryKey(), guideId: uuid("guide_id").notNull().references(() => guides.id, { onDelete: "cascade" }), catalogVersion: varchar("catalog_version", { length: 32 }).notNull(), weaponKey: varchar("weapon_key", { length: 64 }).notNull(), refinement: integer("refinement").notNull(), overallPercent: numeric("overall_percent", { precision: 8, scale: 2 }), personalPercent: numeric("personal_percent", { precision: 8, scale: 2 }), note: text("note"), sortOrder: integer("sort_order").default(0).notNull(), }, (table) => [index("weapon_recommendation_order_idx").on(table.guideId, table.sortOrder)], ); export const artifactGroups = guidesSchema.table( "artifact_group", { id: uuid("id").defaultRandom().primaryKey(), guideId: uuid("guide_id").notNull().references(() => guides.id, { onDelete: "cascade" }), name: text("name").notNull(), sortOrder: integer("sort_order").default(0).notNull(), }, (table) => [index("artifact_group_order_idx").on(table.guideId, table.sortOrder)], ); export const artifactRecommendations = guidesSchema.table( "artifact_recommendation", { id: uuid("id").defaultRandom().primaryKey(), groupId: uuid("group_id").notNull().references(() => artifactGroups.id, { onDelete: "cascade" }), catalogVersion: varchar("catalog_version", { length: 32 }).notNull(), firstArtifactKey: varchar("first_artifact_key", { length: 64 }).notNull(), secondArtifactKey: varchar("second_artifact_key", { length: 64 }), percent: numeric("percent", { precision: 8, scale: 2 }).notNull(), note: text("note"), sortOrder: integer("sort_order").default(0).notNull(), }, (table) => [index("artifact_recommendation_order_idx").on(table.groupId, table.sortOrder)], ); export const artifactProfiles = guidesSchema.table("artifact_profile", { guideId: uuid("guide_id").primaryKey().references(() => guides.id, { onDelete: "cascade" }), sands: jsonb("sands").$type().default([]).notNull(), goblet: jsonb("goblet").$type().default([]).notNull(), circlet: jsonb("circlet").$type().default([]).notNull(), substats: jsonb("substats").$type().default([]).notNull(), targets: jsonb("targets").$type>().default([]).notNull(), talents: jsonb("talents").$type>().default([]).notNull(), note: text("note"), ...timestamps, }); export const constellationRecommendations = guidesSchema.table( "constellation_recommendation", { guideId: uuid("guide_id").notNull().references(() => guides.id, { onDelete: "cascade" }), level: integer("level").notNull(), detail: text("detail").notNull(), cumulativePercent: numeric("cumulative_percent", { precision: 8, scale: 2 }).notNull(), isGreat: boolean("is_great").default(false).notNull(), }, (table) => [primaryKey({ columns: [table.guideId, table.level] })], ); export const teamCompositions = guidesSchema.table( "team_composition", { id: uuid("id").defaultRandom().primaryKey(), guideId: uuid("guide_id").notNull().references(() => guides.id, { onDelete: "cascade" }), title: text("title").notNull(), rotationSeconds: numeric("rotation_seconds", { precision: 8, scale: 2 }).notNull(), combo: text("combo").notNull(), sortOrder: integer("sort_order").default(0).notNull(), }, (table) => [index("team_composition_order_idx").on(table.guideId, table.sortOrder)], ); export const teamMembers = guidesSchema.table( "team_member", { teamId: uuid("team_id").notNull().references(() => teamCompositions.id, { onDelete: "cascade" }), position: integer("position").notNull(), catalogVersion: varchar("catalog_version", { length: 32 }).notNull(), characterKey: varchar("character_key", { length: 64 }).notNull(), weaponKey: varchar("weapon_key", { length: 64 }), artifactKey: varchar("artifact_key", { length: 64 }), constellation: integer("constellation").default(0).notNull(), refinement: integer("refinement").default(1).notNull(), damage: numeric("damage", { precision: 18, scale: 2 }).notNull(), }, (table) => [primaryKey({ columns: [table.teamId, table.position] })], ); export const extraSections = guidesSchema.table("extra_section", { sectionId: uuid("section_id").primaryKey().references(() => guideSections.id, { onDelete: "cascade" }), ...timestamps, }); export const extraSectionRows = guidesSchema.table( "extra_section_row", { id: uuid("id").defaultRandom().primaryKey(), sectionId: uuid("section_id").notNull().references(() => extraSections.sectionId, { onDelete: "cascade" }), mediaId: uuid("media_id").references(() => media.id, { onDelete: "set null" }), ratio: varchar("ratio", { length: 5 }).default("16:9").notNull(), heading: text("heading").notNull(), body: text("body").notNull(), percent: numeric("percent", { precision: 8, scale: 2 }), sortOrder: integer("sort_order").default(0).notNull(), }, (table) => [index("extra_section_row_order_idx").on(table.sectionId, table.sortOrder)], ); export const characterRelations = relations(characters, ({ many, one }) => ({ pages: many(pages), portrait: one(media, { fields: [characters.portraitMediaId], references: [media.id], }), })); export const pageRelations = relations(pages, ({ many, one }) => ({ character: one(characters, { fields: [pages.characterId], references: [characters.id], }), blocks: many(blocks), dependencies: many(pageDataSources), revisions: many(revisions), })); export const blockRelations = relations(blocks, ({ one }) => ({ page: one(pages, { fields: [blocks.pageId], references: [pages.id], }), })); export const dataSourceRelations = relations(dataSources, ({ many }) => ({ versions: many(dataSourceVersions), pageDependencies: many(pageDataSources), })); export const dataSourceVersionRelations = relations( dataSourceVersions, ({ one }) => ({ dataSource: one(dataSources, { fields: [dataSourceVersions.dataSourceId], references: [dataSources.id], }), }), );