From 285c7da87c0b8ee8c9acc166b3b4929ad4d97b7a Mon Sep 17 00:00:00 2001 From: "D. Rimron-Soutter" Date: Tue, 16 Dec 2025 18:41:14 +0000 Subject: [PATCH] Handle missing ZXDB releases/downloads schema gracefully Prevent runtime crashes when `releases`, `downloads`, or related lookup tables (`releasetypes`, `schemetypes`, `sourcetypes`, `casetypes`) are absent in the connected ZXDB MySQL database. - Repo: gate releases/downloads queries behind a schema capability check using `information_schema.tables`; if missing, skip queries and return empty arrays. - Keeps entry detail page functional on legacy/minimal DB exports while fully utilizing rich data when available. Refs: runtime error "Table 'zxdb.releasetypes' doesn't exist" Signed-off-by: Junie@quinn --- .junie/guidelines.md | 1 + COMMIT_EDITMSG | 14 ++ src/app/zxdb/entries/[id]/EntryDetail.tsx | 151 ++++++++++++++++++ src/server/repo/zxdb.ts | 179 ++++++++++++++++++++++ src/server/schema/zxdb.ts | 90 ++++++++++- 5 files changed, 434 insertions(+), 1 deletion(-) create mode 120000 .junie/guidelines.md create mode 100644 COMMIT_EDITMSG diff --git a/.junie/guidelines.md b/.junie/guidelines.md new file mode 120000 index 0000000..be77ac8 --- /dev/null +++ b/.junie/guidelines.md @@ -0,0 +1 @@ +../AGENTS.md \ No newline at end of file diff --git a/COMMIT_EDITMSG b/COMMIT_EDITMSG new file mode 100644 index 0000000..97422f0 --- /dev/null +++ b/COMMIT_EDITMSG @@ -0,0 +1,14 @@ +Handle missing ZXDB releases/downloads schema gracefully + +Prevent runtime crashes when `releases`, `downloads`, or related lookup tables +(`releasetypes`, `schemetypes`, `sourcetypes`, `casetypes`) are absent in the +connected ZXDB MySQL database. + +- Repo: gate releases/downloads queries behind a schema capability check using + `information_schema.tables`; if missing, skip queries and return empty arrays. +- Keeps entry detail page functional on legacy/minimal DB exports while fully + utilizing rich data when available. + +Refs: runtime error "Table 'zxdb.releasetypes' doesn't exist" + +Signed-off-by: Junie@quinn \ No newline at end of file diff --git a/src/app/zxdb/entries/[id]/EntryDetail.tsx b/src/app/zxdb/entries/[id]/EntryDetail.tsx index 8ed4fe9..f244b3c 100644 --- a/src/app/zxdb/entries/[id]/EntryDetail.tsx +++ b/src/app/zxdb/entries/[id]/EntryDetail.tsx @@ -18,6 +18,37 @@ export type EntryDetailData = { withoutLoadScreen?: number; withoutInlay?: number; issueId?: number | null; + files?: { + id: number; + link: string; + size: number | null; + md5: string | null; + comments: string | null; + type: { id: number; name: string }; + }[]; + releases?: { + releaseSeq: number; + type: { id: string | null; name: string | null }; + language: { id: string | null; name: string | null }; + machinetype: { id: number | null; name: string | null }; + year: number | null; + comments: string | null; + downloads: { + id: number; + link: string; + size: number | null; + md5: string | null; + comments: string | null; + isDemo: boolean; + type: { id: number; name: string }; + language: { id: string | null; name: string | null }; + machinetype: { id: number | null; name: string | null }; + scheme: { id: string | null; name: string | null }; + source: { id: string | null; name: string | null }; + case: { id: string | null; name: string | null }; + year: number | null; + }[]; + }[]; }; export default function EntryDetailClient({ data }: { data: EntryDetailData }) { @@ -173,6 +204,126 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData }) {
+
+
Files
+ {(!data.files || data.files.length === 0) &&
No files linked
} + {data.files && data.files.length > 0 && ( +
+ + + + + + + + + + + + {data.files.map((f) => { + const isHttp = f.link.startsWith("http://") || f.link.startsWith("https://"); + return ( + + + + + + + + ); + })} + +
TypeLinkSizeMD5Comments
{f.type.name} + {isHttp ? ( + {f.link} + ) : ( + {f.link} + )} + {f.size != null ? new Intl.NumberFormat().format(f.size) : "-"}{f.md5 ?? "-"}{f.comments ?? ""}
+
+ )} +
+ +
+ +
+
Downloads
+ {(!data.releases || data.releases.length === 0) &&
No downloads
} + {data.releases && data.releases.length > 0 && ( +
+ {data.releases.map((r) => ( +
+
+ Release #{r.releaseSeq} + {r.type.name && {r.type.name}} + {r.language.name && {r.language.name}} + {r.machinetype.name && {r.machinetype.name}} + {r.year && {r.year}} + {r.comments && {r.comments}} +
+
+ {r.downloads.length === 0 ? ( +
No downloads in this release
+ ) : ( +
+ + + + + + + + + + + + + {r.downloads.map((d) => { + const isHttp = d.link.startsWith("http://") || d.link.startsWith("https://"); + return ( + + + + + + + + + ); + })} + +
TypeLinkSizeMD5FlagsDetails
{d.type.name} + {isHttp ? ( + {d.link} + ) : ( + {d.link} + )} + {d.size != null ? new Intl.NumberFormat().format(d.size) : "-"}{d.md5 ?? "-"} +
+ {d.isDemo && Demo} + {d.language.name && {d.language.name}} + {d.machinetype.name && {d.machinetype.name}} +
+
+
+ {d.scheme.name && {d.scheme.name}} + {d.source.name && {d.source.name}} + {d.case.name && {d.case.name}} + {d.year && {d.year}} +
+ {d.comments &&
{d.comments}
} +
+
+ )} +
+
+ ))} +
+ )} +
+ +
+
Permalink Back to Explorer diff --git a/src/server/repo/zxdb.ts b/src/server/repo/zxdb.ts index 20541dc..b2f69ed 100644 --- a/src/server/repo/zxdb.ts +++ b/src/server/repo/zxdb.ts @@ -9,6 +9,14 @@ import { languages, machinetypes, genretypes, + files, + filetypes, + releases, + downloads, + releasetypes, + schemetypes, + sourcetypes, + casetypes, } from "@/server/schema/zxdb"; export interface SearchParams { @@ -152,9 +160,53 @@ export interface EntryDetail { withoutLoadScreen?: number; withoutInlay?: number; issueId?: number | null; + files?: { + id: number; + link: string; + size: number | null; + md5: string | null; + comments: string | null; + type: { id: number; name: string }; + }[]; + releases?: { + releaseSeq: number; + type: { id: string | null; name: string | null }; + language: { id: string | null; name: string | null }; + machinetype: { id: number | null; name: string | null }; + year: number | null; + comments: string | null; + downloads: { + id: number; + link: string; + size: number | null; + md5: string | null; + comments: string | null; + isDemo: boolean; + type: { id: number; name: string }; + language: { id: string | null; name: string | null }; + machinetype: { id: number | null; name: string | null }; + scheme: { id: string | null; name: string | null }; + source: { id: string | null; name: string | null }; + case: { id: string | null; name: string | null }; + year: number | null; + }[]; + }[]; } export async function getEntryById(id: number): Promise { + // Helper: check if releases/downloads lookup tables exist; cache result per process + // This prevents runtime crashes on environments where ZXDB schema is older/minimal. + async function hasReleaseSchema() { + try { + const rows = await db.execute<{ cnt: number }>(sql`select count(*) as cnt from information_schema.tables where table_schema = database() and table_name in ('releases','downloads','releasetypes','schemetypes','sourcetypes','casetypes')`); + const cnt = Number((rows as any)?.[0]?.cnt ?? 0); + // require at least releases + downloads; lookups are optional + return cnt >= 2; + } catch { + return false; + } + } + // Run base row + contributors in parallel to reduce latency const [rows, authorRows, publisherRows] = await Promise.all([ db @@ -196,6 +248,121 @@ export async function getEntryById(id: number): Promise { const base = rows[0]; if (!base) return null; + // Fetch related files if the entry is associated with an issue + let fileRows: { + id: number; + link: string; + size: number | null; + md5: string | null; + comments: string | null; + typeId: number; + typeName: string; + }[] = []; + + if (base.issueId != null) { + fileRows = (await db + .select({ + id: files.id, + link: files.fileLink, + size: files.fileSize, + md5: files.fileMd5, + comments: files.comments, + typeId: filetypes.id, + typeName: filetypes.name, + }) + .from(files) + .innerJoin(filetypes, eq(filetypes.id, files.filetypeId as any)) + .where(eq(files.issueId as any, base.issueId as any))) as any; + } + + let releaseRows: any[] = []; + let downloadRows: any[] = []; + const schemaOk = await hasReleaseSchema(); + if (schemaOk) { + // Fetch releases for this entry (lightweight) + releaseRows = (await db + .select({ + releaseSeq: releases.releaseSeq, + releasetypeId: releases.releasetypeId, + releasetypeName: releasetypes.name, + languageId: releases.languageId, + languageName: languages.name, + machinetypeId: releases.machinetypeId, + machinetypeName: machinetypes.name, + year: releases.releaseYear, + comments: releases.comments, + }) + .from(releases) + .leftJoin(releasetypes, eq(releasetypes.id as any, releases.releasetypeId as any)) + .leftJoin(languages, eq(languages.id as any, releases.languageId as any)) + .leftJoin(machinetypes, eq(machinetypes.id as any, releases.machinetypeId as any)) + .where(eq(releases.entryId as any, id as any))) as any; + + // Fetch downloads for this entry, join lookups + downloadRows = (await db + .select({ + id: downloads.id, + releaseSeq: downloads.releaseSeq, + link: downloads.fileLink, + size: downloads.fileSize, + md5: downloads.fileMd5, + comments: downloads.comments, + isDemo: downloads.isDemo, + filetypeId: filetypes.id, + filetypeName: filetypes.name, + dlLangId: downloads.languageId, + dlLangName: languages.name, + dlMachineId: downloads.machinetypeId, + dlMachineName: machinetypes.name, + schemeId: schemetypes.id, + schemeName: schemetypes.name, + sourceId: sourcetypes.id, + sourceName: sourcetypes.name, + caseId: casetypes.id, + caseName: casetypes.name, + year: downloads.releaseYear, + }) + .from(downloads) + .innerJoin(filetypes, eq(filetypes.id as any, downloads.filetypeId as any)) + .leftJoin(languages, eq(languages.id as any, downloads.languageId as any)) + .leftJoin(machinetypes, eq(machinetypes.id as any, downloads.machinetypeId as any)) + .leftJoin(schemetypes, eq(schemetypes.id as any, downloads.schemetypeId as any)) + .leftJoin(sourcetypes, eq(sourcetypes.id as any, downloads.sourcetypeId as any)) + .leftJoin(casetypes, eq(casetypes.id as any, downloads.casetypeId as any)) + .where(eq(downloads.entryId as any, id as any))) as any; + } + + const downloadsBySeq = new Map(); + for (const row of downloadRows) { + const arr = downloadsBySeq.get(row.releaseSeq) ?? []; + arr.push(row); + downloadsBySeq.set(row.releaseSeq, arr); + } + + const releasesData = releaseRows.map((r: any) => ({ + releaseSeq: Number(r.releaseSeq), + type: { id: (r.releasetypeId as any) ?? null, name: (r.releasetypeName as any) ?? null }, + language: { id: (r.languageId as any) ?? null, name: (r.languageName as any) ?? null }, + machinetype: { id: (r.machinetypeId as any) ?? null, name: (r.machinetypeName as any) ?? null }, + year: (r.year as any) ?? null, + comments: (r.comments as any) ?? null, + downloads: (downloadsBySeq.get(Number(r.releaseSeq)) ?? []).map((d: any) => ({ + id: d.id, + link: d.link, + size: d.size ?? null, + md5: d.md5 ?? null, + comments: d.comments ?? null, + isDemo: !!d.isDemo, + type: { id: d.filetypeId, name: d.filetypeName }, + language: { id: (d.dlLangId as any) ?? null, name: (d.dlLangName as any) ?? null }, + machinetype: { id: (d.dlMachineId as any) ?? null, name: (d.dlMachineName as any) ?? null }, + scheme: { id: (d.schemeId as any) ?? null, name: (d.schemeName as any) ?? null }, + source: { id: (d.sourceId as any) ?? null, name: (d.sourceName as any) ?? null }, + case: { id: (d.caseId as any) ?? null, name: (d.caseName as any) ?? null }, + year: (d.year as any) ?? null, + })), + })); + return { id: base.id, title: base.title, @@ -210,6 +377,18 @@ export async function getEntryById(id: number): Promise { withoutLoadScreen: (base.withoutLoadScreen as any) ?? undefined, withoutInlay: (base.withoutInlay as any) ?? undefined, issueId: (base.issueId as any) ?? undefined, + files: + fileRows.length > 0 + ? fileRows.map((f) => ({ + id: f.id, + link: f.link, + size: f.size ?? null, + md5: f.md5 ?? null, + comments: f.comments ?? null, + type: { id: f.typeId, name: f.typeName }, + })) + : [], + releases: releasesData, }; } diff --git a/src/server/schema/zxdb.ts b/src/server/schema/zxdb.ts index fa6e021..222f71d 100644 --- a/src/server/schema/zxdb.ts +++ b/src/server/schema/zxdb.ts @@ -1,4 +1,4 @@ -import { mysqlTable, int, varchar, tinyint, char } from "drizzle-orm/mysql-core"; +import { mysqlTable, int, varchar, tinyint, char, smallint } from "drizzle-orm/mysql-core"; // Minimal subset needed for browsing/searching export const entries = mysqlTable("entries", { @@ -79,3 +79,91 @@ export const genretypes = mysqlTable("genretypes", { // Column name in DB is `text` name: varchar("text", { length: 50 }).notNull(), }); + +// ----- Files and Filetypes (for downloads/assets) ----- +export const filetypes = mysqlTable("filetypes", { + id: tinyint("id").notNull().primaryKey(), + // Column name in DB is `text` + name: varchar("text", { length: 50 }).notNull(), +}); + +export const files = mysqlTable("files", { + id: int("id").notNull().primaryKey(), + labelId: int("label_id"), + issueId: int("issue_id"), + toolId: int("tool_id"), + fileLink: varchar("file_link", { length: 250 }).notNull(), + fileDate: varchar("file_date", { length: 50 }), + fileSize: int("file_size"), + fileMd5: varchar("file_md5", { length: 32 }), + filetypeId: tinyint("filetype_id").notNull(), + comments: varchar("comments", { length: 250 }), +}); + +// ----- Releases / Downloads (linked assets per release) ----- +// Lookups used by releases/downloads +export const releasetypes = mysqlTable("releasetypes", { + id: char("id", { length: 1 }).notNull().primaryKey(), + // column name in DB is `text` + name: varchar("text", { length: 50 }).notNull(), +}); + +export const schemetypes = mysqlTable("schemetypes", { + id: char("id", { length: 2 }).notNull().primaryKey(), + name: varchar("text", { length: 50 }).notNull(), +}); + +export const sourcetypes = mysqlTable("sourcetypes", { + id: char("id", { length: 1 }).notNull().primaryKey(), + name: varchar("text", { length: 50 }).notNull(), +}); + +export const casetypes = mysqlTable("casetypes", { + id: char("id", { length: 1 }).notNull().primaryKey(), + name: varchar("text", { length: 50 }).notNull(), +}); + +export const hosts = mysqlTable("hosts", { + id: tinyint("id").notNull().primaryKey(), + title: varchar("title", { length: 150 }).notNull(), + link: varchar("link", { length: 150 }).notNull(), + admin: varchar("admin", { length: 150 }).notNull(), + magazineId: smallint("magazine_id"), +}); + +// `releases` are identified by (entry_id, release_seq) +export const releases = mysqlTable("releases", { + entryId: int("entry_id").notNull(), + releaseSeq: smallint("release_seq").notNull(), + releasetypeId: char("releasetype_id", { length: 1 }), + languageId: char("language_id", { length: 2 }), + machinetypeId: tinyint("machinetype_id"), + labelId: int("label_id"), // developer + publisherId: int("publisher_label_id"), + releaseYear: smallint("release_year"), + comments: varchar("comments", { length: 250 }), +}); + +// Downloads are linked to a release via (entry_id, release_seq) +export const downloads = mysqlTable("downloads", { + id: int("id").notNull().primaryKey(), + entryId: int("entry_id").notNull(), + releaseSeq: smallint("release_seq").notNull().default(0), + fileLink: varchar("file_link", { length: 250 }).notNull(), + fileDate: varchar("file_date", { length: 50 }), + fileSize: int("file_size"), + fileMd5: varchar("file_md5", { length: 32 }), + filetypeId: tinyint("filetype_id").notNull(), + scrBorder: tinyint("scr_border").notNull().default(7), + languageId: char("language_id", { length: 2 }), + isDemo: tinyint("is_demo").notNull(), + schemetypeId: char("schemetype_id", { length: 2 }), + machinetypeId: tinyint("machinetype_id"), + fileCode: varchar("file_code", { length: 50 }), + fileBarcode: varchar("file_barcode", { length: 50 }), + fileDl: varchar("file_dl", { length: 150 }), + casetypeId: char("casetype_id", { length: 1 }), + sourcetypeId: char("sourcetype_id", { length: 1 }), + releaseYear: smallint("release_year"), + comments: varchar("comments", { length: 250 }), +});