diff --git a/app/admin/actions.ts b/app/admin/actions.ts index da85272..307fab0 100644 --- a/app/admin/actions.ts +++ b/app/admin/actions.ts @@ -28,7 +28,6 @@ import { permanentlyDeleteGuide, reorderExtraSections, restoreGuide, - runCatalogSync, saveArtifacts, saveConstellations, saveExtraSection, @@ -39,6 +38,11 @@ import { trashGuide, updateGuideBasics, } from "@/lib/guides/mutations"; +import { + cancelCatalogSyncJob, + getCatalogSyncJob, + startCatalogSyncJob, +} from "@/lib/catalog/sync"; const serializedBlockSchema = z.strictObject({ id: z.string().uuid(), @@ -362,12 +366,22 @@ export async function deleteStructuredGuide(guideId: string) { export async function startCatalogSync() { await requireAdmin(); try { - return await runCatalogSync(); + return { status: "started" as const, job: await startCatalogSyncJob() }; } catch (cause) { return { status: "error" as const, message: cause instanceof Error ? cause.message : "เริ่ม Sync ไม่สำเร็จ" }; } } +export async function getCatalogSyncProgress(jobId: string) { + await requireAdmin(); + return getCatalogSyncJob(jobId); +} + +export async function cancelCatalogSync(jobId: string) { + await requireAdmin(); + return cancelCatalogSyncJob(jobId); +} + export async function addGlossaryAlias(input: unknown) { await requireAdmin(); try { diff --git a/components/admin/guide-overview-form.tsx b/components/admin/guide-overview-form.tsx index 714277f..c12fe8c 100644 --- a/components/admin/guide-overview-form.tsx +++ b/components/admin/guide-overview-form.tsx @@ -1,6 +1,10 @@ "use client"; import Image from "next/image"; +import { closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core"; +import { SortableContext, arrayMove, sortableKeyboardCoordinates, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { GripVerticalIcon, Trash2Icon } from "lucide-react"; import { useState } from "react"; import { saveGuideOverview } from "@/app/admin/actions"; @@ -8,6 +12,7 @@ import { AutosaveStatus } from "@/components/admin/autosave-status"; import { MediaUploader } from "@/components/admin/media-uploader"; import { MaterialPoster } from "@/components/public/material-poster"; import { GuideImageExport } from "@/components/public/guide-image-export"; +import { Button } from "@/components/ui/button"; import { Card, CardContent, @@ -32,6 +37,31 @@ import { detectThemeColorsFromPixels } from "@/lib/overview-image"; import type { PublicGuide } from "@/lib/guides/queries"; import { cn } from "@/lib/utils"; +type BleedingLayer = { + id: string; + mediaId: string; + imageUrl: string | null; + amount: number; + scale: number; +}; + +function SortableBleedingLayer({ layer, onChange, onRemove }: { layer: BleedingLayer; onChange: (patch: Partial>) => void; onRemove: () => void }) { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ id: layer.id }); + return ( +
+ + {layer.imageUrl ? :
} +
+
Amount{layer.amount}%
+ onChange({ amount: Number(event.target.value) })} aria-label="Bleeding amount" /> +
Scale{layer.scale}%
+ onChange({ scale: Number(event.target.value) })} aria-label="Bleeding scale" /> +
+ +
+ ); +} + export function GuideOverviewForm({ guide, }: { @@ -49,6 +79,7 @@ export function GuideOverviewForm({ posterArtOffsetY: number; posterTopColors: [string, string]; posterBottomColors: [string, string]; + posterBleeding: Array<{ id: string; mediaId: string; imageUrl: string | null; amount: number; scale: number; sortOrder: number }>; character: Omit; }; }) { @@ -68,6 +99,8 @@ export function GuideOverviewForm({ }); const [posterTopColors, setPosterTopColors] = useState(guide.posterTopColors); const [posterBottomColors, setPosterBottomColors] = useState(guide.posterBottomColors); + const [bleeding, setBleeding] = useState(guide.posterBleeding.map(({ id, mediaId, imageUrl, amount, scale }) => ({ id, mediaId, imageUrl, amount, scale }))); + const sensors = useSensors(useSensor(PointerSensor), useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates })); const youtubeVideo = parseYouTubeUrl(youtubeUrl.trim()); const invalidYoutubeUrl = Boolean(youtubeUrl.trim() && !youtubeVideo); const value = { @@ -79,6 +112,7 @@ export function GuideOverviewForm({ posterArtOffsetY: posterArtOffset.y, posterTopColors, posterBottomColors, + posterBleeding: bleeding.map(({ mediaId, amount, scale }) => ({ mediaId, amount, scale })), }; const incompleteIssues = [ !cover.id ? "กรุณาอัปโหลด Guide Image" : null, @@ -87,7 +121,7 @@ export function GuideOverviewForm({ ].filter((issue): issue is string => Boolean(issue)); const invalidCover = !cover.id; const invalidOverview = !overview.trim(); - const localDraft = { overview, youtubeUrl, cover, posterArt, posterArtOffset, posterTopColors, posterBottomColors }; + const localDraft = { overview, youtubeUrl, cover, posterArt, posterArtOffset, posterTopColors, posterBottomColors, bleeding }; const autosave = useAutosave({ value, valid: Boolean(cover.id && overview.trim() && !invalidYoutubeUrl), @@ -143,6 +177,15 @@ export function GuideOverviewForm({ posterBottomColors: Array.isArray(draft.posterBottomColors) && draft.posterBottomColors.length === 2 ? draft.posterBottomColors as [string, string] : guide.posterBottomColors, + bleeding: Array.isArray(draft.bleeding) + ? draft.bleeding.flatMap((item) => { + if (!item || typeof item !== "object") return []; + const layer = item as Record; + return typeof layer.id === "string" && typeof layer.mediaId === "string" && typeof layer.imageUrl === "string" + ? [{ id: layer.id, mediaId: layer.mediaId, imageUrl: layer.imageUrl, amount: typeof layer.amount === "number" ? Math.max(0, Math.min(100, layer.amount)) : 100, scale: typeof layer.scale === "number" ? Math.max(25, Math.min(300, layer.scale)) : 100 }] + : []; + }) + : guide.posterBleeding.map(({ id, mediaId, imageUrl, amount, scale }) => ({ id, mediaId, imageUrl, amount, scale })), }; }, restoreLocalDraft: (draft) => { @@ -153,6 +196,7 @@ export function GuideOverviewForm({ setPosterArtOffset(draft.posterArtOffset); setPosterTopColors(draft.posterTopColors); setPosterBottomColors(draft.posterBottomColors); + setBleeding(draft.bleeding); }, }); @@ -193,6 +237,16 @@ export function GuideOverviewForm({ const url = URL.createObjectURL(file); void detectPosterColors(url).finally(() => URL.revokeObjectURL(url)); } + + function handleBleedingDragEnd(event: DragEndEvent) { + if (!event.over || event.active.id === event.over.id) return; + setBleeding((current) => { + const oldIndex = current.findIndex((layer) => layer.id === event.active.id); + const newIndex = current.findIndex((layer) => layer.id === event.over?.id); + return oldIndex < 0 || newIndex < 0 ? current : arrayMove(current, oldIndex, newIndex); + }); + } + return ( @@ -273,6 +327,7 @@ export function GuideOverviewForm({ artworkOffsetY={posterArtOffset.y} topColors={posterTopColors} bottomColors={posterBottomColors} + bleeding={bleeding} interactive onArtworkMove={setPosterArtOffset} /> @@ -297,9 +352,28 @@ export function GuideOverviewForm({ artworkOffsetY={posterArtOffset.y} topColors={posterTopColors} bottomColors={posterBottomColors} + bleeding={bleeding} />
+
+
+
+ Bleeding layers +

รายการบนสุดจะแสดงก่อน

+
+ setBleeding((current) => [...current, { id: crypto.randomUUID(), mediaId: asset.id, imageUrl: asset.url, amount: 100, scale: 100 }])} /> +
+ {bleeding.length ? ( + + layer.id)} strategy={verticalListSortingStrategy}> +
+ {bleeding.map((layer) => setBleeding((current) => current.map((item) => item.id === layer.id ? { ...item, ...patch } : item))} onRemove={() => setBleeding((current) => current.filter((item) => item.id !== layer.id))} />)} +
+
+
+ ) :

อัปโหลดภาพเพื่อเพิ่ม Bleeding layer

} +
อัปโหลดภาพตัวละคร แล้วลากบนตัวอย่างเพื่อจัดตำแหน่ง สีด้านบนและล่างจะตรวจให้อัตโนมัติ diff --git a/components/admin/structured-dashboard.tsx b/components/admin/structured-dashboard.tsx index 83bd18e..07ed339 100644 --- a/components/admin/structured-dashboard.tsx +++ b/components/admin/structured-dashboard.tsx @@ -7,13 +7,16 @@ import { PlusIcon, RefreshCwIcon, RotateCcwIcon, + XCircleIcon, Trash2Icon, } from "lucide-react"; -import { useTransition } from "react"; +import { useEffect, useState, useTransition } from "react"; import { toast } from "sonner"; import { deleteStructuredGuide, + cancelCatalogSync, + getCatalogSyncProgress, moveGuideToTrash, restoreStructuredGuide, startCatalogSync, @@ -40,6 +43,8 @@ import { } from "@/components/ui/empty"; import { Spinner } from "@/components/ui/spinner"; import { useAdminPresence } from "@/hooks/use-presence"; +import type { CatalogSyncJob } from "@/lib/catalog/sync"; +import { cn } from "@/lib/utils"; type GuideCard = { id: string; @@ -66,6 +71,7 @@ export function StructuredDashboard({ }) { const router = useRouter(); const [pending, startTransition] = useTransition(); + const [syncJob, setSyncJob] = useState(null); const presence = useAdminPresence(null); const catalogReady = Boolean(catalogVersion); const updateAvailable = Boolean( @@ -73,6 +79,39 @@ export function StructuredDashboard({ latestCatalogVersion && catalogVersion !== latestCatalogVersion, ); + const syncJobId = syncJob?.id; + const syncJobStatus = syncJob?.status; + + useEffect(() => { + if (!syncJobId || !syncJobStatus || ["completed", "unchanged", "cancelled", "error"].includes(syncJobStatus)) return; + let stopped = false; + const poll = async () => { + const latest = await getCatalogSyncProgress(syncJobId); + if (!stopped && latest) setSyncJob(latest); + }; + void poll(); + const timer = window.setInterval(() => void poll(), 1000); + return () => { + stopped = true; + window.clearInterval(timer); + }; + }, [syncJobId, syncJobStatus]); + + function startSync() { + startTransition(async () => { + const result = await startCatalogSync(); + if (result.status === "error") toast.error(result.message); + else setSyncJob(result.job); + }); + } + + function cancelSync() { + if (!syncJob) return; + startTransition(async () => { + const result = await cancelCatalogSync(syncJob.id); + if (result) setSyncJob(result); + }); + } function run( action: () => Promise<{ status: string; message?: string }>, @@ -107,8 +146,8 @@ export function StructuredDashboard({
+ {syncJob && !["completed", "unchanged", "cancelled", "error"].includes(syncJob.status) ? ( + + ) : null}
+ {syncJob ? ( + + +
+ {syncJob.message} + {syncJob.total ? `${syncJob.completed}/${syncJob.total}` : "กำลังเริ่ม…"} +
+
+
+
+ {syncJob.status === "error" ?

{syncJob.error ?? "Sync ไม่สำเร็จ"}

: null} + + + ) : null} {guides.length ? (
{guides.map((guide) => ( diff --git a/components/public/material-poster.tsx b/components/public/material-poster.tsx index e19f47c..6862c43 100644 --- a/components/public/material-poster.tsx +++ b/components/public/material-poster.tsx @@ -55,6 +55,7 @@ export function MaterialPoster({ interactive = false, onArtworkMove, className, + bleeding = [], }: { character: PosterCharacter; characterArtUrl: string | null; @@ -65,6 +66,7 @@ export function MaterialPoster({ interactive?: boolean; onArtworkMove?: (offset: { x: number; y: number }) => void; className?: string; + bleeding?: Array<{ id: string; imageUrl: string | null; amount: number; scale: number }>; }) { const dragStart = useRef<{ pointerId: number; x: number; y: number; offsetX: number; offsetY: number } | null>(null); const stats = character.baseStats.slice(0, 4); @@ -141,6 +143,19 @@ export function MaterialPoster({ />
) : null} +
+ {bleeding.map((layer) => layer.imageUrl ? ( + + ) : null)} +
diff --git a/components/public/structured-guide.tsx b/components/public/structured-guide.tsx index d02e729..3acf158 100644 --- a/components/public/structured-guide.tsx +++ b/components/public/structured-guide.tsx @@ -279,6 +279,7 @@ function GuideOverview({ artworkOffsetY={guide.posterArtOffsetY} topColors={guide.posterTopColors} bottomColors={guide.posterBottomColors} + bleeding={guide.posterBleeding} /> ) : null} diff --git a/db/schema.ts b/db/schema.ts index fd37947..bf61bb2 100644 --- a/db/schema.ts +++ b/db/schema.ts @@ -565,6 +565,22 @@ export const guides = guidesSchema.table( ], ); +export const guidePosterBleeding = guidesSchema.table( + "guide_poster_bleeding", + { + id: uuid("id").defaultRandom().primaryKey(), + guideId: uuid("guide_id").notNull().references(() => guides.id, { onDelete: "cascade" }), + mediaId: uuid("media_id").notNull().references(() => media.id, { onDelete: "restrict" }), + amount: integer("amount").default(100).notNull(), + scale: integer("scale").default(100).notNull(), + sortOrder: integer("sort_order").default(0).notNull(), + ...timestamps, + }, + (table) => [ + index("guide_poster_bleeding_order_idx").on(table.guideId, table.sortOrder), + ], +); + export const guideSections = guidesSchema.table( "guide_section", { diff --git a/drizzle/0032_grey_excalibur.sql b/drizzle/0032_grey_excalibur.sql index 580260b..d864308 100644 --- a/drizzle/0032_grey_excalibur.sql +++ b/drizzle/0032_grey_excalibur.sql @@ -4,4 +4,4 @@ ALTER TABLE "guides"."guide" ADD COLUMN "poster_art_offset_x" integer DEFAULT 0 ALTER TABLE "guides"."guide" ADD COLUMN "poster_art_offset_y" integer DEFAULT 0 NOT NULL;--> statement-breakpoint ALTER TABLE "guides"."guide" ADD COLUMN "poster_top_colors" jsonb DEFAULT '["#000000","#000000"]'::jsonb NOT NULL;--> statement-breakpoint ALTER TABLE "guides"."guide" ADD COLUMN "poster_bottom_colors" jsonb DEFAULT '["#000000","#000000"]'::jsonb NOT NULL;--> statement-breakpoint -ALTER TABLE "guides"."guide" ADD CONSTRAINT "guide_poster_art_media_id_media_id_fk" FOREIGN KEY ("poster_art_media_id") REFERENCES "media"."media"("id") ON DELETE set null ON UPDATE no action; \ No newline at end of file +ALTER TABLE "guides"."guide" ADD CONSTRAINT "guide_poster_art_media_id_media_id_fk" FOREIGN KEY ("poster_art_media_id") REFERENCES "media"."media"("id") ON DELETE set null ON UPDATE no action; diff --git a/drizzle/0033_true_captain_marvel.sql b/drizzle/0033_true_captain_marvel.sql new file mode 100644 index 0000000..bad2053 --- /dev/null +++ b/drizzle/0033_true_captain_marvel.sql @@ -0,0 +1,14 @@ +CREATE TABLE "guides"."guide_poster_bleeding" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "guide_id" uuid NOT NULL, + "media_id" uuid NOT NULL, + "amount" integer DEFAULT 100 NOT NULL, + "scale" integer DEFAULT 100 NOT NULL, + "sort_order" integer DEFAULT 0 NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "guides"."guide_poster_bleeding" ADD CONSTRAINT "guide_poster_bleeding_guide_id_guide_id_fk" FOREIGN KEY ("guide_id") REFERENCES "guides"."guide"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "guides"."guide_poster_bleeding" ADD CONSTRAINT "guide_poster_bleeding_media_id_media_id_fk" FOREIGN KEY ("media_id") REFERENCES "media"."media"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "guide_poster_bleeding_order_idx" ON "guides"."guide_poster_bleeding" USING btree ("guide_id","sort_order"); \ No newline at end of file diff --git a/drizzle/meta/0033_snapshot.json b/drizzle/meta/0033_snapshot.json new file mode 100644 index 0000000..da99e79 --- /dev/null +++ b/drizzle/meta/0033_snapshot.json @@ -0,0 +1,3816 @@ +{ + "id": "0f22e771-e10f-4aa5-a95e-9aa44649d6fc", + "prevId": "fd6d16b2-b751-442d-83dd-74c9d39ef669", + "version": "7", + "dialect": "postgresql", + "tables": { + "auth.account": { + "name": "account", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "account_issuer_unique": { + "name": "account_issuer_unique", + "columns": [ + { + "expression": "issuer", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "guides.artifact_group": { + "name": "artifact_group", + "schema": "guides", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "guide_id": { + "name": "guide_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "artifact_group_order_idx": { + "name": "artifact_group_order_idx", + "columns": [ + { + "expression": "guide_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "artifact_group_guide_id_guide_id_fk": { + "name": "artifact_group_guide_id_guide_id_fk", + "tableFrom": "artifact_group", + "tableTo": "guide", + "schemaTo": "guides", + "columnsFrom": [ + "guide_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "guides.artifact_profile": { + "name": "artifact_profile", + "schema": "guides", + "columns": { + "guide_id": { + "name": "guide_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "sands": { + "name": "sands", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "goblet": { + "name": "goblet", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "circlet": { + "name": "circlet", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "substats": { + "name": "substats", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "targets": { + "name": "targets", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "talents": { + "name": "talents", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "artifact_profile_guide_id_guide_id_fk": { + "name": "artifact_profile_guide_id_guide_id_fk", + "tableFrom": "artifact_profile", + "tableTo": "guide", + "schemaTo": "guides", + "columnsFrom": [ + "guide_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "guides.artifact_recommendation": { + "name": "artifact_recommendation", + "schema": "guides", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "group_id": { + "name": "group_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "first_artifact_key": { + "name": "first_artifact_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "second_artifact_key": { + "name": "second_artifact_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "percent": { + "name": "percent", + "type": "numeric(8, 2)", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "artifact_recommendation_order_idx": { + "name": "artifact_recommendation_order_idx", + "columns": [ + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "artifact_recommendation_group_id_artifact_group_id_fk": { + "name": "artifact_recommendation_group_id_artifact_group_id_fk", + "tableFrom": "artifact_recommendation", + "tableTo": "artifact_group", + "schemaTo": "guides", + "columnsFrom": [ + "group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "editor.block": { + "name": "block", + "schema": "editor", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "page_id": { + "name": "page_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "responsive_layout": { + "name": "responsive_layout", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "block_page_order_idx": { + "name": "block_page_order_idx", + "columns": [ + { + "expression": "page_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "block_page_id_page_id_fk": { + "name": "block_page_id_page_id_fk", + "tableFrom": "block", + "tableTo": "page", + "schemaTo": "editor", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "catalog.artifact": { + "name": "artifact", + "schema": "catalog", + "columns": { + "key": { + "name": "key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rarity": { + "name": "rarity", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "image_key": { + "name": "image_key", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "catalog_artifact_name_idx": { + "name": "catalog_artifact_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "artifact_key_pk": { + "name": "artifact_key_pk", + "columns": [ + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "catalog.character": { + "name": "character", + "schema": "catalog", + "columns": { + "key": { + "name": "key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "element": { + "name": "element", + "type": "varchar(48)", + "primaryKey": false, + "notNull": false + }, + "rarity": { + "name": "rarity", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "weapon_type": { + "name": "weapon_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "image_key": { + "name": "image_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "element_icon_key": { + "name": "element_icon_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "coop_image_key": { + "name": "coop_image_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name_card_image_key": { + "name": "name_card_image_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "constellations": { + "name": "constellations", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "talents": { + "name": "talents", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "ascension": { + "name": "ascension", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "base_stats": { + "name": "base_stats", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "catalog_character_name_idx": { + "name": "catalog_character_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "character_key_pk": { + "name": "character_key_pk", + "columns": [ + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "catalog.material": { + "name": "material", + "schema": "catalog", + "columns": { + "key": { + "name": "key", + "type": "varchar(64)", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rarity": { + "name": "rarity", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "image_key": { + "name": "image_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "catalog_material_name_idx": { + "name": "catalog_material_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "catalog.weapon": { + "name": "weapon", + "schema": "catalog", + "columns": { + "key": { + "name": "key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rarity": { + "name": "rarity", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "weapon_type": { + "name": "weapon_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "image_key": { + "name": "image_key", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "catalog_weapon_name_idx": { + "name": "catalog_weapon_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "weapon_key_pk": { + "name": "weapon_key_pk", + "columns": [ + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "editor.character": { + "name": "character", + "schema": "editor", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "name_th": { + "name": "name_th", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name_en": { + "name": "name_en", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "portrait_media_id": { + "name": "portrait_media_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "element": { + "name": "element", + "type": "varchar(48)", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "varchar(80)", + "primaryKey": false, + "notNull": false + }, + "rarity": { + "name": "rarity", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "visible": { + "name": "visible", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "character_slug_unique": { + "name": "character_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "character_directory_idx": { + "name": "character_directory_idx", + "columns": [ + { + "expression": "visible", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "character_portrait_media_id_media_id_fk": { + "name": "character_portrait_media_id_media_id_fk", + "tableFrom": "character", + "tableTo": "media", + "schemaTo": "media", + "columnsFrom": [ + "portrait_media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "guides.constellation_condition": { + "name": "constellation_condition", + "schema": "guides", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "guide_id": { + "name": "guide_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "constellation_condition_order_idx": { + "name": "constellation_condition_order_idx", + "columns": [ + { + "expression": "guide_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "constellation_condition_guide_id_guide_id_fk": { + "name": "constellation_condition_guide_id_guide_id_fk", + "tableFrom": "constellation_condition", + "tableTo": "guide", + "schemaTo": "guides", + "columnsFrom": [ + "guide_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "guides.constellation_recommendation": { + "name": "constellation_recommendation", + "schema": "guides", + "columns": { + "condition_id": { + "name": "condition_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "detail": { + "name": "detail", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cumulative_percent": { + "name": "cumulative_percent", + "type": "numeric(8, 2)", + "primaryKey": false, + "notNull": true + }, + "is_great": { + "name": "is_great", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "constellation_recommendation_condition_id_constellation_condition_id_fk": { + "name": "constellation_recommendation_condition_id_constellation_condition_id_fk", + "tableFrom": "constellation_recommendation", + "tableTo": "constellation_condition", + "schemaTo": "guides", + "columnsFrom": [ + "condition_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "constellation_recommendation_condition_id_level_pk": { + "name": "constellation_recommendation_condition_id_level_pk", + "columns": [ + "condition_id", + "level" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "editor.data_source_version": { + "name": "data_source_version", + "schema": "editor", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "data_source_id": { + "name": "data_source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "rows": { + "name": "rows", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "evaluation": { + "name": "evaluation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_by_id": { + "name": "created_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_source_version_unique": { + "name": "data_source_version_unique", + "columns": [ + { + "expression": "data_source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_source_version_data_source_id_data_source_id_fk": { + "name": "data_source_version_data_source_id_data_source_id_fk", + "tableFrom": "data_source_version", + "tableTo": "data_source", + "schemaTo": "editor", + "columnsFrom": [ + "data_source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_source_version_created_by_id_user_id_fk": { + "name": "data_source_version_created_by_id_user_id_fk", + "tableFrom": "data_source_version", + "tableTo": "user", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "editor.data_source": { + "name": "data_source", + "schema": "editor", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "fields": { + "name": "fields", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "current_version": { + "name": "current_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_source_name_idx": { + "name": "data_source_name_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "guides.extra_section_row": { + "name": "extra_section_row", + "schema": "guides", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "section_id": { + "name": "section_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "media_id": { + "name": "media_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "heading": { + "name": "heading", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "percent": { + "name": "percent", + "type": "numeric(8, 2)", + "primaryKey": false, + "notNull": false + }, + "background_color": { + "name": "background_color", + "type": "varchar(7)", + "primaryKey": false, + "notNull": false + }, + "text_color": { + "name": "text_color", + "type": "varchar(7)", + "primaryKey": false, + "notNull": false + }, + "highlights": { + "name": "highlights", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "catalog_items": { + "name": "catalog_items", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "sheet": { + "name": "sheet", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "extra_section_row_order_idx": { + "name": "extra_section_row_order_idx", + "columns": [ + { + "expression": "section_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "extra_section_row_section_id_extra_section_section_id_fk": { + "name": "extra_section_row_section_id_extra_section_section_id_fk", + "tableFrom": "extra_section_row", + "tableTo": "extra_section", + "schemaTo": "guides", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "section_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "extra_section_row_media_id_media_id_fk": { + "name": "extra_section_row_media_id_media_id_fk", + "tableFrom": "extra_section_row", + "tableTo": "media", + "schemaTo": "media", + "columnsFrom": [ + "media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "guides.extra_section": { + "name": "extra_section", + "schema": "guides", + "columns": { + "section_id": { + "name": "section_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "extra_section_section_id_guide_section_id_fk": { + "name": "extra_section_section_id_guide_section_id_fk", + "tableFrom": "extra_section", + "tableTo": "guide_section", + "schemaTo": "guides", + "columnsFrom": [ + "section_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "guides.glossary_alias": { + "name": "glossary_alias", + "schema": "guides", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "varchar(16)", + "primaryKey": false, + "notNull": true + }, + "catalog_key": { + "name": "catalog_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "alias": { + "name": "alias", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "normalized_alias": { + "name": "normalized_alias", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "glossary_alias_kind_normalized_unique": { + "name": "glossary_alias_kind_normalized_unique", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "normalized_alias", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "glossary_alias_item_idx": { + "name": "glossary_alias_item_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "catalog_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "guides.guide_poster_bleeding": { + "name": "guide_poster_bleeding", + "schema": "guides", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "guide_id": { + "name": "guide_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "media_id": { + "name": "media_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "scale": { + "name": "scale", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 100 + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "guide_poster_bleeding_order_idx": { + "name": "guide_poster_bleeding_order_idx", + "columns": [ + { + "expression": "guide_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "guide_poster_bleeding_guide_id_guide_id_fk": { + "name": "guide_poster_bleeding_guide_id_guide_id_fk", + "tableFrom": "guide_poster_bleeding", + "tableTo": "guide", + "schemaTo": "guides", + "columnsFrom": [ + "guide_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "guide_poster_bleeding_media_id_media_id_fk": { + "name": "guide_poster_bleeding_media_id_media_id_fk", + "tableFrom": "guide_poster_bleeding", + "tableTo": "media", + "schemaTo": "media", + "columnsFrom": [ + "media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "guides.guide_section": { + "name": "guide_section", + "schema": "guides", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "guide_id": { + "name": "guide_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "varchar(32)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "guide_section_slug_unique": { + "name": "guide_section_slug_unique", + "columns": [ + { + "expression": "guide_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "guide_section_order_idx": { + "name": "guide_section_order_idx", + "columns": [ + { + "expression": "guide_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "guide_section_guide_id_guide_id_fk": { + "name": "guide_section_guide_id_guide_id_fk", + "tableFrom": "guide_section", + "tableTo": "guide", + "schemaTo": "guides", + "columnsFrom": [ + "guide_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "guides.guide": { + "name": "guide", + "schema": "guides", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "character_key": { + "name": "character_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "overview": { + "name": "overview", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "youtube_url": { + "name": "youtube_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cover_media_id": { + "name": "cover_media_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "poster_art_media_id": { + "name": "poster_art_media_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "poster_art_offset_x": { + "name": "poster_art_offset_x", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "poster_art_offset_y": { + "name": "poster_art_offset_y", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "poster_top_colors": { + "name": "poster_top_colors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[\"#000000\",\"#000000\"]'::jsonb" + }, + "poster_bottom_colors": { + "name": "poster_bottom_colors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[\"#000000\",\"#000000\"]'::jsonb" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "trashed_at": { + "name": "trashed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "guide_character_key_unique": { + "name": "guide_character_key_unique", + "columns": [ + { + "expression": "character_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "guide_slug_unique": { + "name": "guide_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "guide_directory_idx": { + "name": "guide_directory_idx", + "columns": [ + { + "expression": "trashed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "guide_cover_media_id_media_id_fk": { + "name": "guide_cover_media_id_media_id_fk", + "tableFrom": "guide", + "tableTo": "media", + "schemaTo": "media", + "columnsFrom": [ + "cover_media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "guide_poster_art_media_id_media_id_fk": { + "name": "guide_poster_art_media_id_media_id_fk", + "tableFrom": "guide", + "tableTo": "media", + "schemaTo": "media", + "columnsFrom": [ + "poster_art_media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "media.media": { + "name": "media", + "schema": "media", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "object_key": { + "name": "object_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "byte_size": { + "name": "byte_size", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "checksum": { + "name": "checksum", + "type": "varchar(128)", + "primaryKey": false, + "notNull": false + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(24)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "current_reference_count": { + "name": "current_reference_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "revision_reference_count": { + "name": "revision_reference_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_by_id": { + "name": "created_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "media_object_key_unique": { + "name": "media_object_key_unique", + "columns": [ + { + "expression": "object_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "media_visibility_idx": { + "name": "media_visibility_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "current_reference_count", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "media_created_by_id_user_id_fk": { + "name": "media_created_by_id_user_id_fk", + "tableFrom": "media", + "tableTo": "user", + "schemaTo": "auth", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "media.media_reference": { + "name": "media_reference", + "schema": "media", + "columns": { + "media_id": { + "name": "media_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "owner_type": { + "name": "owner_type", + "type": "varchar(24)", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reference_kind": { + "name": "reference_kind", + "type": "varchar(24)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "media_reference_owner_idx": { + "name": "media_reference_owner_idx", + "columns": [ + { + "expression": "owner_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "media_reference_media_id_media_id_fk": { + "name": "media_reference_media_id_media_id_fk", + "tableFrom": "media_reference", + "tableTo": "media", + "schemaTo": "media", + "columnsFrom": [ + "media_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "media_reference_media_id_owner_type_owner_id_reference_kind_pk": { + "name": "media_reference_media_id_owner_type_owner_id_reference_kind_pk", + "columns": [ + "media_id", + "owner_type", + "owner_id", + "reference_kind" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "bigint", + "primaryKey": true, + "notNull": true, + "identity": { + "type": "always", + "name": "outbox_event_id_seq", + "schema": "public", + "increment": "1", + "startWith": "1", + "minValue": "1", + "maxValue": "9223372036854775807", + "cache": "1", + "cycle": false + } + }, + "topic": { + "name": "topic", + "type": "varchar(160)", + "primaryKey": false, + "notNull": true + }, + "aggregate_id": { + "name": "aggregate_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "varchar(24)", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "available_at": { + "name": "available_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "outbox_pending_idx": { + "name": "outbox_pending_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "editor.page_data_source": { + "name": "page_data_source", + "schema": "editor", + "columns": { + "page_id": { + "name": "page_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "data_source_id": { + "name": "data_source_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pinned_version": { + "name": "pinned_version", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "page_data_source_dependency_idx": { + "name": "page_data_source_dependency_idx", + "columns": [ + { + "expression": "data_source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "page_data_source_page_id_page_id_fk": { + "name": "page_data_source_page_id_page_id_fk", + "tableFrom": "page_data_source", + "tableTo": "page", + "schemaTo": "editor", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "page_data_source_data_source_id_data_source_id_fk": { + "name": "page_data_source_data_source_id_data_source_id_fk", + "tableFrom": "page_data_source", + "tableTo": "data_source", + "schemaTo": "editor", + "columnsFrom": [ + "data_source_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "page_data_source_page_id_data_source_id_pk": { + "name": "page_data_source_page_id_data_source_id_pk", + "columns": [ + "page_id", + "data_source_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "editor.page": { + "name": "page", + "schema": "editor", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "character_id": { + "name": "character_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "nav_order": { + "name": "nav_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "visible": { + "name": "visible", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "public_note": { + "name": "public_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "page_character_slug_unique": { + "name": "page_character_slug_unique", + "columns": [ + { + "expression": "character_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "page_navigation_idx": { + "name": "page_navigation_idx", + "columns": [ + { + "expression": "character_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "visible", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "nav_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "page_character_id_character_id_fk": { + "name": "page_character_id_character_id_fk", + "tableFrom": "page", + "tableTo": "character", + "schemaTo": "editor", + "columnsFrom": [ + "character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "editor.public_snapshot": { + "name": "public_snapshot", + "schema": "editor", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "page_id": { + "name": "page_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "page_version": { + "name": "page_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "version_vector": { + "name": "version_vector", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "vector_hash": { + "name": "vector_hash", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "dto": { + "name": "dto", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "visible": { + "name": "visible", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_snapshot_version_unique": { + "name": "public_snapshot_version_unique", + "columns": [ + { + "expression": "page_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "page_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "vector_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_snapshot_lookup_idx": { + "name": "public_snapshot_lookup_idx", + "columns": [ + { + "expression": "page_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "visible", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_snapshot_page_id_page_id_fk": { + "name": "public_snapshot_page_id_page_id_fk", + "tableFrom": "public_snapshot", + "tableTo": "page", + "schemaTo": "editor", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "editor.revision": { + "name": "revision", + "schema": "editor", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "page_id": { + "name": "page_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "page_version": { + "name": "page_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "varchar(24)", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_revision_id": { + "name": "source_revision_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "checkpoint_hour": { + "name": "checkpoint_hour", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "data_source_versions": { + "name": "data_source_versions", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "media_ids": { + "name": "media_ids", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "revision_page_created_idx": { + "name": "revision_page_created_idx", + "columns": [ + { + "expression": "page_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "revision_checkpoint_hour_unique": { + "name": "revision_checkpoint_hour_unique", + "columns": [ + { + "expression": "page_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "checkpoint_hour", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"editor\".\"revision\".\"kind\" = 'checkpoint'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "revision_page_id_page_id_fk": { + "name": "revision_page_id_page_id_fk", + "tableFrom": "revision", + "tableTo": "page", + "schemaTo": "editor", + "columnsFrom": [ + "page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "revision_author_id_user_id_fk": { + "name": "revision_author_id_user_id_fk", + "tableFrom": "revision", + "tableTo": "user", + "schemaTo": "auth", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.session": { + "name": "session", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "session_token_unique": { + "name": "session_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "editor.slug_alias": { + "name": "slug_alias", + "schema": "editor", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "kind": { + "name": "kind", + "type": "varchar(24)", + "primaryKey": false, + "notNull": true + }, + "old_character_slug": { + "name": "old_character_slug", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "old_page_slug": { + "name": "old_page_slug", + "type": "varchar(120)", + "primaryKey": false, + "notNull": false + }, + "target_character_id": { + "name": "target_character_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "target_page_id": { + "name": "target_page_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "slug_alias_path_unique": { + "name": "slug_alias_path_unique", + "columns": [ + { + "expression": "old_character_slug", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "old_page_slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "slug_alias_target_character_id_character_id_fk": { + "name": "slug_alias_target_character_id_character_id_fk", + "tableFrom": "slug_alias", + "tableTo": "character", + "schemaTo": "editor", + "columnsFrom": [ + "target_character_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "slug_alias_target_page_id_page_id_fk": { + "name": "slug_alias_target_page_id_page_id_fk", + "tableFrom": "slug_alias", + "tableTo": "page", + "schemaTo": "editor", + "columnsFrom": [ + "target_page_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "guides.team_composition": { + "name": "team_composition", + "schema": "guides", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "guide_id": { + "name": "guide_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "rotation_seconds": { + "name": "rotation_seconds", + "type": "numeric(8, 2)", + "primaryKey": false, + "notNull": true + }, + "combo": { + "name": "combo", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "team_composition_order_idx": { + "name": "team_composition_order_idx", + "columns": [ + { + "expression": "guide_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_composition_guide_id_guide_id_fk": { + "name": "team_composition_guide_id_guide_id_fk", + "tableFrom": "team_composition", + "tableTo": "guide", + "schemaTo": "guides", + "columnsFrom": [ + "guide_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "guides.team_member": { + "name": "team_member", + "schema": "guides", + "columns": { + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "character_key": { + "name": "character_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "weapon_key": { + "name": "weapon_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "artifact_key": { + "name": "artifact_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "second_artifact_key": { + "name": "second_artifact_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "constellation": { + "name": "constellation", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "refinement": { + "name": "refinement", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "damage": { + "name": "damage", + "type": "numeric(18, 2)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "team_member_team_id_team_composition_id_fk": { + "name": "team_member_team_id_team_composition_id_fk", + "tableFrom": "team_member", + "tableTo": "team_composition", + "schemaTo": "guides", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "team_member_team_id_position_pk": { + "name": "team_member_team_id_position_pk", + "columns": [ + "team_id", + "position" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "editor.template": { + "name": "template", + "schema": "editor", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "slug": { + "name": "slug", + "type": "varchar(120)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "category": { + "name": "category", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "structure": { + "name": "structure", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "built_in": { + "name": "built_in", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "template_slug_unique": { + "name": "template_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.user": { + "name": "user", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'admin'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_email_unique": { + "name": "user_email_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "auth.verification": { + "name": "verification", + "schema": "auth", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "guides.weapon_condition": { + "name": "weapon_condition", + "schema": "guides", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "guide_id": { + "name": "guide_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "weapon_condition_order_idx": { + "name": "weapon_condition_order_idx", + "columns": [ + { + "expression": "guide_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "weapon_condition_guide_id_guide_id_fk": { + "name": "weapon_condition_guide_id_guide_id_fk", + "tableFrom": "weapon_condition", + "tableTo": "guide", + "schemaTo": "guides", + "columnsFrom": [ + "guide_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "guides.weapon_recommendation": { + "name": "weapon_recommendation", + "schema": "guides", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "condition_id": { + "name": "condition_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "weapon_key": { + "name": "weapon_key", + "type": "varchar(64)", + "primaryKey": false, + "notNull": true + }, + "refinement": { + "name": "refinement", + "type": "varchar(8)", + "primaryKey": false, + "notNull": true + }, + "overall_percent": { + "name": "overall_percent", + "type": "numeric(8, 2)", + "primaryKey": false, + "notNull": false + }, + "personal_percent": { + "name": "personal_percent", + "type": "numeric(8, 2)", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "weapon_recommendation_order_idx": { + "name": "weapon_recommendation_order_idx", + "columns": [ + { + "expression": "condition_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "weapon_recommendation_condition_id_weapon_condition_id_fk": { + "name": "weapon_recommendation_condition_id_weapon_condition_id_fk", + "tableFrom": "weapon_recommendation", + "tableTo": "weapon_condition", + "schemaTo": "guides", + "columnsFrom": [ + "condition_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/drizzle/meta/_journal.json b/drizzle/meta/_journal.json index 839e8fb..be44023 100644 --- a/drizzle/meta/_journal.json +++ b/drizzle/meta/_journal.json @@ -232,6 +232,13 @@ "when": 1788680680873, "tag": "0032_grey_excalibur", "breakpoints": true + }, + { + "idx": 33, + "version": "7", + "when": 1788681628083, + "tag": "0033_true_captain_marvel", + "breakpoints": true } ] } \ No newline at end of file diff --git a/lib/catalog/sync.ts b/lib/catalog/sync.ts index 1e8deeb..48f4d95 100644 --- a/lib/catalog/sync.ts +++ b/lib/catalog/sync.ts @@ -28,6 +28,7 @@ import type { } from "@/lib/catalog/lunaris"; import { customTravelers, isUpstreamTraveler } from "@/lib/catalog/travelers"; import { withCatalogSyncLock } from "@/lib/catalog/sync-lock"; +import { getRedisClient, redisEventPrefix } from "@/lib/redis/client"; import { compareLunarisVersions, lunarisVersionSchema, @@ -100,9 +101,11 @@ interface SyncedCharacter { baseStats: CatalogBaseStat[]; } -async function getJson(url: string): Promise { +type SyncControl = { checkCancelled: () => Promise; report: (phase: string, completed: number, total: number, message: string) => Promise }; + +async function getJson(url: string, signal?: AbortSignal): Promise { const response = await fetch(url, { - signal: AbortSignal.timeout(30_000), + signal: signal ?? AbortSignal.timeout(30_000), cache: "no-store", }); if (!response.ok) @@ -110,9 +113,9 @@ async function getJson(url: string): Promise { return response.json(); } -async function getOptionalJson(url: string): Promise { +async function getOptionalJson(url: string, signal?: AbortSignal): Promise { const response = await fetch(url, { - signal: AbortSignal.timeout(30_000), + signal: signal ?? AbortSignal.timeout(30_000), cache: "no-store", }); if (response.status === 404) return null; @@ -149,13 +152,16 @@ export async function getLatestLunarisVersion(): Promise { async function mapConcurrent( items: T[], task: (item: T) => Promise, + control?: SyncControl, ): Promise { const results = new Array(items.length); let next = 0; async function worker() { while (next < items.length) { + await control?.checkCancelled(); const index = next++; results[index] = await task(items[index]); + await control?.report("characters", index + 1, items.length, `ดึงข้อมูลตัวละคร ${index + 1}/${items.length}`); } } await Promise.all( @@ -171,12 +177,14 @@ async function uploadAssets( target: string; type?: string; }>, + control?: SyncControl, ): Promise> { const storage = await getMediaStorage(); let next = 0; const missing = new Set(); async function worker() { while (next < assets.length) { + await control?.checkCancelled(); const asset = assets[next++]; if (!(await storage.file(asset.target).exists())) { if (asset.body) { @@ -199,6 +207,7 @@ async function uploadAssets( }); } } + await control?.report("assets", next, assets.length, `ดาวน์โหลดรูปภาพ ${next}/${assets.length}`); } } await Promise.all( @@ -207,13 +216,15 @@ async function uploadAssets( return missing; } -async function syncCatalogVersion(version: string): Promise { +async function syncCatalogVersion(version: string, control?: SyncControl): Promise { + await control?.report("lists", 0, 4, "กำลังดึงรายการ Catalog จาก Lunaris"); const [charactersRaw, weaponsRaw, artifactsRaw, materialsRaw] = await Promise.all([ getJson(`${API_ROOT}/${version}/charlist.json`), getJson(`${API_ROOT}/${version}/weaponlist.json`), getJson(`${API_ROOT}/${version}/artifactlist.json`), getJson(`${API_ROOT}/${version}/materiallist.json`), ]); + await control?.report("lists", 4, 4, "ได้รับรายการ Catalog แล้ว"); const characterData = characterSchema.parse(charactersRaw); const weaponData = weaponSchema.parse(weaponsRaw); const artifactData = artifactSchema.parse(artifactsRaw); @@ -280,6 +291,7 @@ async function syncCatalogVersion(version: string): Promise { baseStats: detail ? baseStatsSnapshot(detail) : [], }; }, + control, ); const customCharacters: SyncedCharacter[] = await mapConcurrent( [...customTravelers], @@ -315,6 +327,7 @@ async function syncCatalogVersion(version: string): Promise { baseStats: baseStatsSnapshot(detail), }; }, + control, ); let characters = [...remoteCharacters, ...customCharacters]; let weapons = Object.entries(weaponData) @@ -409,7 +422,8 @@ async function syncCatalogVersion(version: string): Promise { }); } } - const missingAssets = await uploadAssets([...uniqueAssets.values()]); + await control?.report("assets", 0, uniqueAssets.size, "กำลังดาวน์โหลดรูปภาพ Catalog"); + const missingAssets = await uploadAssets([...uniqueAssets.values()], control); characters = characters.filter((item) => !missingAssets.has(item.imageKey)); characters = characters.map((item) => ({ ...item, @@ -498,6 +512,7 @@ async function syncCatalogVersion(version: string): Promise { ), ]); + await control?.report("database", 0, 1, "กำลังบันทึก Catalog"); await getDb().transaction(async (tx) => { await tx.delete(catalogCharacters); await tx.delete(catalogMaterials); @@ -549,6 +564,7 @@ async function syncCatalogVersion(version: string): Promise { })), ); }); + await control?.report("database", 1, 1, "บันทึก Catalog แล้ว"); await storage.write("catalog/current.json", JSON.stringify({ version }), { type: "application/json", acl: "public-read", @@ -557,6 +573,67 @@ async function syncCatalogVersion(version: string): Promise { export type CatalogSyncResult = "synced" | "unchanged"; +export type CatalogSyncJob = { + id: string; + status: "queued" | "running" | "completed" | "unchanged" | "cancelled" | "error"; + phase: string; + completed: number; + total: number; + message: string; + version?: string; + error?: string; +}; + +const syncJobKey = (id: string) => `${redisEventPrefix()}:catalog-sync-job:${id}`; + +async function writeSyncJob(job: CatalogSyncJob) { + const redis = await getRedisClient(); + await redis.set(syncJobKey(job.id), JSON.stringify(job), "EX", 60 * 60); + return job; +} + +export async function getCatalogSyncJob(id: string): Promise { + const redis = await getRedisClient(); + const value = await redis.get(syncJobKey(id)); + return value ? JSON.parse(value) as CatalogSyncJob : null; +} + +export async function cancelCatalogSyncJob(id: string): Promise { + const job = await getCatalogSyncJob(id); + if (!job || ["completed", "unchanged", "cancelled", "error"].includes(job.status)) return job; + return writeSyncJob({ ...job, message: "กำลังยกเลิก Sync…", status: "cancelled" }); +} + +export async function startCatalogSyncJob(): Promise { + const id = crypto.randomUUID(); + const job = await writeSyncJob({ id, status: "queued", phase: "queued", completed: 0, total: 0, message: "กำลังเตรียม Sync" }); + void executeCatalogSyncJob(job).catch(() => undefined); + return job; +} + +async function executeCatalogSyncJob(initial: CatalogSyncJob) { + const checkCancelled = async () => { + const current = await getCatalogSyncJob(initial.id); + if (current?.status === "cancelled") throw new Error("CATALOG_SYNC_CANCELLED"); + }; + const report = async (phase: string, completed: number, total: number, message: string) => { + const current = await getCatalogSyncJob(initial.id); + if (current?.status === "cancelled") throw new Error("CATALOG_SYNC_CANCELLED"); + await writeSyncJob({ ...initial, status: "running", phase, completed, total, message }); + }; + try { + await writeSyncJob({ ...initial, status: "running", phase: "version", message: "กำลังตรวจสอบเวอร์ชัน Lunaris" }); + const result = await processCatalogSync(undefined, { checkCancelled, report }); + await writeSyncJob({ ...initial, status: result === "unchanged" ? "unchanged" : "completed", phase: "done", completed: 1, total: 1, message: result === "unchanged" ? "Catalog เป็นเวอร์ชันล่าสุดแล้ว" : "Sync Catalog สำเร็จ" }); + } catch (cause) { + if (cause instanceof Error && cause.message === "CATALOG_SYNC_CANCELLED") { + await writeSyncJob({ ...initial, status: "cancelled", phase: "cancelled", message: "ยกเลิก Sync แล้ว" }); + } else { + await writeSyncJob({ ...initial, status: "error", phase: "error", message: "Sync ไม่สำเร็จ", error: cause instanceof Error ? cause.message : "Unknown sync error" }); + } + } +} + export async function getCurrentCatalogVersion(): Promise { const storage = await getMediaStorage(); const current = storage.file("catalog/current.json"); @@ -566,6 +643,7 @@ export async function getCurrentCatalogVersion(): Promise { export async function processCatalogSync( requestedVersion?: string, + control?: SyncControl, ): Promise { const version = lunarisVersionSchema.parse( requestedVersion ?? (await getLatestLunarisVersion()), @@ -579,7 +657,8 @@ export async function processCatalogSync( ) { return "unchanged"; } - await syncCatalogVersion(version); + await control?.checkCancelled(); + await syncCatalogVersion(version, control); return "synced"; }); } diff --git a/lib/content/queries.ts b/lib/content/queries.ts index 37a04e2..56bfc2f 100644 --- a/lib/content/queries.ts +++ b/lib/content/queries.ts @@ -9,6 +9,7 @@ import { dataSources, dataSourceVersions, guides, + guidePosterBleeding, media, pageDataSources, pages, @@ -309,7 +310,7 @@ export async function getMediaVisibility(mediaId: string): Promise<{ retained: boolean; }> { const db = getDb(); - const [referencedBlocks, portraitCharacters, posterGuides, [mediaRecord]] = await Promise.all([ + const [referencedBlocks, portraitCharacters, posterGuides, bleedingGuides, [mediaRecord]] = await Promise.all([ db .select({ block: blocks, page: pages, character: characters }) .from(blocks) @@ -323,6 +324,11 @@ export async function getMediaVisibility(mediaId: string): Promise<{ .select({ guide: guides }) .from(guides) .where(eq(guides.posterArtMediaId, mediaId)), + db + .select({ guide: guides }) + .from(guidePosterBleeding) + .innerJoin(guides, eq(guides.id, guidePosterBleeding.guideId)) + .where(eq(guidePosterBleeding.mediaId, mediaId)), db.select().from(media).where(eq(media.id, mediaId)).limit(1), ]); @@ -342,11 +348,13 @@ export async function getMediaVisibility(mediaId: string): Promise<{ references.some( ({ page, character }) => page.visible && character.visible, ) || portraitCharacters.some(({ character }) => character.visible) || - posterGuides.some(({ guide }) => guide.isPublic && !guide.trashedAt), + posterGuides.some(({ guide }) => guide.isPublic && !guide.trashedAt) || + bleedingGuides.some(({ guide }) => guide.isPublic && !guide.trashedAt), retained: references.length > 0 || portraitCharacters.length > 0 || posterGuides.length > 0 || + bleedingGuides.length > 0 || Boolean( mediaRecord && (mediaRecord.currentReferenceCount > 0 || diff --git a/lib/guides/mutations.ts b/lib/guides/mutations.ts index 952b59b..2b9cb9b 100644 --- a/lib/guides/mutations.ts +++ b/lib/guides/mutations.ts @@ -15,6 +15,7 @@ import { extraSectionRows, extraSections, guides, + guidePosterBleeding, guideSections, media, outboxEvents, @@ -137,6 +138,13 @@ export async function updateGuideBasics(guideId: string, input: unknown) { .limit(1); if (!poster) throw new Error("ไม่พบภาพตัวละครที่อัปโหลดแล้ว"); } + if (data.posterBleeding !== undefined) { + const bleedingMediaIds = [...new Set(data.posterBleeding.map((layer) => layer.mediaId))]; + const bleedingMedia = bleedingMediaIds.length + ? await tx.select({ id: media.id }).from(media).where(and(inArray(media.id, bleedingMediaIds), eq(media.status, "ready"))) + : []; + if (bleedingMedia.length !== bleedingMediaIds.length) throw new Error("ไม่พบภาพ Bleeding ที่อัปโหลดแล้ว"); + } const updated = await advanceGuideVersion(tx, guideId, data.expectedVersion); await tx.update(guides).set({ overview: data.overview, @@ -148,6 +156,25 @@ export async function updateGuideBasics(guideId: string, input: unknown) { ...(data.posterTopColors !== undefined ? { posterTopColors: data.posterTopColors } : {}), ...(data.posterBottomColors !== undefined ? { posterBottomColors: data.posterBottomColors } : {}), }).where(eq(guides.id, guideId)); + if (data.posterBleeding !== undefined) { + const previous = await tx.select({ mediaId: guidePosterBleeding.mediaId }).from(guidePosterBleeding).where(eq(guidePosterBleeding.guideId, guideId)); + for (const layer of previous) { + await tx.update(media).set({ currentReferenceCount: sql`greatest(${media.currentReferenceCount} - 1, 0)` }).where(eq(media.id, layer.mediaId)); + } + await tx.delete(guidePosterBleeding).where(eq(guidePosterBleeding.guideId, guideId)); + if (data.posterBleeding.length) { + await tx.insert(guidePosterBleeding).values(data.posterBleeding.map((layer, sortOrder) => ({ + guideId, + mediaId: layer.mediaId, + amount: Math.round(layer.amount), + scale: Math.round(layer.scale), + sortOrder, + }))); + for (const layer of data.posterBleeding) { + await tx.update(media).set({ currentReferenceCount: sql`${media.currentReferenceCount} + 1` }).where(eq(media.id, layer.mediaId)); + } + } + } if (current.coverMediaId !== data.coverMediaId) { await tx.update(media).set({ currentReferenceCount: sql`greatest(${media.currentReferenceCount} - 1, 0)` }).where(eq(media.id, current.coverMediaId)); await tx.update(media).set({ currentReferenceCount: sql`${media.currentReferenceCount} + 1` }).where(eq(media.id, data.coverMediaId)); @@ -412,11 +439,15 @@ export async function permanentlyDeleteGuide(guideId: string) { return getDb().transaction(async (tx) => { const [guide] = await tx.select().from(guides).where(eq(guides.id, guideId)).limit(1); if (!guide?.trashedAt) throw new Error("Guide ต้องอยู่ในถังขยะก่อนลบถาวร"); + const bleeding = await tx.select({ mediaId: guidePosterBleeding.mediaId }).from(guidePosterBleeding).where(eq(guidePosterBleeding.guideId, guideId)); await tx.delete(guides).where(eq(guides.id, guideId)); await tx.update(media).set({ currentReferenceCount: sql`greatest(${media.currentReferenceCount} - 1, 0)` }).where(eq(media.id, guide.coverMediaId)); if (guide.posterArtMediaId) { await tx.update(media).set({ currentReferenceCount: sql`greatest(${media.currentReferenceCount} - 1, 0)` }).where(eq(media.id, guide.posterArtMediaId)); } + for (const layer of bleeding) { + await tx.update(media).set({ currentReferenceCount: sql`greatest(${media.currentReferenceCount} - 1, 0)` }).where(eq(media.id, layer.mediaId)); + } await emitGuideEvents(tx, guide.id, guide.version + 1); return { version: guide.version + 1 }; }); diff --git a/lib/guides/queries.ts b/lib/guides/queries.ts index 33060bd..66d01c6 100644 --- a/lib/guides/queries.ts +++ b/lib/guides/queries.ts @@ -16,6 +16,7 @@ import { constellationRecommendations, extraSectionRows, guides, + guidePosterBleeding, guideSections, glossaryAliases, media, @@ -124,13 +125,14 @@ export async function getGuideByCharacterKey(characterKey: string) { const materialKeys = [...new Set( row.character.ascension.flatMap((group) => group.items.map((item) => item.key)), )]; - const [materials, poster] = await Promise.all([ + const [materials, poster, bleeding] = await Promise.all([ materialKeys.length ? getDb().select().from(catalogMaterials).where(inArray(catalogMaterials.key, materialKeys)) : [], row.guide.posterArtMediaId ? getDb().select().from(media).where(eq(media.id, row.guide.posterArtMediaId)).limit(1) : Promise.resolve([]), + getDb().select({ layer: guidePosterBleeding, asset: media }).from(guidePosterBleeding).leftJoin(media, eq(media.id, guidePosterBleeding.mediaId)).where(eq(guidePosterBleeding.guideId, row.guide.id)).orderBy(asc(guidePosterBleeding.sortOrder)), ]); const materialByKey = new Map(materials.map((item) => [item.key, item])); const sections = await getDb().select().from(guideSections).where(eq(guideSections.guideId, row.guide.id)).orderBy(asc(guideSections.sortOrder)); @@ -138,6 +140,7 @@ export async function getGuideByCharacterKey(characterKey: string) { ...row.guide, coverUrl: publicMediaUrl(row.cover.objectKey), posterArtUrl: poster[0] ? publicMediaUrl(poster[0].objectKey) : null, + posterBleeding: bleeding.map(({ layer, asset }) => ({ ...layer, imageUrl: asset ? publicMediaUrl(asset.objectKey) : null })), character: catalogCharacterWithUrls(row.character, materialByKey), sections, }; @@ -159,7 +162,7 @@ async function getGuideContent(slug: string, publicOnly: boolean) { const materialKeys = [...new Set( row.character.ascension.flatMap((group) => group.items.map((item) => item.key)), )]; - const [sections, weaponConditionsRows, groups, profile, constellationGroups, teams, aliases, materials, poster] = await Promise.all([ + const [sections, weaponConditionsRows, groups, profile, constellationGroups, teams, aliases, materials, poster, bleeding] = await Promise.all([ getDb().select().from(guideSections).where(and(eq(guideSections.guideId, row.guide.id), eq(guideSections.enabled, true))).orderBy(asc(guideSections.sortOrder)), getDb().select().from(weaponConditions).where(eq(weaponConditions.guideId, row.guide.id)).orderBy(asc(weaponConditions.sortOrder)), getDb().select().from(artifactGroups).where(eq(artifactGroups.guideId, row.guide.id)).orderBy(asc(artifactGroups.sortOrder)), @@ -173,6 +176,7 @@ async function getGuideContent(slug: string, publicOnly: boolean) { row.guide.posterArtMediaId ? getDb().select().from(media).where(eq(media.id, row.guide.posterArtMediaId)).limit(1) : Promise.resolve([]), + getDb().select({ layer: guidePosterBleeding, asset: media }).from(guidePosterBleeding).leftJoin(media, eq(media.id, guidePosterBleeding.mediaId)).where(eq(guidePosterBleeding.guideId, row.guide.id)).orderBy(asc(guidePosterBleeding.sortOrder)), ]); const groupedAliases = aliasesByItem(aliases); const materialByKey = new Map(materials.map((item) => [item.key, item])); @@ -221,6 +225,7 @@ async function getGuideContent(slug: string, publicOnly: boolean) { ...row.guide, coverUrl: publicMediaUrl(row.cover.objectKey), posterArtUrl: poster[0] ? publicMediaUrl(poster[0].objectKey) : null, + posterBleeding: bleeding.map(({ layer, asset }) => ({ ...layer, imageUrl: asset ? publicMediaUrl(asset.objectKey) : null })), character: { ...catalogCharacterWithUrls(row.character, materialByKey), aliases: groupedAliases.character.get(row.character.key) ?? [] }, elementIconUrls, sections, diff --git a/lib/guides/schemas.ts b/lib/guides/schemas.ts index 3f229a9..0f7b629 100644 --- a/lib/guides/schemas.ts +++ b/lib/guides/schemas.ts @@ -41,6 +41,11 @@ export const updateGuideSchema = z.strictObject({ posterArtOffsetY: z.number().finite().min(-50).max(50).optional(), posterTopColors: z.tuple([z.string().regex(/^#[0-9a-fA-F]{6}$/u), z.string().regex(/^#[0-9a-fA-F]{6}$/u)]).optional(), posterBottomColors: z.tuple([z.string().regex(/^#[0-9a-fA-F]{6}$/u), z.string().regex(/^#[0-9a-fA-F]{6}$/u)]).optional(), + posterBleeding: z.array(z.strictObject({ + mediaId: z.string().uuid(), + amount: z.number().finite().min(0).max(100), + scale: z.number().finite().min(25).max(300), + })).max(30).optional(), }); export const weaponSectionSchema = z.strictObject({