feat(catalog) : sync Lunaris updates from Discord
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
|
||||
import { getRedisClient, redisEventPrefix } from "@/lib/redis/client";
|
||||
|
||||
const LOCK_TTL_MS = 120_000;
|
||||
const RENEW_INTERVAL_MS = 30_000;
|
||||
|
||||
const renewScript = `
|
||||
if redis.call("get", KEYS[1]) == ARGV[1] then
|
||||
return redis.call("pexpire", KEYS[1], ARGV[2])
|
||||
end
|
||||
return 0
|
||||
`;
|
||||
|
||||
const releaseScript = `
|
||||
if redis.call("get", KEYS[1]) == ARGV[1] then
|
||||
return redis.call("del", KEYS[1])
|
||||
end
|
||||
return 0
|
||||
`;
|
||||
|
||||
export class CatalogSyncBusyError extends Error {
|
||||
constructor() {
|
||||
super("กำลัง Sync Catalog อยู่");
|
||||
this.name = "CatalogSyncBusyError";
|
||||
}
|
||||
}
|
||||
|
||||
export async function withCatalogSyncLock<T>(
|
||||
task: () => Promise<T>,
|
||||
): Promise<T> {
|
||||
const redis = await getRedisClient();
|
||||
const key = `${redisEventPrefix()}:catalog-sync-lock`;
|
||||
const token = randomUUID();
|
||||
const acquired = await redis.set(key, token, "PX", LOCK_TTL_MS, "NX");
|
||||
if (acquired !== "OK") throw new CatalogSyncBusyError();
|
||||
|
||||
let renewing = false;
|
||||
const timer = setInterval(() => {
|
||||
if (renewing) return;
|
||||
renewing = true;
|
||||
void redis
|
||||
.eval(renewScript, 1, key, token, String(LOCK_TTL_MS))
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
renewing = false;
|
||||
});
|
||||
}, RENEW_INTERVAL_MS);
|
||||
timer.unref();
|
||||
|
||||
try {
|
||||
return await task();
|
||||
} finally {
|
||||
clearInterval(timer);
|
||||
await redis.eval(releaseScript, 1, key, token).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
+37
-3
@@ -21,15 +21,21 @@ import type {
|
||||
CatalogTalent,
|
||||
} from "@/lib/catalog/lunaris";
|
||||
import { customTravelers, isUpstreamTraveler } from "@/lib/catalog/travelers";
|
||||
import { withCatalogSyncLock } from "@/lib/catalog/sync-lock";
|
||||
import {
|
||||
compareLunarisVersions,
|
||||
lunarisVersionSchema,
|
||||
} from "@/lib/catalog/version";
|
||||
import { getMediaStorage } from "@/lib/media/storage";
|
||||
|
||||
const VERSION_URL = "https://api.lunaris.moe/data/version.json";
|
||||
const API_ROOT = "https://api.lunaris.moe/data";
|
||||
|
||||
const versionSchema = z.object({
|
||||
version: z.string().regex(/^\d+(?:\.\d+)+$/u),
|
||||
version: lunarisVersionSchema,
|
||||
timestamp: z.number().int().optional(),
|
||||
});
|
||||
const currentVersionSchema = z.object({ version: lunarisVersionSchema });
|
||||
const characterSchema = z.record(
|
||||
z.string(),
|
||||
z.object({
|
||||
@@ -179,8 +185,7 @@ async function uploadAssets(
|
||||
return missing;
|
||||
}
|
||||
|
||||
export async function processCatalogSync(): Promise<void> {
|
||||
const version = await getLatestLunarisVersion();
|
||||
async function syncCatalogVersion(version: string): Promise<void> {
|
||||
const [charactersRaw, weaponsRaw, artifactsRaw] = await Promise.all([
|
||||
getJson(`${API_ROOT}/${version}/charlist.json`),
|
||||
getJson(`${API_ROOT}/${version}/weaponlist.json`),
|
||||
@@ -491,3 +496,32 @@ export async function processCatalogSync(): Promise<void> {
|
||||
acl: "public-read",
|
||||
});
|
||||
}
|
||||
|
||||
export type CatalogSyncResult = "synced" | "unchanged";
|
||||
|
||||
export async function getCurrentCatalogVersion(): Promise<string | null> {
|
||||
const storage = await getMediaStorage();
|
||||
const current = storage.file("catalog/current.json");
|
||||
if (!(await current.exists())) return null;
|
||||
return currentVersionSchema.parse(await current.json()).version;
|
||||
}
|
||||
|
||||
export async function processCatalogSync(
|
||||
requestedVersion?: string,
|
||||
): Promise<CatalogSyncResult> {
|
||||
const version = lunarisVersionSchema.parse(
|
||||
requestedVersion ?? (await getLatestLunarisVersion()),
|
||||
);
|
||||
|
||||
return withCatalogSyncLock(async () => {
|
||||
const currentVersion = await getCurrentCatalogVersion();
|
||||
if (
|
||||
currentVersion &&
|
||||
compareLunarisVersions(version, currentVersion) <= 0
|
||||
) {
|
||||
return "unchanged";
|
||||
}
|
||||
await syncCatalogVersion(version);
|
||||
return "synced";
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
compareLunarisVersions,
|
||||
parseLunarisVersionChange,
|
||||
} from "./version";
|
||||
|
||||
describe("Lunaris version announcements", () => {
|
||||
it("extracts a version change from a full announcement", () => {
|
||||
expect(
|
||||
parseLunarisVersionChange(`Lunaris has been updated!
|
||||
Version Change: 7.0.53.2 → 7.0.54
|
||||
Characters
|
||||
Odette`),
|
||||
).toEqual({
|
||||
previousVersion: "7.0.53.2",
|
||||
targetVersion: "7.0.54",
|
||||
});
|
||||
});
|
||||
|
||||
it("ignores ordinary and malformed messages", () => {
|
||||
expect(parseLunarisVersionChange("hello")).toBeNull();
|
||||
expect(parseLunarisVersionChange("Version Change: latest → 7.0.54")).toBeNull();
|
||||
expect(parseLunarisVersionChange("Version Change: 7.0.53 -> 7.0.54")).toBeNull();
|
||||
});
|
||||
|
||||
it("orders numeric dotted versions with different segment counts", () => {
|
||||
expect(compareLunarisVersions("7.0.54", "7.0.53.2")).toBe(1);
|
||||
expect(compareLunarisVersions("7.0.54", "7.0.54.0")).toBe(0);
|
||||
expect(compareLunarisVersions("7.0.53.2", "7.0.54")).toBe(-1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,33 @@
|
||||
import * as z from "zod";
|
||||
|
||||
export const lunarisVersionSchema = z.string().regex(/^\d+(?:\.\d+)+$/u);
|
||||
|
||||
export interface LunarisVersionChange {
|
||||
previousVersion: string;
|
||||
targetVersion: string;
|
||||
}
|
||||
|
||||
export function parseLunarisVersionChange(
|
||||
message: string,
|
||||
): LunarisVersionChange | null {
|
||||
const match = message.match(
|
||||
/^Version Change:\s*(\d+(?:\.\d+)+)\s*→\s*(\d+(?:\.\d+)+)\s*$/imu,
|
||||
);
|
||||
if (!match) return null;
|
||||
return {
|
||||
previousVersion: lunarisVersionSchema.parse(match[1]),
|
||||
targetVersion: lunarisVersionSchema.parse(match[2]),
|
||||
};
|
||||
}
|
||||
|
||||
export function compareLunarisVersions(left: string, right: string): number {
|
||||
const leftParts = lunarisVersionSchema.parse(left).split(".").map(Number);
|
||||
const rightParts = lunarisVersionSchema.parse(right).split(".").map(Number);
|
||||
const length = Math.max(leftParts.length, rightParts.length);
|
||||
|
||||
for (let index = 0; index < length; index += 1) {
|
||||
const difference = (leftParts[index] ?? 0) - (rightParts[index] ?? 0);
|
||||
if (difference !== 0) return Math.sign(difference);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
Reference in New Issue
Block a user