feat : update
CI / Verify (push) Successful in 1m25s
CI / Build immutable images and deploy (push) Has been cancelled

This commit is contained in:
2026-09-06 15:07:28 +07:00 Unverified
parent c3a7a44d02
commit 03149a1499
15 changed files with 4162 additions and 18 deletions
+86 -7
View File
@@ -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<unknown> {
type SyncControl = { checkCancelled: () => Promise<void>; report: (phase: string, completed: number, total: number, message: string) => Promise<void> };
async function getJson(url: string, signal?: AbortSignal): Promise<unknown> {
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<unknown> {
return response.json();
}
async function getOptionalJson(url: string): Promise<unknown | null> {
async function getOptionalJson(url: string, signal?: AbortSignal): Promise<unknown | null> {
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<string> {
async function mapConcurrent<T, R>(
items: T[],
task: (item: T) => Promise<R>,
control?: SyncControl,
): Promise<R[]> {
const results = new Array<R>(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<Set<string>> {
const storage = await getMediaStorage();
let next = 0;
const missing = new Set<string>();
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<void> {
async function syncCatalogVersion(version: string, control?: SyncControl): Promise<void> {
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<void> {
baseStats: detail ? baseStatsSnapshot(detail) : [],
};
},
control,
);
const customCharacters: SyncedCharacter[] = await mapConcurrent(
[...customTravelers],
@@ -315,6 +327,7 @@ async function syncCatalogVersion(version: string): Promise<void> {
baseStats: baseStatsSnapshot(detail),
};
},
control,
);
let characters = [...remoteCharacters, ...customCharacters];
let weapons = Object.entries(weaponData)
@@ -409,7 +422,8 @@ async function syncCatalogVersion(version: string): Promise<void> {
});
}
}
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<void> {
),
]);
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<void> {
})),
);
});
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<void> {
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<CatalogSyncJob | null> {
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<CatalogSyncJob | null> {
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<CatalogSyncJob> {
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<string | null> {
const storage = await getMediaStorage();
const current = storage.file("catalog/current.json");
@@ -566,6 +643,7 @@ export async function getCurrentCatalogVersion(): Promise<string | null> {
export async function processCatalogSync(
requestedVersion?: string,
control?: SyncControl,
): Promise<CatalogSyncResult> {
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";
});
}