Files
explorer/src/server/repo/zxdb.ts
D. Rimron-Soutter e2f6aac856 Expand ZXDB entry data and search
Add entry release/license sections, label permissions/licenses,
expanded search scope (titles+aliases+origins), and home search.
Also include ZXDB submodule and update gitignore.

Signed-off-by: codex@lucy.xalior.com
2026-01-10 18:04:04 +00:00

1949 lines
66 KiB
TypeScript

import { and, desc, eq, like, sql, asc } from "drizzle-orm";
import { cache } from "react";
// import { alias } from "drizzle-orm/mysql-core";
import { db } from "@/server/db";
import {
entries,
searchByTitles,
searchByAliases,
searchByOrigins,
labels,
authors,
publishers,
languages,
machinetypes,
genretypes,
files,
filetypes,
releases,
downloads,
scraps,
schemetypes,
sourcetypes,
casetypes,
availabletypes,
currencies,
roletypes,
aliases,
relatedlicenses,
licenses,
licensetypes,
licensors,
permissions,
permissiontypes,
webrefs,
websites,
magazines,
issues,
magrefs,
searchByMagrefs,
referencetypes,
} from "@/server/schema/zxdb";
export type EntrySearchScope = "title" | "title_aliases" | "title_aliases_origins";
export interface SearchParams {
q?: string;
page?: number; // 1-based
pageSize?: number; // default 20
// Optional simple filters (ANDed together)
genreId?: number;
languageId?: string;
machinetypeId?: number;
// Sorting
sort?: "title" | "id_desc";
// Search scope (defaults to titles only)
scope?: EntrySearchScope;
}
export interface SearchResultItem {
id: number;
title: string;
isXrated: number;
machinetypeId: number | null;
machinetypeName?: string | null;
languageId: string | null;
languageName?: string | null;
}
export interface PagedResult<T> {
items: T[];
page: number;
pageSize: number;
total: number;
}
export interface FacetItem<T extends number | string> {
id: T;
name: string;
count: number;
}
export interface EntryFacets {
genres: FacetItem<number>[];
languages: FacetItem<string>[];
machinetypes: FacetItem<number>[];
}
function buildEntrySearchUnion(pattern: string, scope: EntrySearchScope) {
const parts: Array<ReturnType<typeof sql>> = [
sql`select ${searchByTitles.entryId} as entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern}`,
];
if (scope !== "title") {
parts.push(sql`select ${searchByAliases.entryId} as entry_id from ${searchByAliases} where ${searchByAliases.libraryTitle} like ${pattern}`);
}
if (scope === "title_aliases_origins") {
parts.push(sql`select ${searchByOrigins.entryId} as entry_id from ${searchByOrigins} where ${searchByOrigins.libraryTitle} like ${pattern}`);
}
return sql.join(parts, sql` union `);
}
export interface MagazineListItem {
id: number;
title: string;
languageId: string;
issueCount: number;
}
export interface MagazineDetail {
id: number;
title: string;
languageId: string;
linkSite?: string | null;
linkMask?: string | null;
archiveMask?: string | null;
issues: Array<{
id: number;
dateYear: number | null;
dateMonth: number | null;
number: number | null;
volume: number | null;
special: string | null;
supplement: string | null;
linkMask?: string | null;
archiveMask?: string | null;
}>;
}
export interface IssueDetail {
id: number;
magazine: { id: number; title: string };
dateYear: number | null;
dateMonth: number | null;
number: number | null;
volume: number | null;
special: string | null;
supplement: string | null;
linkMask?: string | null;
archiveMask?: string | null;
refs: Array<{
id: number;
page: number;
typeId: number;
typeName: string;
entryId: number | null;
entryTitle: string | null;
labelId: number | null;
labelName: string | null;
isOriginal: number;
scoreGroup: string;
}>
}
export async function searchEntries(params: SearchParams): Promise<PagedResult<SearchResultItem>> {
const q = (params.q ?? "").trim();
const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100));
const page = Math.max(1, params.page ?? 1);
const offset = (page - 1) * pageSize;
const sort = params.sort ?? (q ? "title" : "id_desc");
const scope: EntrySearchScope = params.scope ?? "title";
if (q.length === 0) {
// Default listing: return first page by id desc (no guaranteed ordering field; using id)
// Apply optional filters even without q
const whereClauses: Array<ReturnType<typeof eq>> = [];
if (typeof params.genreId === "number") {
whereClauses.push(eq(entries.genretypeId, params.genreId));
}
if (typeof params.languageId === "string") {
whereClauses.push(eq(entries.languageId, params.languageId));
}
if (typeof params.machinetypeId === "number") {
whereClauses.push(eq(entries.machinetypeId, params.machinetypeId));
}
const whereExpr = whereClauses.length ? and(...whereClauses) : undefined;
const [items, countRows] = await Promise.all([
(async () => {
const q1 = db
.select({
id: entries.id,
title: entries.title,
isXrated: entries.isXrated,
machinetypeId: entries.machinetypeId,
machinetypeName: machinetypes.name,
languageId: entries.languageId,
languageName: languages.name,
})
.from(entries)
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId))
.leftJoin(languages, eq(languages.id, entries.languageId))
.where(whereExpr ?? sql`true`)
.orderBy(sort === "id_desc" ? desc(entries.id) : entries.title)
.limit(pageSize)
.offset(offset);
return q1;
})(),
db
.select({ total: sql<number>`count(*)` })
.from(entries)
.where(whereExpr ?? sql`true`),
]);
const total = Number(countRows?.[0]?.total ?? 0);
return { items, page, pageSize, total };
}
const pattern = `%${q.toLowerCase().replace(/[^a-z0-9]+/g, "")}%`;
if (scope !== "title") {
try {
const union = buildEntrySearchUnion(pattern, scope);
const countRows = await db.execute(sql`
select count(distinct entry_id) as total
from (${union}) as matches
`);
type CountRow = { total: number | string };
const total = Number((countRows as unknown as CountRow[])[0]?.total ?? 0);
const items = await db
.select({
id: entries.id,
title: entries.title,
isXrated: entries.isXrated,
machinetypeId: entries.machinetypeId,
machinetypeName: machinetypes.name,
languageId: entries.languageId,
languageName: languages.name,
})
.from(entries)
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId))
.leftJoin(languages, eq(languages.id, entries.languageId))
.where(sql`${entries.id} in (select entry_id from (${union}) as matches)`)
.groupBy(entries.id)
.orderBy(sort === "id_desc" ? desc(entries.id) : entries.title)
.limit(pageSize)
.offset(offset);
return { items, page, pageSize, total };
} catch {
// Fall through to title-only search if helper tables are unavailable.
}
}
// Count matches via helper table
const countRows = await db
.select({ total: sql<number>`count(distinct ${searchByTitles.entryId})` })
.from(searchByTitles)
.where(like(searchByTitles.entryTitle, pattern));
const total = Number(countRows[0]?.total ?? 0);
// Items using join to entries, distinct entry ids
const items = await db
.select({
id: entries.id,
title: entries.title,
isXrated: entries.isXrated,
machinetypeId: entries.machinetypeId,
machinetypeName: machinetypes.name,
languageId: entries.languageId,
languageName: languages.name,
})
.from(searchByTitles)
.innerJoin(entries, eq(entries.id, searchByTitles.entryId))
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId))
.leftJoin(languages, eq(languages.id, entries.languageId))
.where(like(searchByTitles.entryTitle, pattern))
.groupBy(entries.id)
.orderBy(sort === "id_desc" ? desc(entries.id) : entries.title)
.limit(pageSize)
.offset(offset);
return { items, page, pageSize, total };
}
export interface LabelSummary {
id: number;
name: string;
labeltypeId: string | null;
}
export interface EntryDetail {
id: number;
title: string;
isXrated: number;
machinetype: { id: number | null; name: string | null };
language: { id: string | null; name: string | null };
genre: { id: number | null; name: string | null };
authors: LabelSummary[];
publishers: LabelSummary[];
licenses?: {
id: number;
name: string;
type: { id: string; name: string | null };
isOfficial: boolean;
linkWikipedia?: string | null;
linkSite?: string | null;
comments?: string | null;
}[];
// Additional entry fields for richer details
maxPlayers?: number;
availabletypeId?: string | null;
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 };
}[];
// Flat downloads by entry_id (no dependency on releases)
downloadsFlat?: {
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;
releaseSeq: number;
}[];
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;
}[];
}[];
// Additional relationships surfaced on the entry detail page
aliases?: { releaseSeq: number; languageId: string; title: string }[];
webrefs?: { link: string; languageId: string; website: { id: number; name: string; link?: string | null } }[];
}
export async function getEntryById(id: number): Promise<EntryDetail | null> {
// Run base row + contributors in parallel to reduce latency
const [rows, authorRows, publisherRows] = await Promise.all([
db
.select({
id: entries.id,
title: entries.title,
isXrated: entries.isXrated,
machinetypeId: entries.machinetypeId,
machinetypeName: machinetypes.name,
languageId: entries.languageId,
languageName: languages.name,
genreId: entries.genretypeId,
genreName: genretypes.name,
maxPlayers: entries.maxPlayers,
availabletypeId: entries.availabletypeId,
withoutLoadScreen: entries.withoutLoadScreen,
withoutInlay: entries.withoutInlay,
issueId: entries.issueId,
})
.from(entries)
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId))
.leftJoin(languages, eq(languages.id, entries.languageId))
.leftJoin(genretypes, eq(genretypes.id, entries.genretypeId))
.where(eq(entries.id, id)),
db
.select({ id: labels.id, name: labels.name, labeltypeId: labels.labeltypeId })
.from(authors)
.innerJoin(labels, eq(labels.id, authors.labelId))
.where(eq(authors.entryId, id))
.groupBy(labels.id),
db
.select({ id: labels.id, name: labels.name, labeltypeId: labels.labeltypeId })
.from(publishers)
.innerJoin(labels, eq(labels.id, publishers.labelId))
.where(eq(publishers.entryId, id))
.groupBy(labels.id),
]);
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))
.where(eq(files.issueId, base.issueId)));
}
type ReleaseRow = { releaseSeq: number | string; year: number | string | null };
type DownloadRow = {
id: number | string;
releaseSeq: number | string;
link: string;
size: number | string | null;
md5: string | null;
comments: string | null;
isDemo: number | boolean | null;
filetypeId: number | string;
filetypeName: string;
dlLangId: string | null;
dlLangName: string | null;
dlMachineId: number | string | null;
dlMachineName: string | null;
schemeId: string | null;
schemeName: string | null;
sourceId: string | null;
sourceName: string | null;
caseId: string | null;
caseName: string | null;
year: number | string | null;
};
let releaseRows: ReleaseRow[] = [];
let downloadRows: DownloadRow[] = [];
let downloadFlatRows: DownloadRow[] = [];
// Fetch releases for this entry (optional; ignore if table missing)
try {
releaseRows = (await db
.select({
releaseSeq: releases.releaseSeq,
year: releases.releaseYear,
})
.from(releases)
.where(eq(releases.entryId, id)));
} catch {
releaseRows = [];
}
// Fetch downloads for this entry, join lookups (do not gate behind schema checks)
try {
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, downloads.filetypeId))
.leftJoin(languages, eq(languages.id, downloads.languageId))
.leftJoin(machinetypes, eq(machinetypes.id, downloads.machinetypeId))
.leftJoin(schemetypes, eq(schemetypes.id, downloads.schemetypeId))
.leftJoin(sourcetypes, eq(sourcetypes.id, downloads.sourcetypeId))
.leftJoin(casetypes, eq(casetypes.id, downloads.casetypeId))
.where(eq(downloads.entryId, id)));
} catch {
downloadRows = [];
}
// Flat list: same rows mapped, independent of releases
downloadFlatRows = downloadRows;
const downloadsBySeq = new Map<number, DownloadRow[]>();
for (const row of downloadRows) {
const key = Number(row.releaseSeq);
const arr = downloadsBySeq.get(key) ?? [];
arr.push(row);
downloadsBySeq.set(key, arr);
}
// Build a map of downloads grouped by release_seq
// Then ensure we create "synthetic" release groups for any release_seq
// that appears in downloads but has no corresponding releases row.
const releasesData = releaseRows.map((r) => ({
releaseSeq: Number(r.releaseSeq),
type: { id: null, name: null },
language: { id: null, name: null },
machinetype: { id: null, name: null },
year: r.year != null ? Number(r.year) : null,
comments: null,
downloads: (downloadsBySeq.get(Number(r.releaseSeq)) ?? []).map((d) => ({
id: Number(d.id),
link: d.link,
size: d.size != null ? Number(d.size) : null,
md5: d.md5 ?? null,
comments: d.comments ?? null,
isDemo: !!d.isDemo,
type: { id: Number(d.filetypeId), name: d.filetypeName },
language: { id: (d.dlLangId) ?? null, name: (d.dlLangName) ?? null },
machinetype: { id: d.dlMachineId != null ? Number(d.dlMachineId) : null, name: (d.dlMachineName) ?? null },
scheme: { id: (d.schemeId) ?? null, name: (d.schemeName) ?? null },
source: { id: (d.sourceId) ?? null, name: (d.sourceName) ?? null },
case: { id: (d.caseId) ?? null, name: (d.caseName) ?? null },
year: d.year != null ? Number(d.year) : null,
})),
}));
// No synthetic release groups: only real releases are returned
// Sort releases by sequence for stable UI order
releasesData.sort((a, b) => a.releaseSeq - b.releaseSeq);
// Fetch extra relationships in parallel
let aliasRows: { releaseSeq: number | string; languageId: string; title: string }[] = [];
let webrefRows: { link: string; languageId: string; websiteId: number | string; websiteName: string; websiteLink: string | null }[] = [];
let licenseRows: {
id: number | string;
name: string;
licensetypeId: string;
licensetypeName: string | null;
isOfficial: number | boolean;
linkWikipedia: string | null;
linkSite: string | null;
comments: string | null;
}[] = [];
try {
aliasRows = await db
.select({ releaseSeq: aliases.releaseSeq, languageId: aliases.languageId, title: aliases.title })
.from(aliases)
.where(eq(aliases.entryId, id));
} catch {}
try {
const rows = await db
.select({ link: webrefs.link, languageId: webrefs.languageId, websiteId: websites.id, websiteName: websites.name, websiteLink: websites.link })
.from(webrefs)
.innerJoin(websites, eq(websites.id, webrefs.websiteId))
.where(eq(webrefs.entryId, id));
webrefRows = rows as typeof webrefRows;
} catch {}
try {
const rows = await db
.select({
id: licenses.id,
name: licenses.name,
licensetypeId: licenses.licensetypeId,
licensetypeName: licensetypes.name,
isOfficial: relatedlicenses.isOfficial,
linkWikipedia: licenses.linkWikipedia,
linkSite: licenses.linkSite,
comments: licenses.comments,
})
.from(relatedlicenses)
.innerJoin(licenses, eq(licenses.id, relatedlicenses.licenseId))
.leftJoin(licensetypes, eq(licensetypes.id, licenses.licensetypeId))
.where(eq(relatedlicenses.entryId, id));
licenseRows = rows as typeof licenseRows;
} catch {}
return {
id: base.id,
title: base.title,
isXrated: base.isXrated,
machinetype: { id: (base.machinetypeId) ?? null, name: (base.machinetypeName) ?? null },
language: { id: (base.languageId) ?? null, name: (base.languageName) ?? null },
genre: { id: (base.genreId) ?? null, name: (base.genreName) ?? null },
authors: authorRows,
publishers: publisherRows,
licenses: licenseRows.map((l) => ({
id: Number(l.id),
name: l.name,
type: { id: l.licensetypeId, name: l.licensetypeName ?? null },
isOfficial: !!l.isOfficial,
linkWikipedia: l.linkWikipedia ?? null,
linkSite: l.linkSite ?? null,
comments: l.comments ?? null,
})),
maxPlayers: (base.maxPlayers) ?? undefined,
availabletypeId: (base.availabletypeId) ?? undefined,
withoutLoadScreen: (base.withoutLoadScreen) ?? undefined,
withoutInlay: (base.withoutInlay) ?? undefined,
issueId: (base.issueId) ?? 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,
downloadsFlat: downloadFlatRows.map((d) => ({
id: Number(d.id),
link: d.link,
size: d.size != null ? Number(d.size) : null,
md5: d.md5 ?? null,
comments: d.comments ?? null,
isDemo: !!d.isDemo,
type: { id: Number(d.filetypeId), name: d.filetypeName },
language: { id: (d.dlLangId) ?? null, name: (d.dlLangName) ?? null },
machinetype: { id: d.dlMachineId != null ? Number(d.dlMachineId) : null, name: (d.dlMachineName) ?? null },
scheme: { id: (d.schemeId) ?? null, name: (d.schemeName) ?? null },
source: { id: (d.sourceId) ?? null, name: (d.sourceName) ?? null },
case: { id: (d.caseId) ?? null, name: (d.caseName) ?? null },
year: d.year != null ? Number(d.year) : null,
releaseSeq: Number(d.releaseSeq),
})),
aliases: aliasRows.map((a) => ({ releaseSeq: Number(a.releaseSeq), languageId: a.languageId, title: a.title })),
webrefs: webrefRows.map((w) => ({ link: w.link, languageId: w.languageId, website: { id: Number(w.websiteId), name: w.websiteName, link: w.websiteLink } })),
};
}
// ----- Labels -----
export interface LabelDetail extends LabelSummary {
permissions: {
website: { id: number; name: string; link?: string | null };
type: { id: string; name: string | null };
text: string | null;
}[];
licenses: {
id: number;
name: string;
type: { id: string; name: string | null };
linkWikipedia?: string | null;
linkSite?: string | null;
comments?: string | null;
}[];
}
export interface LabelSearchParams {
q?: string;
page?: number;
pageSize?: number;
}
export async function searchLabels(params: LabelSearchParams): Promise<PagedResult<LabelSummary>> {
const q = (params.q ?? "").trim().toLowerCase().replace(/[^a-z0-9]+/g, "");
const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100));
const page = Math.max(1, params.page ?? 1);
const offset = (page - 1) * pageSize;
if (!q) {
const [items, countRows] = await Promise.all([
db.select().from(labels).orderBy(labels.name).limit(pageSize).offset(offset),
db
.select({ total: sql<number>`count(*)` })
.from(labels) as unknown as Promise<{ total: number }[]>,
]);
const total = Number(countRows?.[0]?.total ?? 0);
return { items: items, page, pageSize, total };
}
// Using helper search_by_names for efficiency via subselect to avoid raw identifier typing
const pattern = `%${q}%`;
const countRows = await db
.select({ total: sql<number>`count(*)` })
.from(labels)
.where(sql`${labels.id} in (select distinct label_id from search_by_names where label_name like ${pattern})`);
const total = Number(countRows[0]?.total ?? 0);
const items = await db
.select({ id: labels.id, name: labels.name, labeltypeId: labels.labeltypeId })
.from(labels)
.where(sql`${labels.id} in (select distinct label_id from search_by_names where label_name like ${pattern})`)
.orderBy(labels.name)
.limit(pageSize)
.offset(offset);
return { items: items, page, pageSize, total };
}
export async function getLabelById(id: number): Promise<LabelDetail | null> {
const rows = await db.select().from(labels).where(eq(labels.id, id)).limit(1);
const base = rows[0];
if (!base) return null;
let permissionRows: {
websiteId: number | string;
websiteName: string;
websiteLink: string | null;
permissiontypeId: string;
permissiontypeName: string | null;
text: string | null;
}[] = [];
let licenseRows: {
id: number | string;
name: string;
licensetypeId: string;
licensetypeName: string | null;
linkWikipedia: string | null;
linkSite: string | null;
comments: string | null;
}[] = [];
try {
const rowsPerm = await db
.select({
websiteId: websites.id,
websiteName: websites.name,
websiteLink: websites.link,
permissiontypeId: permissiontypes.id,
permissiontypeName: permissiontypes.name,
text: permissions.text,
})
.from(permissions)
.innerJoin(websites, eq(websites.id, permissions.websiteId))
.leftJoin(permissiontypes, eq(permissiontypes.id, permissions.permissiontypeId))
.where(eq(permissions.labelId, id));
permissionRows = rowsPerm as typeof permissionRows;
} catch {}
try {
const rowsLic = await db
.select({
id: licenses.id,
name: licenses.name,
licensetypeId: licenses.licensetypeId,
licensetypeName: licensetypes.name,
linkWikipedia: licenses.linkWikipedia,
linkSite: licenses.linkSite,
comments: licenses.comments,
})
.from(licensors)
.innerJoin(licenses, eq(licenses.id, licensors.licenseId))
.leftJoin(licensetypes, eq(licensetypes.id, licenses.licensetypeId))
.where(eq(licensors.labelId, id));
licenseRows = rowsLic as typeof licenseRows;
} catch {}
return {
id: base.id,
name: base.name,
labeltypeId: base.labeltypeId,
permissions: permissionRows.map((p) => ({
website: { id: Number(p.websiteId), name: p.websiteName, link: p.websiteLink ?? null },
type: { id: p.permissiontypeId, name: p.permissiontypeName ?? null },
text: p.text ?? null,
})),
licenses: licenseRows.map((l) => ({
id: Number(l.id),
name: l.name,
type: { id: l.licensetypeId, name: l.licensetypeName ?? null },
linkWikipedia: l.linkWikipedia ?? null,
linkSite: l.linkSite ?? null,
comments: l.comments ?? null,
})),
};
}
export interface LabelContribsParams {
page?: number;
pageSize?: number;
q?: string; // optional title filter
}
export async function getLabelAuthoredEntries(labelId: number, params: LabelContribsParams): Promise<PagedResult<SearchResultItem>> {
const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100));
const page = Math.max(1, params.page ?? 1);
const offset = (page - 1) * pageSize;
const hasQ = !!(params.q && params.q.trim());
if (!hasQ) {
const countRows = await db
.select({ total: sql<number>`count(distinct ${authors.entryId})` })
.from(authors)
.where(eq(authors.labelId, labelId));
const total = Number(countRows[0]?.total ?? 0);
const items = await db
.select({
id: entries.id,
title: entries.title,
isXrated: entries.isXrated,
machinetypeId: entries.machinetypeId,
machinetypeName: machinetypes.name,
languageId: entries.languageId,
languageName: languages.name,
})
.from(authors)
.innerJoin(entries, eq(entries.id, authors.entryId))
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId))
.leftJoin(languages, eq(languages.id, entries.languageId))
.where(eq(authors.labelId, labelId))
.groupBy(entries.id)
.orderBy(entries.title)
.limit(pageSize)
.offset(offset);
return { items: items, page, pageSize, total };
}
const pattern = `%${params.q!.toLowerCase().replace(/[^a-z0-9]+/g, "")}%`;
const countRows = await db
.select({ total: sql<number>`count(distinct ${entries.id})` })
.from(authors)
.innerJoin(entries, eq(entries.id, authors.entryId))
.where(and(eq(authors.labelId, labelId), sql`${entries.id} in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`));
const total = Number((countRows)[0]?.total ?? 0);
const items = await db
.select({
id: entries.id,
title: entries.title,
isXrated: entries.isXrated,
machinetypeId: entries.machinetypeId,
machinetypeName: machinetypes.name,
languageId: entries.languageId,
languageName: languages.name,
})
.from(authors)
.innerJoin(entries, eq(entries.id, authors.entryId))
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId))
.leftJoin(languages, eq(languages.id, entries.languageId))
.where(and(eq(authors.labelId, labelId), sql`${entries.id} in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`))
.groupBy(entries.id)
.orderBy(entries.title)
.limit(pageSize)
.offset(offset);
return { items: items, page, pageSize, total };
}
export async function getLabelPublishedEntries(labelId: number, params: LabelContribsParams): Promise<PagedResult<SearchResultItem>> {
const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100));
const page = Math.max(1, params.page ?? 1);
const offset = (page - 1) * pageSize;
const hasQ = !!(params.q && params.q.trim());
if (!hasQ) {
const countRows = await db
.select({ total: sql<number>`count(distinct ${publishers.entryId})` })
.from(publishers)
.where(eq(publishers.labelId, labelId));
const total = Number(countRows[0]?.total ?? 0);
const items = await db
.select({
id: entries.id,
title: entries.title,
isXrated: entries.isXrated,
machinetypeId: entries.machinetypeId,
machinetypeName: machinetypes.name,
languageId: entries.languageId,
languageName: languages.name,
})
.from(publishers)
.innerJoin(entries, eq(entries.id, publishers.entryId))
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId))
.leftJoin(languages, eq(languages.id, entries.languageId))
.where(eq(publishers.labelId, labelId))
.groupBy(entries.id)
.orderBy(entries.title)
.limit(pageSize)
.offset(offset);
return { items: items, page, pageSize, total };
}
const pattern = `%${params.q!.toLowerCase().replace(/[^a-z0-9]+/g, "")}%`;
const countRows = await db
.select({ total: sql<number>`count(distinct ${entries.id})` })
.from(publishers)
.innerJoin(entries, eq(entries.id, publishers.entryId))
.where(and(eq(publishers.labelId, labelId), sql`${entries.id} in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`));
const total = Number((countRows)[0]?.total ?? 0);
const items = await db
.select({
id: entries.id,
title: entries.title,
isXrated: entries.isXrated,
machinetypeId: entries.machinetypeId,
machinetypeName: machinetypes.name,
languageId: entries.languageId,
languageName: languages.name,
})
.from(publishers)
.innerJoin(entries, eq(entries.id, publishers.entryId))
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId))
.leftJoin(languages, eq(languages.id, entries.languageId))
.where(and(eq(publishers.labelId, labelId), sql`${entries.id} in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`))
.groupBy(entries.id)
.orderBy(entries.title)
.limit(pageSize)
.offset(offset);
return { items: items, page, pageSize, total };
}
// ----- Lookups lists and category browsing -----
export const listGenres = cache(async () => db.select().from(genretypes).orderBy(genretypes.name));
export const listLanguages = cache(async () => db.select().from(languages).orderBy(languages.name));
export const listMachinetypes = cache(async () => db.select().from(machinetypes).orderBy(machinetypes.name));
// Note: ZXDB structure in this project does not include a `releasetypes` table.
// Do not attempt to query it here.
// Search with pagination for lookups
export interface SimpleSearchParams {
q?: string;
page?: number;
pageSize?: number;
}
export async function searchLanguages(params: SimpleSearchParams) {
const q = (params.q ?? "").trim();
const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100));
const page = Math.max(1, params.page ?? 1);
const offset = (page - 1) * pageSize;
if (!q) {
const [items, countRows] = await Promise.all([
db.select().from(languages).orderBy(languages.name).limit(pageSize).offset(offset),
db.select({ total: sql<number>`count(*)` }).from(languages),
]);
const total = Number(countRows?.[0]?.total ?? 0);
return { items, page, pageSize, total };
}
const pattern = `%${q}%`;
const [items, countRows] = await Promise.all([
db
.select()
.from(languages)
.where(like(languages.name, pattern))
.orderBy(languages.name)
.limit(pageSize)
.offset(offset),
db.select({ total: sql<number>`count(*)` }).from(languages).where(like(languages.name, pattern)),
]);
const total = Number(countRows?.[0]?.total ?? 0);
return { items, page, pageSize, total };
}
export async function searchGenres(params: SimpleSearchParams) {
const q = (params.q ?? "").trim();
const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100));
const page = Math.max(1, params.page ?? 1);
const offset = (page - 1) * pageSize;
if (!q) {
const [items, countRows] = await Promise.all([
db.select().from(genretypes).orderBy(genretypes.name).limit(pageSize).offset(offset),
db.select({ total: sql<number>`count(*)` }).from(genretypes),
]);
const total = Number(countRows?.[0]?.total ?? 0);
return { items, page, pageSize, total };
}
const pattern = `%${q}%`;
const [items, countRows] = await Promise.all([
db
.select()
.from(genretypes)
.where(like(genretypes.name, pattern))
.orderBy(genretypes.name)
.limit(pageSize)
.offset(offset),
db.select({ total: sql<number>`count(*)` }).from(genretypes).where(like(genretypes.name, pattern)),
]);
const total = Number(countRows?.[0]?.total ?? 0);
return { items, page, pageSize, total };
}
export async function searchMachinetypes(params: SimpleSearchParams) {
const q = (params.q ?? "").trim();
const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100));
const page = Math.max(1, params.page ?? 1);
const offset = (page - 1) * pageSize;
if (!q) {
const [items, countRows] = await Promise.all([
db.select().from(machinetypes).orderBy(machinetypes.name).limit(pageSize).offset(offset),
db.select({ total: sql<number>`count(*)` }).from(machinetypes),
]);
const total = Number(countRows?.[0]?.total ?? 0);
return { items, page, pageSize, total };
}
const pattern = `%${q}%`;
const [items, countRows] = await Promise.all([
db
.select()
.from(machinetypes)
.where(like(machinetypes.name, pattern))
.orderBy(machinetypes.name)
.limit(pageSize)
.offset(offset),
db.select({ total: sql<number>`count(*)` }).from(machinetypes).where(like(machinetypes.name, pattern)),
]);
const total = Number((countRows as { total: number }[])[0]?.total ?? 0);
return { items, page, pageSize, total };
}
export async function entriesByGenre(
genreId: number,
page: number,
pageSize: number,
q?: string
): Promise<PagedResult<SearchResultItem>> {
const offset = (page - 1) * pageSize;
const hasQ = !!(q && q.trim());
if (!hasQ) {
const countRows = await db
.select({ total: sql<number>`count(*)` })
.from(entries)
.where(eq(entries.genretypeId, genreId));
const items = await db
.select({
id: entries.id,
title: entries.title,
isXrated: entries.isXrated,
machinetypeId: entries.machinetypeId,
machinetypeName: machinetypes.name,
languageId: entries.languageId,
languageName: languages.name,
})
.from(entries)
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId))
.leftJoin(languages, eq(languages.id, entries.languageId))
.where(eq(entries.genretypeId, genreId))
.orderBy(entries.title)
.limit(pageSize)
.offset(offset);
return { items, page, pageSize, total: Number(countRows?.[0]?.total ?? 0) };
}
const pattern = `%${q!.toLowerCase().replace(/[^a-z0-9]+/g, "")}%`;
const countRows = await db
.select({ total: sql<number>`count(distinct ${entries.id})` })
.from(entries)
.where(and(eq(entries.genretypeId, genreId), sql`${entries.id} in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`));
const total = Number(countRows[0]?.total ?? 0);
const items = await db
.select({
id: entries.id,
title: entries.title,
isXrated: entries.isXrated,
machinetypeId: entries.machinetypeId,
machinetypeName: machinetypes.name,
languageId: entries.languageId,
languageName: languages.name,
})
.from(entries)
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId))
.leftJoin(languages, eq(languages.id, entries.languageId))
.where(and(eq(entries.genretypeId, genreId), sql`${entries.id} in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`))
.groupBy(entries.id)
.orderBy(entries.title)
.limit(pageSize)
.offset(offset);
return { items, page, pageSize, total };
}
export async function entriesByLanguage(
langId: string,
page: number,
pageSize: number,
q?: string
): Promise<PagedResult<SearchResultItem>> {
const offset = (page - 1) * pageSize;
const hasQ = !!(q && q.trim());
if (!hasQ) {
const countRows = await db
.select({ total: sql<number>`count(*)` })
.from(entries)
.where(eq(entries.languageId, langId));
const items = await db
.select({
id: entries.id,
title: entries.title,
isXrated: entries.isXrated,
machinetypeId: entries.machinetypeId,
machinetypeName: machinetypes.name,
languageId: entries.languageId,
languageName: languages.name,
})
.from(entries)
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId))
.leftJoin(languages, eq(languages.id, entries.languageId))
.where(eq(entries.languageId, langId))
.orderBy(entries.title)
.limit(pageSize)
.offset(offset);
return { items, page, pageSize, total: Number(countRows?.[0]?.total ?? 0) };
}
const pattern = `%${q!.toLowerCase().replace(/[^a-z0-9]+/g, "")}%`;
const countRows = await db
.select({ total: sql<number>`count(distinct ${entries.id})` })
.from(entries)
.where(and(eq(entries.languageId, langId), sql`${entries.id} in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`));
const total = Number(countRows[0]?.total ?? 0);
const items = await db
.select({
id: entries.id,
title: entries.title,
isXrated: entries.isXrated,
machinetypeId: entries.machinetypeId,
machinetypeName: machinetypes.name,
languageId: entries.languageId,
languageName: languages.name,
})
.from(entries)
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId))
.leftJoin(languages, eq(languages.id, entries.languageId))
.where(and(eq(entries.languageId, langId), sql`${entries.id} in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`))
.groupBy(entries.id)
.orderBy(entries.title)
.limit(pageSize)
.offset(offset);
return { items, page, pageSize, total };
}
export async function entriesByMachinetype(
mtId: number,
page: number,
pageSize: number,
q?: string
): Promise<PagedResult<SearchResultItem>> {
const offset = (page - 1) * pageSize;
const hasQ = !!(q && q.trim());
if (!hasQ) {
const countRows = await db
.select({ total: sql<number>`count(*)` })
.from(entries)
.where(eq(entries.machinetypeId, mtId));
const items = await db
.select({
id: entries.id,
title: entries.title,
isXrated: entries.isXrated,
machinetypeId: entries.machinetypeId,
machinetypeName: machinetypes.name,
languageId: entries.languageId,
languageName: languages.name,
})
.from(entries)
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId))
.leftJoin(languages, eq(languages.id, entries.languageId))
.where(eq(entries.machinetypeId, mtId))
.orderBy(entries.title)
.limit(pageSize)
.offset(offset);
return { items, page, pageSize, total: Number(countRows?.[0]?.total ?? 0) };
}
const pattern = `%${q!.toLowerCase().replace(/[^a-z0-9]+/g, "")}%`;
const countRows = await db
.select({ total: sql<number>`count(distinct ${entries.id})` })
.from(entries)
.where(and(eq(entries.machinetypeId, mtId), sql`${entries.id} in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`));
const total = Number(countRows[0]?.total ?? 0);
const items = await db
.select({
id: entries.id,
title: entries.title,
isXrated: entries.isXrated,
machinetypeId: entries.machinetypeId,
machinetypeName: machinetypes.name,
languageId: entries.languageId,
languageName: languages.name,
})
.from(entries)
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId))
.leftJoin(languages, eq(languages.id, entries.languageId))
.where(and(eq(entries.machinetypeId, mtId), sql`${entries.id} in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`))
.groupBy(entries.id)
.orderBy(entries.title)
.limit(pageSize)
.offset(offset);
return { items, page, pageSize, total };
}
// ----- Facets for search -----
export async function getEntryFacets(params: SearchParams): Promise<EntryFacets> {
const q = (params.q ?? "").trim();
const pattern = q ? `%${q.toLowerCase().replace(/[^a-z0-9]+/g, "")}%` : null;
const scope: EntrySearchScope = params.scope ?? "title";
// Build base WHERE SQL snippet considering q + filters
const whereParts: Array<ReturnType<typeof sql>> = [];
if (pattern) {
if (scope !== "title") {
try {
const union = buildEntrySearchUnion(pattern, scope);
whereParts.push(sql`id in (select entry_id from (${union}) as matches)`);
} catch {
whereParts.push(sql`id in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`);
}
} else {
whereParts.push(sql`id in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`);
}
}
if (params.genreId) whereParts.push(sql`${entries.genretypeId} = ${params.genreId}`);
if (params.languageId) whereParts.push(sql`${entries.languageId} = ${params.languageId}`);
if (params.machinetypeId) whereParts.push(sql`${entries.machinetypeId} = ${params.machinetypeId}`);
const whereSql = whereParts.length ? sql.join([sql`where `, sql.join(whereParts, sql` and `)], sql``) : sql``;
// Genres facet
const genresRows = await db.execute(sql`
select e.genretype_id as id, gt.text as name, count(*) as count
from ${entries} as e
left join ${genretypes} as gt on gt.id = e.genretype_id
${whereSql}
group by e.genretype_id, gt.text
order by count desc, name asc
`);
// Languages facet
const langRows = await db.execute(sql`
select e.language_id as id, l.text as name, count(*) as count
from ${entries} as e
left join ${languages} as l on l.id = e.language_id
${whereSql}
group by e.language_id, l.text
order by count desc, name asc
`);
// Machinetypes facet
const mtRows = await db.execute(sql`
select e.machinetype_id as id, m.text as name, count(*) as count
from ${entries} as e
left join ${machinetypes} as m on m.id = e.machinetype_id
${whereSql}
group by e.machinetype_id, m.text
order by count desc, name asc
`);
type FacetRow = { id: number | string | null; name: string | null; count: number | string };
return {
genres: (genresRows as unknown as FacetRow[])
.map((r) => ({ id: Number(r.id), name: r.name ?? "(none)", count: Number(r.count) }))
.filter((r) => !!r.id),
languages: (langRows as unknown as FacetRow[])
.map((r) => ({ id: String(r.id), name: r.name ?? "(none)", count: Number(r.count) }))
.filter((r) => !!r.id),
machinetypes: (mtRows as unknown as FacetRow[])
.map((r) => ({ id: Number(r.id), name: r.name ?? "(none)", count: Number(r.count) }))
.filter((r) => !!r.id),
};
}
// ----- Releases search (browser) -----
export interface ReleaseSearchParams {
q?: string; // match entry title via helper search
page?: number;
pageSize?: number;
year?: number;
sort?: "year_desc" | "year_asc" | "title" | "entry_id_desc";
// Optional download-based filters (matched via EXISTS on downloads)
dLanguageId?: string; // downloads.language_id
dMachinetypeId?: number; // downloads.machinetype_id
filetypeId?: number; // downloads.filetype_id
schemetypeId?: string; // downloads.schemetype_id
sourcetypeId?: string; // downloads.sourcetype_id
casetypeId?: string; // downloads.casetype_id
isDemo?: boolean; // downloads.is_demo
}
export interface ReleaseListItem {
entryId: number;
releaseSeq: number;
entryTitle: string;
year: number | null;
magrefCount: number;
}
export async function searchReleases(params: ReleaseSearchParams): Promise<PagedResult<ReleaseListItem>> {
const q = (params.q ?? "").trim();
const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100));
const page = Math.max(1, params.page ?? 1);
const offset = (page - 1) * pageSize;
// Build WHERE conditions in Drizzle QB
const wherePartsQB: Array<ReturnType<typeof sql>> = [];
if (q) {
const pattern = `%${q.toLowerCase().replace(/[^a-z0-9]+/g, "")}%`;
wherePartsQB.push(sql`${releases.entryId} in (select ${searchByTitles.entryId} from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`);
}
if (params.year != null) {
wherePartsQB.push(eq(releases.releaseYear, params.year));
}
// Optional filters via downloads table: use EXISTS for performance and correctness
// IMPORTANT: when hand-writing SQL with an aliased table, we must render
// "from downloads as d" explicitly; using only the alias identifier ("d")
// would produce "from `d`" which MySQL interprets as a literal table.
const dlConds: Array<ReturnType<typeof sql>> = [];
if (params.dLanguageId) dlConds.push(sql`d.language_id = ${params.dLanguageId}`);
if (params.dMachinetypeId != null) dlConds.push(sql`d.machinetype_id = ${params.dMachinetypeId}`);
if (params.filetypeId != null) dlConds.push(sql`d.filetype_id = ${params.filetypeId}`);
if (params.schemetypeId) dlConds.push(sql`d.schemetype_id = ${params.schemetypeId}`);
if (params.sourcetypeId) dlConds.push(sql`d.sourcetype_id = ${params.sourcetypeId}`);
if (params.casetypeId) dlConds.push(sql`d.casetype_id = ${params.casetypeId}`);
if (params.isDemo != null) dlConds.push(sql`d.is_demo = ${params.isDemo ? 1 : 0}`);
if (dlConds.length) {
const baseConds = [
sql`d.entry_id = ${releases.entryId}`,
sql`d.release_seq = ${releases.releaseSeq}`,
...dlConds,
];
wherePartsQB.push(sql`exists (select 1 from ${downloads} as d where ${sql.join(baseConds, sql` and `)})`);
}
const whereExpr = wherePartsQB.length ? and(...wherePartsQB) : undefined;
// Count total
const countRows = await db
.select({ total: sql<number>`count(*)` })
.from(releases)
.where(whereExpr ?? sql`true`);
const total = Number(countRows?.[0]?.total ?? 0);
// Rows via Drizzle QB to avoid tuple/field leakage
let orderBy1;
let orderBy2;
let orderBy3;
switch (params.sort) {
case "year_asc":
orderBy1 = asc(releases.releaseYear);
orderBy2 = asc(releases.entryId);
orderBy3 = asc(releases.releaseSeq);
break;
case "title":
orderBy1 = asc(entries.title);
orderBy2 = desc(releases.releaseYear);
orderBy3 = asc(releases.releaseSeq);
break;
case "entry_id_desc":
orderBy1 = desc(releases.entryId);
orderBy2 = desc(releases.releaseSeq);
break;
case "year_desc":
default:
orderBy1 = desc(releases.releaseYear);
orderBy2 = desc(releases.entryId);
orderBy3 = desc(releases.releaseSeq);
break;
}
const rowsQB = await db
.select({
entryId: releases.entryId,
releaseSeq: releases.releaseSeq,
entryTitle: entries.title,
year: releases.releaseYear,
})
.from(releases)
.leftJoin(entries, eq(entries.id, releases.entryId))
.where(whereExpr ?? sql`true`)
.orderBy(orderBy1!, ...(orderBy2 ? [orderBy2] : []), ...(orderBy3 ? [orderBy3] : []))
.limit(pageSize)
.offset(offset);
const entryIds = Array.from(new Set(rowsQB.map((r) => Number(r.entryId)).filter((id) => Number.isFinite(id))));
const magrefCounts = new Map<number, number>();
if (entryIds.length > 0) {
try {
const values = entryIds.map((id) => sql`${id}`);
const rows = await db.execute(sql`
select ${searchByMagrefs.entryId} as entryId, count(*) as count
from ${searchByMagrefs}
where ${searchByMagrefs.entryId} in (${sql.join(values, sql`, `)})
group by ${searchByMagrefs.entryId}
`);
type CountRow = { entryId: number | string; count: number | string };
for (const row of rows as unknown as CountRow[]) {
magrefCounts.set(Number(row.entryId), Number(row.count));
}
} catch {
// Helper table might be missing; default to 0 counts.
}
}
// Ensure plain primitives
const items: ReleaseListItem[] = rowsQB.map((r) => ({
entryId: Number(r.entryId),
releaseSeq: Number(r.releaseSeq),
entryTitle: r.entryTitle ?? "",
year: r.year != null ? Number(r.year) : null,
magrefCount: magrefCounts.get(Number(r.entryId)) ?? 0,
}));
return { items, page, pageSize, total };
}
export interface ReleaseDetail {
entry: {
id: number;
title: string;
issueId: number | null;
};
entryReleases: Array<{
releaseSeq: number;
year: number | null;
}>;
release: {
entryId: number;
releaseSeq: number;
year: number | null;
month: number | null;
day: number | null;
currency: { id: string | null; name: string | null; symbol: string | null; prefix: number | null };
prices: {
release: number | null;
budget: number | null;
microdrive: number | null;
disk: number | null;
cartridge: number | null;
};
book: { isbn: string | null; pages: number | null };
};
downloads: Array<{
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;
}>;
scraps: Array<{
id: number;
link: string | null;
size: number | null;
comments: string | null;
rationale: string;
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;
}>;
files: Array<{
id: number;
link: string;
size: number | null;
md5: string | null;
comments: string | null;
type: { id: number; name: string };
}>;
magazineRefs: Array<{
id: number;
issueId: number;
magazineId: number | null;
magazineName: string | null;
referencetypeId: number;
referencetypeName: string | null;
page: number;
isOriginal: number;
scoreGroup: string;
issue: {
dateYear: number | null;
dateMonth: number | null;
dateDay: number | null;
volume: number | null;
number: number | null;
special: string | null;
supplement: string | null;
};
}>;
}
export async function getReleaseDetail(entryId: number, releaseSeq: number): Promise<ReleaseDetail | null> {
const rows = await db
.select({
entryId: releases.entryId,
releaseSeq: releases.releaseSeq,
year: releases.releaseYear,
month: releases.releaseMonth,
day: releases.releaseDay,
currencyId: releases.currencyId,
currencyName: currencies.name,
currencySymbol: currencies.symbol,
currencyPrefix: currencies.prefix,
releasePrice: releases.releasePrice,
budgetPrice: releases.budgetPrice,
microdrivePrice: releases.microdrivePrice,
diskPrice: releases.diskPrice,
cartridgePrice: releases.cartridgePrice,
bookIsbn: releases.bookIsbn,
bookPages: releases.bookPages,
entryTitle: entries.title,
issueId: entries.issueId,
})
.from(releases)
.leftJoin(entries, eq(entries.id, releases.entryId))
.leftJoin(currencies, eq(currencies.id, releases.currencyId))
.where(and(eq(releases.entryId, entryId), eq(releases.releaseSeq, releaseSeq)))
.limit(1);
const base = rows[0];
if (!base) return null;
const entryReleaseRows = await db
.select({
releaseSeq: releases.releaseSeq,
year: releases.releaseYear,
})
.from(releases)
.where(eq(releases.entryId, entryId))
.orderBy(asc(releases.releaseSeq));
type DownloadRow = {
id: number | string;
link: string;
size: number | string | null;
md5: string | null;
comments: string | null;
isDemo: number | boolean | null;
filetypeId: number | string;
filetypeName: string;
dlLangId: string | null;
dlLangName: string | null;
dlMachineId: number | string | null;
dlMachineName: string | null;
schemeId: string | null;
schemeName: string | null;
sourceId: string | null;
sourceName: string | null;
caseId: string | null;
caseName: string | null;
year: number | string | null;
};
type ScrapRow = DownloadRow & { rationale: string };
const downloadRows = await db
.select({
id: downloads.id,
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, downloads.filetypeId))
.leftJoin(languages, eq(languages.id, downloads.languageId))
.leftJoin(machinetypes, eq(machinetypes.id, downloads.machinetypeId))
.leftJoin(schemetypes, eq(schemetypes.id, downloads.schemetypeId))
.leftJoin(sourcetypes, eq(sourcetypes.id, downloads.sourcetypeId))
.leftJoin(casetypes, eq(casetypes.id, downloads.casetypeId))
.where(and(eq(downloads.entryId, entryId), eq(downloads.releaseSeq, releaseSeq)));
const scrapRows = await db
.select({
id: scraps.id,
link: scraps.fileLink,
size: scraps.fileSize,
comments: scraps.comments,
rationale: scraps.rationale,
isDemo: scraps.isDemo,
filetypeId: filetypes.id,
filetypeName: filetypes.name,
dlLangId: scraps.languageId,
dlLangName: languages.name,
dlMachineId: scraps.machinetypeId,
dlMachineName: machinetypes.name,
schemeId: schemetypes.id,
schemeName: schemetypes.name,
sourceId: sourcetypes.id,
sourceName: sourcetypes.name,
caseId: casetypes.id,
caseName: casetypes.name,
year: scraps.releaseYear,
})
.from(scraps)
.innerJoin(filetypes, eq(filetypes.id, scraps.filetypeId))
.leftJoin(languages, eq(languages.id, scraps.languageId))
.leftJoin(machinetypes, eq(machinetypes.id, scraps.machinetypeId))
.leftJoin(schemetypes, eq(schemetypes.id, scraps.schemetypeId))
.leftJoin(sourcetypes, eq(sourcetypes.id, scraps.sourcetypeId))
.leftJoin(casetypes, eq(casetypes.id, scraps.casetypeId))
.where(and(eq(scraps.entryId, entryId), eq(scraps.releaseSeq, releaseSeq)));
const fileRows = base.issueId != null ? 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))
.where(eq(files.issueId, base.issueId)) : [];
const magazineRefs = await db
.select({
id: magrefs.id,
issueId: magrefs.issueId,
magazineId: magazines.id,
magazineName: magazines.name,
referencetypeId: magrefs.referencetypeId,
referencetypeName: referencetypes.name,
page: magrefs.page,
isOriginal: magrefs.isOriginal,
scoreGroup: magrefs.scoreGroup,
issueDateYear: issues.dateYear,
issueDateMonth: issues.dateMonth,
issueDateDay: issues.dateDay,
issueVolume: issues.volume,
issueNumber: issues.number,
issueSpecial: issues.special,
issueSupplement: issues.supplement,
})
.from(searchByMagrefs)
.innerJoin(magrefs, eq(magrefs.id, searchByMagrefs.magrefId))
.leftJoin(issues, eq(issues.id, magrefs.issueId))
.leftJoin(magazines, eq(magazines.id, issues.magazineId))
.leftJoin(referencetypes, eq(referencetypes.id, magrefs.referencetypeId))
.where(eq(searchByMagrefs.entryId, entryId))
.orderBy(
asc(magazines.name),
asc(issues.dateYear),
asc(issues.dateMonth),
asc(issues.id),
asc(magrefs.page),
asc(magrefs.id),
);
return {
entry: {
id: Number(base.entryId),
title: base.entryTitle ?? "",
issueId: base.issueId ?? null,
},
entryReleases: entryReleaseRows.map((r) => ({
releaseSeq: Number(r.releaseSeq),
year: r.year != null ? Number(r.year) : null,
})),
release: {
entryId: Number(base.entryId),
releaseSeq: Number(base.releaseSeq),
year: base.year != null ? Number(base.year) : null,
month: base.month != null ? Number(base.month) : null,
day: base.day != null ? Number(base.day) : null,
currency: {
id: base.currencyId ?? null,
name: base.currencyName ?? null,
symbol: base.currencySymbol ?? null,
prefix: base.currencyPrefix != null ? Number(base.currencyPrefix) : null,
},
prices: {
release: base.releasePrice != null ? Number(base.releasePrice) : null,
budget: base.budgetPrice != null ? Number(base.budgetPrice) : null,
microdrive: base.microdrivePrice != null ? Number(base.microdrivePrice) : null,
disk: base.diskPrice != null ? Number(base.diskPrice) : null,
cartridge: base.cartridgePrice != null ? Number(base.cartridgePrice) : null,
},
book: {
isbn: base.bookIsbn ?? null,
pages: base.bookPages != null ? Number(base.bookPages) : null,
},
},
downloads: (downloadRows as DownloadRow[]).map((d) => ({
id: Number(d.id),
link: d.link,
size: d.size != null ? Number(d.size) : null,
md5: d.md5 ?? null,
comments: d.comments ?? null,
isDemo: !!d.isDemo,
type: { id: Number(d.filetypeId), name: d.filetypeName },
language: { id: d.dlLangId ?? null, name: d.dlLangName ?? null },
machinetype: { id: d.dlMachineId != null ? Number(d.dlMachineId) : null, name: d.dlMachineName ?? null },
scheme: { id: d.schemeId ?? null, name: d.schemeName ?? null },
source: { id: d.sourceId ?? null, name: d.sourceName ?? null },
case: { id: d.caseId ?? null, name: d.caseName ?? null },
year: d.year != null ? Number(d.year) : null,
})),
scraps: (scrapRows as ScrapRow[]).map((s) => ({
id: Number(s.id),
link: s.link ?? null,
size: s.size != null ? Number(s.size) : null,
comments: s.comments ?? null,
rationale: s.rationale ?? "",
isDemo: !!s.isDemo,
type: { id: Number(s.filetypeId), name: s.filetypeName },
language: { id: s.dlLangId ?? null, name: s.dlLangName ?? null },
machinetype: { id: s.dlMachineId != null ? Number(s.dlMachineId) : null, name: s.dlMachineName ?? null },
scheme: { id: s.schemeId ?? null, name: s.schemeName ?? null },
source: { id: s.sourceId ?? null, name: s.sourceName ?? null },
case: { id: s.caseId ?? null, name: s.caseName ?? null },
year: s.year != null ? Number(s.year) : null,
})),
files: 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 },
})),
magazineRefs: magazineRefs.map((m) => ({
id: m.id,
issueId: Number(m.issueId),
magazineId: m.magazineId != null ? Number(m.magazineId) : null,
magazineName: m.magazineName ?? null,
referencetypeId: Number(m.referencetypeId),
referencetypeName: m.referencetypeName ?? null,
page: Number(m.page),
isOriginal: Number(m.isOriginal),
scoreGroup: m.scoreGroup ?? "",
issue: {
dateYear: m.issueDateYear != null ? Number(m.issueDateYear) : null,
dateMonth: m.issueDateMonth != null ? Number(m.issueDateMonth) : null,
dateDay: m.issueDateDay != null ? Number(m.issueDateDay) : null,
volume: m.issueVolume != null ? Number(m.issueVolume) : null,
number: m.issueNumber != null ? Number(m.issueNumber) : null,
special: m.issueSpecial ?? null,
supplement: m.issueSupplement ?? null,
},
})),
};
}
// ----- Download/lookups simple lists -----
export const listFiletypes = cache(async () => db.select().from(filetypes).orderBy(filetypes.name));
export const listSchemetypes = cache(async () => db.select().from(schemetypes).orderBy(schemetypes.name));
export const listSourcetypes = cache(async () => db.select().from(sourcetypes).orderBy(sourcetypes.name));
export const listCasetypes = cache(async () => db.select().from(casetypes).orderBy(casetypes.name));
// Newly exposed lookups
export const listAvailabletypes = cache(async () => db.select().from(availabletypes).orderBy(availabletypes.name));
export const listCurrencies = cache(async () =>
db
.select({ id: currencies.id, name: currencies.name, symbol: currencies.symbol, prefix: currencies.prefix })
.from(currencies)
.orderBy(currencies.name)
);
export const listRoletypes = cache(async () => db.select().from(roletypes).orderBy(roletypes.name));
export async function listMagazines(params: { q?: string; page?: number; pageSize?: number }): Promise<PagedResult<MagazineListItem>> {
const q = (params.q ?? "").trim();
const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100));
const page = Math.max(1, params.page ?? 1);
const offset = (page - 1) * pageSize;
const whereExpr = q ? like(magazines.name, `%${q}%`) : sql`true`;
const [items, totalRows] = await Promise.all([
db
.select({
id: magazines.id,
// Expose as `title` to UI while DB column is `name`
title: magazines.name,
languageId: magazines.languageId,
issueCount: sql<number>`count(${issues.id})`,
})
.from(magazines)
.leftJoin(issues, eq(issues.magazineId, magazines.id))
.where(whereExpr)
.groupBy(magazines.id)
.orderBy(asc(magazines.name))
.limit(pageSize)
.offset(offset),
db
.select({ cnt: sql<number>`count(*)` })
.from(magazines)
.where(whereExpr),
]);
return {
items,
page,
pageSize,
total: totalRows[0]?.cnt ?? 0,
};
}
export async function getMagazine(id: number): Promise<MagazineDetail | null> {
const rows = await db
.select({
id: magazines.id,
// Alias DB `name` as `title` for UI shape
title: magazines.name,
languageId: magazines.languageId,
linkSite: magazines.linkSite,
linkMask: magazines.linkMask,
archiveMask: magazines.archiveMask,
})
.from(magazines)
.where(eq(magazines.id, id));
if (rows.length === 0) return null;
const mag = rows[0];
const iss = await db
.select({
id: issues.id,
dateYear: issues.dateYear,
dateMonth: issues.dateMonth,
number: issues.number,
volume: issues.volume,
special: issues.special,
supplement: issues.supplement,
linkMask: issues.linkMask,
archiveMask: issues.archiveMask,
})
.from(issues)
.where(eq(issues.magazineId, id))
.orderBy(
asc(issues.dateYear),
asc(issues.dateMonth),
asc(issues.volume),
asc(issues.number),
asc(issues.id),
);
return { ...mag, issues: iss };
}
export async function getIssue(id: number): Promise<IssueDetail | null> {
const rows = await db
.select({
id: issues.id,
magazineId: issues.magazineId,
magazineTitle: magazines.name,
dateYear: issues.dateYear,
dateMonth: issues.dateMonth,
number: issues.number,
volume: issues.volume,
special: issues.special,
supplement: issues.supplement,
linkMask: issues.linkMask,
archiveMask: issues.archiveMask,
})
.from(issues)
.leftJoin(magazines, eq(magazines.id, issues.magazineId))
.where(eq(issues.id, id));
const base = rows[0];
if (!base) return null;
const refs = await db
.select({
id: magrefs.id,
page: magrefs.page,
typeId: magrefs.referencetypeId,
typeName: referencetypes.name,
entryId: magrefs.entryId,
entryTitle: entries.title,
labelId: magrefs.labelId,
labelName: labels.name,
isOriginal: magrefs.isOriginal,
scoreGroup: magrefs.scoreGroup,
})
.from(magrefs)
.leftJoin(referencetypes, eq(referencetypes.id, magrefs.referencetypeId))
.leftJoin(entries, eq(entries.id, magrefs.entryId))
.leftJoin(labels, eq(labels.id, magrefs.labelId))
.where(eq(magrefs.issueId, id))
.orderBy(asc(magrefs.page), asc(magrefs.id));
return {
id: base.id,
magazine: { id: Number(base.magazineId), title: base.magazineTitle ?? "" },
dateYear: base.dateYear,
dateMonth: base.dateMonth,
number: base.number,
volume: base.volume,
special: base.special,
supplement: base.supplement,
linkMask: base.linkMask,
archiveMask: base.archiveMask,
refs: refs.map((r) => ({
id: r.id,
page: r.page,
typeId: Number(r.typeId),
typeName: r.typeName ?? "",
entryId: r.entryId ?? null,
entryTitle: r.entryTitle ?? null,
labelId: r.labelId ?? null,
labelName: r.labelName ?? null,
isOriginal: Number(r.isOriginal),
scoreGroup: r.scoreGroup,
})),
};
}