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 && (
+
+
+
+
+ | Type |
+ Link |
+ Size |
+ MD5 |
+ Comments |
+
+
+
+ {data.files.map((f) => {
+ const isHttp = f.link.startsWith("http://") || f.link.startsWith("https://");
+ return (
+
+ | {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
+ ) : (
+
+
+
+
+ | Type |
+ Link |
+ Size |
+ MD5 |
+ Flags |
+ Details |
+
+
+
+ {r.downloads.map((d) => {
+ const isHttp = d.link.startsWith("http://") || d.link.startsWith("https://");
+ return (
+
+ | {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 }),
+});