feat : remove
CI / Verify (push) Successful in 1m3s
CI / Build immutable images and deploy (push) Failing after 14m16s

This commit is contained in:
2026-09-03 21:46:35 +07:00 Unverified
parent b93382fa1e
commit 0b16f5aa84
3 changed files with 0 additions and 1798 deletions
-315
View File
@@ -1,315 +0,0 @@
import { and, eq, inArray, isNull, sql } from "drizzle-orm";
import { closeDb, getDb } from "@/db/client";
import {
artifactGroups,
artifactProfiles,
artifactRecommendations,
catalogArtifacts,
catalogCharacters,
catalogWeapons,
constellationConditions,
constellationRecommendations,
extraSectionRows,
extraSections,
guides,
guideSections,
media,
outboxEvents,
teamCompositions,
teamMembers,
weaponRecommendations,
} from "@/db/schema";
import {
artifactSectionSchema,
constellationSectionSchema,
extraSectionSchema,
slugFromCharacterName,
teamSectionSchema,
weaponSectionSchema,
} from "@/lib/guides/schemas";
import {
buildGuideImportDraft,
guideImportSources,
type GuideImportDraft,
type ImportCatalog,
} from "@/lib/guides/google-sheet-import";
import { getMediaStorage } from "@/lib/media/storage";
import { hasValidImageSignature, MAX_MEDIA_BYTES } from "@/lib/media/validation";
const apply = process.argv.includes("--apply");
interface CoverAsset {
bytes: Uint8Array;
extension: "gif" | "jpeg" | "png" | "webp";
mimeType: "image/gif" | "image/jpeg" | "image/png" | "image/webp";
objectKey: string;
}
function coverType(bytes: Uint8Array): Pick<CoverAsset, "extension" | "mimeType"> | null {
const types = [
["png", "image/png"],
["jpeg", "image/jpeg"],
["webp", "image/webp"],
["gif", "image/gif"],
] as const;
for (const [extension, mimeType] of types) {
if (hasValidImageSignature(bytes.subarray(0, 16), mimeType)) return { extension, mimeType };
}
return null;
}
async function downloadCover(draft: GuideImportDraft): Promise<CoverAsset> {
const response = await fetch(`https://buzz.sudloh.com/cdn/${draft.source.coverId}`, {
signal: AbortSignal.timeout(30_000),
});
if (!response.ok) throw new Error(`${draft.source.characterName}: cover returned ${response.status}`);
const bytes = new Uint8Array(await response.arrayBuffer());
if (!bytes.length || bytes.length > MAX_MEDIA_BYTES) throw new Error(`${draft.source.characterName}: invalid cover size ${bytes.length}`);
const type = coverType(bytes);
if (!type) throw new Error(`${draft.source.characterName}: unsupported cover image`);
return {
...type,
bytes,
objectKey: `imports/buzz-guide/${draft.characterKey}/cover.${type.extension}`,
};
}
function validateDraft(draft: GuideImportDraft): void {
weaponSectionSchema.parse({ expectedVersion: 1, note: null, rows: draft.weapons });
artifactSectionSchema.parse({ expectedVersion: 1, ...draft.artifacts });
constellationSectionSchema.parse({ expectedVersion: 1, note: null, groups: draft.constellationGroups });
teamSectionSchema.parse({ expectedVersion: 1, teams: draft.teams });
extraSectionSchema.parse({
expectedVersion: 1,
sections: draft.extras.map((section) => ({
title: section.title,
rows: section.rows.map((row) => ({ ...row, mediaId: null, ratio: "16:9" as const })),
})),
});
}
function reportFor(draft: GuideImportDraft) {
return {
character: draft.source.characterName,
action: draft.source.skipWrite ? "keep-existing" : draft.source.characterName === "Columbina" ? "restore-or-update" : "create-or-update",
weapons: draft.weapons.length,
artifacts: draft.artifacts.groups.reduce((total, group) => total + group.rows.length, 0),
constellations: draft.constellationGroups.reduce((total, group) => total + group.rows.length, 0),
teams: draft.teams.length,
extras: draft.extras.reduce((total, section) => total + section.rows.length, 0),
skipped: [...new Set(draft.skipped)],
};
}
async function loadCatalog(): Promise<ImportCatalog> {
const db = getDb();
const [characters, weapons, artifacts] = await Promise.all([
db.select({ key: catalogCharacters.key, name: catalogCharacters.name, constellations: catalogCharacters.constellations }).from(catalogCharacters),
db.select({ key: catalogWeapons.key, name: catalogWeapons.name }).from(catalogWeapons),
db.select({ key: catalogArtifacts.key, name: catalogArtifacts.name }).from(catalogArtifacts),
]);
return { characters, weapons, artifacts };
}
async function uniqueSlug(tx: Parameters<Parameters<ReturnType<typeof getDb>["transaction"]>[0]>[0], name: string, key: string): Promise<string> {
const base = slugFromCharacterName(name, key);
const [collision] = await tx.select({ id: guides.id }).from(guides).where(eq(guides.slug, base)).limit(1);
return collision ? `${base}-${key.toLowerCase()}` : base;
}
async function replaceGuide(
tx: Parameters<Parameters<ReturnType<typeof getDb>["transaction"]>[0]>[0],
draft: GuideImportDraft,
coverMediaId: string,
sortOrder: number,
): Promise<void> {
const [catalogCharacter] = await tx.select().from(catalogCharacters).where(eq(catalogCharacters.key, draft.characterKey)).limit(1);
if (!catalogCharacter) throw new Error(`${draft.source.characterName}: character disappeared from catalog`);
const [existing] = await tx.select().from(guides).where(eq(guides.characterKey, draft.characterKey)).limit(1);
let guideId: string;
let version: number;
if (existing) {
guideId = existing.id;
version = existing.version + 1;
await tx.update(guides).set({
overview: draft.source.overview,
coverMediaId,
sortOrder,
trashedAt: null,
version,
}).where(eq(guides.id, guideId));
if (existing.coverMediaId !== coverMediaId) {
await tx.update(media).set({ currentReferenceCount: sql`greatest(${media.currentReferenceCount} - 1, 0)` }).where(eq(media.id, existing.coverMediaId));
await tx.update(media).set({ currentReferenceCount: sql`${media.currentReferenceCount} + 1` }).where(eq(media.id, coverMediaId));
}
} else {
const slug = await uniqueSlug(tx, catalogCharacter.name, catalogCharacter.key);
const [created] = await tx.insert(guides).values({
characterKey: catalogCharacter.key,
slug,
name: catalogCharacter.name,
overview: draft.source.overview,
coverMediaId,
sortOrder,
}).returning();
guideId = created.id;
version = created.version;
await tx.update(media).set({ currentReferenceCount: sql`${media.currentReferenceCount} + 1` }).where(eq(media.id, coverMediaId));
}
await Promise.all([
tx.delete(weaponRecommendations).where(eq(weaponRecommendations.guideId, guideId)),
tx.delete(artifactGroups).where(eq(artifactGroups.guideId, guideId)),
tx.delete(artifactProfiles).where(eq(artifactProfiles.guideId, guideId)),
tx.delete(constellationConditions).where(eq(constellationConditions.guideId, guideId)),
tx.delete(teamCompositions).where(eq(teamCompositions.guideId, guideId)),
tx.delete(guideSections).where(eq(guideSections.guideId, guideId)),
]);
const defaultSections = [
{ kind: "weapon", slug: "weapon", title: "Weapons", sortOrder: 0 },
{ kind: "artifact", slug: "artifact", title: "Artifacts & Stats", sortOrder: 1 },
{ kind: "constellations", slug: "constellations", title: "Constellations", sortOrder: 2 },
{ kind: "team", slug: "team", title: "Team Calc", sortOrder: 3 },
];
await tx.insert(guideSections).values(defaultSections.map((section) => ({ ...section, guideId })));
if (draft.weapons.length) {
await tx.insert(weaponRecommendations).values(draft.weapons.map((row, rowIndex) => ({
...row,
guideId,
sortOrder: rowIndex,
overallPercent: row.overallPercent?.toString() ?? null,
personalPercent: row.personalPercent?.toString() ?? null,
})));
}
for (const [groupIndex, group] of draft.artifacts.groups.entries()) {
const [created] = await tx.insert(artifactGroups).values({ guideId, name: group.name, sortOrder: groupIndex }).returning();
if (group.rows.length) {
await tx.insert(artifactRecommendations).values(group.rows.map((row, rowIndex) => ({
...row,
groupId: created.id,
sortOrder: rowIndex,
percent: row.percent.toString(),
})));
}
}
await tx.insert(artifactProfiles).values({
guideId,
sands: draft.artifacts.sands,
goblet: draft.artifacts.goblet,
circlet: draft.artifacts.circlet,
substats: draft.artifacts.substats,
targets: draft.artifacts.targets,
talents: draft.artifacts.talents,
note: draft.artifacts.note,
});
for (const [groupIndex, group] of draft.constellationGroups.entries()) {
const [created] = await tx.insert(constellationConditions).values({ guideId, note: group.note, sortOrder: groupIndex }).returning();
if (group.rows.length) {
await tx.insert(constellationRecommendations).values(group.rows.map((row) => ({
...row,
conditionId: created.id,
cumulativePercent: row.cumulativePercent.toString(),
})));
}
}
for (const [teamIndex, team] of draft.teams.entries()) {
const [created] = await tx.insert(teamCompositions).values({
guideId,
title: team.title,
rotationSeconds: team.rotationSeconds?.toString() ?? null,
combo: team.combo,
sortOrder: teamIndex,
}).returning();
await tx.insert(teamMembers).values(team.members.map((member, position) => ({
...member,
teamId: created.id,
position,
damage: member.damage.toString(),
})));
}
for (const [extraIndex, section] of draft.extras.entries()) {
const [createdSection] = await tx.insert(guideSections).values({
guideId,
kind: "extra",
slug: `extra-${extraIndex + 1}`,
title: section.title,
sortOrder: extraIndex + 4,
}).returning();
await tx.insert(extraSections).values({ sectionId: createdSection.id });
await tx.insert(extraSectionRows).values(section.rows.map((row, rowIndex) => ({
sectionId: createdSection.id,
heading: row.heading,
body: row.body,
percent: row.percent?.toString() ?? null,
ratio: "16:9",
sortOrder: rowIndex,
})));
}
await tx.insert(outboxEvents).values([
{ topic: "directory", aggregateId: guideId, eventType: "directory.updated", payload: { id: guideId, version } },
{ topic: `page:${guideId}`, aggregateId: guideId, eventType: "page.updated", payload: { id: guideId, version } },
{ topic: "admin", aggregateId: guideId, eventType: "admin.updated", payload: { id: guideId, version } },
]);
}
async function applyDrafts(drafts: GuideImportDraft[]): Promise<void> {
const writable = drafts.filter((draft) => !draft.source.skipWrite);
const covers = new Map<string, CoverAsset>();
for (const draft of writable) covers.set(draft.characterKey, await downloadCover(draft));
const storage = await getMediaStorage();
for (const cover of covers.values()) {
await storage.write(cover.objectKey, cover.bytes, { type: cover.mimeType, acl: "public-read" });
}
const db = getDb();
const [{ maximum }] = await db.select({ maximum: sql<number>`coalesce(max(${guides.sortOrder}), 0)` }).from(guides).where(isNull(guides.trashedAt));
await db.transaction(async (tx) => {
for (const [index, draft] of writable.entries()) {
const cover = covers.get(draft.characterKey)!;
let [coverRecord] = await tx.select().from(media).where(eq(media.objectKey, cover.objectKey)).limit(1);
if (!coverRecord) {
[coverRecord] = await tx.insert(media).values({
objectKey: cover.objectKey,
fileName: `cover.${cover.extension}`,
mimeType: cover.mimeType,
byteSize: cover.bytes.length,
status: "ready",
}).returning();
} else {
[coverRecord] = await tx.update(media).set({
mimeType: cover.mimeType,
byteSize: cover.bytes.length,
status: "ready",
}).where(eq(media.id, coverRecord.id)).returning();
}
await replaceGuide(tx, draft, coverRecord.id, Number(maximum) + index + 1);
}
});
}
async function main(): Promise<void> {
const catalog = await loadCatalog();
const drafts: GuideImportDraft[] = [];
for (const source of guideImportSources) {
const draft = await buildGuideImportDraft(source, catalog);
validateDraft(draft);
drafts.push(draft);
}
console.log(JSON.stringify({ mode: apply ? "apply" : "dry-run", guides: drafts.map(reportFor) }, null, 2));
if (!apply) return;
await applyDrafts(drafts);
const sourceKeys = drafts.map((draft) => draft.characterKey);
const active = await getDb().select({ characterKey: guides.characterKey }).from(guides).where(and(inArray(guides.characterKey, sourceKeys), isNull(guides.trashedAt)));
if (active.length !== sourceKeys.length) throw new Error(`Expected ${sourceKeys.length} active source guides, found ${active.length}`);
console.log(`Imported ${drafts.filter((draft) => !draft.source.skipWrite).length} guides; ${active.length} source guides are active.`);
}
try {
await main();
} finally {
await closeDb();
}