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
This commit is contained in:
2025-12-16 18:41:14 +00:00
parent f507d51c61
commit 285c7da87c
5 changed files with 434 additions and 1 deletions

1
.junie/guidelines.md Symbolic link
View File

@@ -0,0 +1 @@
../AGENTS.md

14
COMMIT_EDITMSG Normal file
View File

@@ -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

View File

@@ -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 }) {
<hr />
<div>
<h5>Files</h5>
{(!data.files || data.files.length === 0) && <div className="text-secondary">No files linked</div>}
{data.files && data.files.length > 0 && (
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Type</th>
<th>Link</th>
<th style={{ width: 120 }} className="text-end">Size</th>
<th style={{ width: 260 }}>MD5</th>
<th>Comments</th>
</tr>
</thead>
<tbody>
{data.files.map((f) => {
const isHttp = f.link.startsWith("http://") || f.link.startsWith("https://");
return (
<tr key={f.id}>
<td><span className="badge text-bg-secondary">{f.type.name}</span></td>
<td>
{isHttp ? (
<a href={f.link} target="_blank" rel="noopener noreferrer">{f.link}</a>
) : (
<span>{f.link}</span>
)}
</td>
<td className="text-end">{f.size != null ? new Intl.NumberFormat().format(f.size) : "-"}</td>
<td><code>{f.md5 ?? "-"}</code></td>
<td>{f.comments ?? ""}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
<hr />
<div>
<h5>Downloads</h5>
{(!data.releases || data.releases.length === 0) && <div className="text-secondary">No downloads</div>}
{data.releases && data.releases.length > 0 && (
<div className="vstack gap-3">
{data.releases.map((r) => (
<div key={r.releaseSeq} className="card">
<div className="card-header d-flex align-items-center gap-2 flex-wrap">
<span className="badge text-bg-secondary">Release #{r.releaseSeq}</span>
{r.type.name && <span className="badge text-bg-primary">{r.type.name}</span>}
{r.language.name && <span className="badge text-bg-info">{r.language.name}</span>}
{r.machinetype.name && <span className="badge text-bg-warning text-dark">{r.machinetype.name}</span>}
{r.year && <span className="badge text-bg-light text-dark">{r.year}</span>}
{r.comments && <span className="text-secondary">{r.comments}</span>}
</div>
<div className="card-body">
{r.downloads.length === 0 ? (
<div className="text-secondary">No downloads in this release</div>
) : (
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Type</th>
<th>Link</th>
<th style={{ width: 120 }} className="text-end">Size</th>
<th style={{ width: 260 }}>MD5</th>
<th>Flags</th>
<th>Details</th>
</tr>
</thead>
<tbody>
{r.downloads.map((d) => {
const isHttp = d.link.startsWith("http://") || d.link.startsWith("https://");
return (
<tr key={d.id}>
<td><span className="badge text-bg-secondary">{d.type.name}</span></td>
<td>
{isHttp ? (
<a href={d.link} target="_blank" rel="noopener noreferrer">{d.link}</a>
) : (
<span>{d.link}</span>
)}
</td>
<td className="text-end">{d.size != null ? new Intl.NumberFormat().format(d.size) : "-"}</td>
<td><code>{d.md5 ?? "-"}</code></td>
<td>
<div className="d-flex flex-wrap gap-1">
{d.isDemo && <span className="badge rounded-pill text-bg-warning text-dark">Demo</span>}
{d.language.name && <span className="badge rounded-pill text-bg-info">{d.language.name}</span>}
{d.machinetype.name && <span className="badge rounded-pill text-bg-primary">{d.machinetype.name}</span>}
</div>
</td>
<td>
<div className="d-flex flex-wrap gap-1">
{d.scheme.name && <span className="badge text-bg-light text-dark">{d.scheme.name}</span>}
{d.source.name && <span className="badge text-bg-light text-dark">{d.source.name}</span>}
{d.case.name && <span className="badge text-bg-light text-dark">{d.case.name}</span>}
{d.year && <span className="badge text-bg-light text-dark">{d.year}</span>}
</div>
{d.comments && <div className="text-secondary small mt-1">{d.comments}</div>}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
<hr />
<div className="d-flex align-items-center gap-2">
<Link className="btn btn-sm btn-outline-secondary" href={`/zxdb/entries/${data.id}`}>Permalink</Link>
<Link className="btn btn-sm btn-outline-primary" href="/zxdb">Back to Explorer</Link>

View File

@@ -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<EntryDetail | null> {
// 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<EntryDetail | null> {
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<number, any[]>();
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<EntryDetail | null> {
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,
};
}

View File

@@ -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 }),
});