feat: integrate ZXDB with Drizzle + deep explorer UI; fix Next 15 dynamic params; align ZXDB schema columns
End-to-end ZXDB integration with environment validation, Drizzle ORM MySQL
setup, typed repositories, Zod-validated API endpoints, and a deep, cross‑
linked Explorer UI under `/zxdb`. Also update dynamic route pages to the
Next.js 15 async `params` API and align ZXDB lookup table columns (`text` vs
`name`).
Summary
- Add t3.gg-style Zod environment validation and typed `env` access
- Wire Drizzle ORM to ZXDB (mysql2 pool, singleton) and minimal schemas
- Implement repositories for search, entry details, label browsing, and
category listings (genres, languages, machinetypes)
- Expose a set of Next.js API routes with strict Zod validation
- Build the ZXDB Explorer UI with search, filters, sorting, deep links, and
entity pages (entries, labels, genres, languages, machinetypes)
- Fix Next 15 “sync-dynamic-apis” warning by awaiting dynamic `params`
- Correct ZXDB lookup model columns to use `text` (aliased as `name`)
Details
Env & DB
- example.env: document `ZXDB_URL` with readonly role notes
- src/env.ts: Zod schema validates `ZXDB_URL` as `mysql://…`; fails fast on
invalid env
- src/server/db.ts: create mysql2 pool from `ZXDB_URL`; export Drizzle instance
- drizzle.config.ts: drizzle-kit configuration (schema path, mysql2 driver)
Schema (Drizzle)
- src/server/schema/zxdb.ts:
- entries: id, title, is_xrated, machinetype_id, language_id, genretype_id
- helper tables: search_by_titles, search_by_names, search_by_authors,
search_by_publishers
- relations: authors, publishers
- lookups: labels, languages, machinetypes, genretypes
- map lookup display columns from DB `text` to model property `name`
Repository
- src/server/repo/zxdb.ts:
- searchEntries: title search via helper table with filters (genre, language,
machine), sorting (title, id_desc), and pagination
- getEntryById: join lookups and aggregate authors/publishers
- Label flows: searchLabels (helper table), getLabelById, getLabelAuthoredEntries,
getLabelPublishedEntries
- Category lists: listGenres, listLanguages, listMachinetypes
- Category pages: entriesByGenre, entriesByLanguage, entriesByMachinetype
API (Node runtime, Zod validation)
- GET /api/zxdb/search: search entries with filters and sorting
- GET /api/zxdb/entries/[id]: fetch entry detail
- GET /api/zxdb/labels/search, GET /api/zxdb/labels/[id]: label search and detail
- GET /api/zxdb/genres, /api/zxdb/genres/[id]
- GET /api/zxdb/languages, /api/zxdb/languages/[id]
- GET /api/zxdb/machinetypes, /api/zxdb/machinetypes/[id]
UI (App Router)
- /zxdb: Explorer page with search box, filters (genre, language, machine), sort,
paginated results & links to entries; quick browse links to hubs
- /zxdb/entries/[id]: entry detail client component shows title, badges
(genre/lang/machine), authors and publishers with cross-links
- /zxdb/labels (+ /[id]): search & label detail with "Authored" and "Published"
tabs, paginated lists linking to entries
- /zxdb/genres, /zxdb/languages, /zxdb/machinetypes and their /[id] detail pages
listing paginated entries and deep links
- Navbar: add ZXDB link
Next 15 dynamic routes
- Convert Server Component dynamic pages to await `params` before accessing
properties:
- /zxdb/entries/[id]/page.tsx
- /zxdb/labels/[id]/page.tsx
- /zxdb/genres/[id]/page.tsx
- /zxdb/languages/[id]/page.tsx
- /registers/[hex]/page.tsx (Registers section)
- /api/zxdb/entries/[id]/route.ts: await `ctx.params` before validation
ZXDB schema column alignment
- languages, machinetypes, genretypes tables use `text` for display columns;
models now map to `name` to preserve API/UI contracts and avoid MySQL 1054
errors in joins (e.g., entry detail endpoint).
Notes
- Ensure ZXDB helper tables are created (ZXDB/scripts/ZXDB_help_search.sql)
— required for fast title/name searches and author/publisher lookups.
- Pagination defaults to 20 (max 100). No `select *` used in queries.
- API responses are `cache: no-store` for now; can be tuned later.
Deferred (future work)
- Facet counts in the Explorer sidebar
- Breadcrumbs and additional a11y polish
- Media assets and download links per release
Signed-off-by: Junie@lucy.xalior.com
Signed-off-by: Junie@lucy.xalior.com
This commit is contained in:
@@ -1,11 +1,26 @@
|
||||
import { and, desc, eq, like, sql } from "drizzle-orm";
|
||||
import { db } from "@/server/db";
|
||||
import { entries, searchByTitles } from "@/server/schema/zxdb";
|
||||
import {
|
||||
entries,
|
||||
searchByTitles,
|
||||
labels,
|
||||
authors,
|
||||
publishers,
|
||||
languages,
|
||||
machinetypes,
|
||||
genretypes,
|
||||
} from "@/server/schema/zxdb";
|
||||
|
||||
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";
|
||||
}
|
||||
|
||||
export interface SearchResultItem {
|
||||
@@ -28,12 +43,30 @@ export async function searchEntries(params: SearchParams): Promise<PagedResult<S
|
||||
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");
|
||||
|
||||
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 = [
|
||||
params.genreId ? eq(entries.genretypeId, params.genreId as any) : undefined,
|
||||
params.languageId ? eq(entries.languageId, params.languageId as any) : undefined,
|
||||
params.machinetypeId ? eq(entries.machinetypeId, params.machinetypeId as any) : undefined,
|
||||
].filter(Boolean) as any[];
|
||||
|
||||
const whereExpr = whereClauses.length ? and(...whereClauses) : undefined;
|
||||
|
||||
const [items, [{ total }]] = await Promise.all([
|
||||
db.select().from(entries).orderBy(desc(entries.id)).limit(pageSize).offset(offset),
|
||||
db.execute(sql`select count(*) as total from ${entries}`) as Promise<any>,
|
||||
db
|
||||
.select()
|
||||
.from(entries)
|
||||
.where(whereExpr as any)
|
||||
.orderBy(sort === "id_desc" ? desc(entries.id) : entries.title)
|
||||
.limit(pageSize)
|
||||
.offset(offset),
|
||||
db.execute(
|
||||
sql`select count(*) as total from ${entries} ${whereExpr ? sql`where ${whereExpr}` : sql``}`
|
||||
) as Promise<any>,
|
||||
]);
|
||||
return {
|
||||
items: items as any,
|
||||
@@ -66,9 +99,243 @@ export async function searchEntries(params: SearchParams): Promise<PagedResult<S
|
||||
.innerJoin(entries, eq(entries.id, searchByTitles.entryId))
|
||||
.where(like(searchByTitles.entryTitle, pattern))
|
||||
.groupBy(entries.id)
|
||||
.orderBy(sort === "id_desc" ? desc(entries.id) : entries.title)
|
||||
.limit(pageSize)
|
||||
.offset(offset);
|
||||
|
||||
return { items: items as any, 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[];
|
||||
}
|
||||
|
||||
export async function getEntryById(id: number): Promise<EntryDetail | null> {
|
||||
// Basic entry with lookups
|
||||
const rows = await 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,
|
||||
})
|
||||
.from(entries)
|
||||
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId as any))
|
||||
.leftJoin(languages, eq(languages.id, entries.languageId as any))
|
||||
.leftJoin(genretypes, eq(genretypes.id, entries.genretypeId as any))
|
||||
.where(eq(entries.id, id));
|
||||
|
||||
const base = rows[0];
|
||||
if (!base) return null;
|
||||
|
||||
// Authors
|
||||
const authorRows = await 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);
|
||||
|
||||
// Publishers
|
||||
const publisherRows = await 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);
|
||||
|
||||
return {
|
||||
id: base.id,
|
||||
title: base.title,
|
||||
isXrated: base.isXrated as any,
|
||||
machinetype: { id: (base.machinetypeId as any) ?? null, name: (base.machinetypeName as any) ?? null },
|
||||
language: { id: (base.languageId as any) ?? null, name: (base.languageName as any) ?? null },
|
||||
genre: { id: (base.genreId as any) ?? null, name: (base.genreName as any) ?? null },
|
||||
authors: authorRows as any,
|
||||
publishers: publisherRows as any,
|
||||
};
|
||||
}
|
||||
|
||||
// ----- Labels -----
|
||||
|
||||
export interface LabelDetail extends LabelSummary {}
|
||||
|
||||
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, [{ total }]] = await Promise.all([
|
||||
db.select().from(labels).orderBy(labels.name).limit(pageSize).offset(offset),
|
||||
db.execute(sql`select count(*) as total from ${labels}`) as Promise<any>,
|
||||
]);
|
||||
return { items: items as any, page, pageSize, total: Number(total ?? 0) };
|
||||
}
|
||||
|
||||
// Using helper search_by_names for efficiency
|
||||
const pattern = `%${q}%`;
|
||||
const countRows = await db
|
||||
.select({ total: sql<number>`count(distinct ${sql.identifier("label_id")})` })
|
||||
.from(sql`search_by_names` as any)
|
||||
.where(like(sql.identifier("label_name") as any, pattern));
|
||||
const total = Number(countRows[0]?.total ?? 0);
|
||||
|
||||
const items = await db
|
||||
.select({ id: labels.id, name: labels.name, labeltypeId: labels.labeltypeId })
|
||||
.from(sql`search_by_names` as any)
|
||||
.innerJoin(labels, eq(labels.id as any, sql.identifier("label_id") as any))
|
||||
.where(like(sql.identifier("label_name") as any, pattern))
|
||||
.groupBy(labels.id)
|
||||
.orderBy(labels.name)
|
||||
.limit(pageSize)
|
||||
.offset(offset);
|
||||
|
||||
return { items: items as any, 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);
|
||||
return (rows[0] as any) ?? null;
|
||||
}
|
||||
|
||||
export interface LabelContribsParams {
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
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 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,
|
||||
languageId: entries.languageId,
|
||||
})
|
||||
.from(authors)
|
||||
.innerJoin(entries, eq(entries.id, authors.entryId))
|
||||
.where(eq(authors.labelId, labelId))
|
||||
.groupBy(entries.id)
|
||||
.orderBy(entries.title)
|
||||
.limit(pageSize)
|
||||
.offset(offset);
|
||||
|
||||
return { items: items as any, 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 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,
|
||||
languageId: entries.languageId,
|
||||
})
|
||||
.from(publishers)
|
||||
.innerJoin(entries, eq(entries.id, publishers.entryId))
|
||||
.where(eq(publishers.labelId, labelId))
|
||||
.groupBy(entries.id)
|
||||
.orderBy(entries.title)
|
||||
.limit(pageSize)
|
||||
.offset(offset);
|
||||
|
||||
return { items: items as any, page, pageSize, total };
|
||||
}
|
||||
|
||||
// ----- Lookups lists and category browsing -----
|
||||
|
||||
export async function listGenres() {
|
||||
return db.select().from(genretypes).orderBy(genretypes.name);
|
||||
}
|
||||
export async function listLanguages() {
|
||||
return db.select().from(languages).orderBy(languages.name);
|
||||
}
|
||||
export async function listMachinetypes() {
|
||||
return db.select().from(machinetypes).orderBy(machinetypes.name);
|
||||
}
|
||||
|
||||
export async function entriesByGenre(genreId: number, page: number, pageSize: number): Promise<PagedResult<SearchResultItem>> {
|
||||
const offset = (page - 1) * pageSize;
|
||||
const [{ total }]: any = await db.execute(sql`select count(*) as total from ${entries} where ${entries.genretypeId} = ${genreId}`);
|
||||
const items = await db
|
||||
.select({ id: entries.id, title: entries.title, isXrated: entries.isXrated, machinetypeId: entries.machinetypeId, languageId: entries.languageId })
|
||||
.from(entries)
|
||||
.where(eq(entries.genretypeId, genreId as any))
|
||||
.orderBy(entries.title)
|
||||
.limit(pageSize)
|
||||
.offset(offset);
|
||||
return { items: items as any, page, pageSize, total: Number(total ?? 0) };
|
||||
}
|
||||
|
||||
export async function entriesByLanguage(langId: string, page: number, pageSize: number): Promise<PagedResult<SearchResultItem>> {
|
||||
const offset = (page - 1) * pageSize;
|
||||
const [{ total }]: any = await db.execute(sql`select count(*) as total from ${entries} where ${entries.languageId} = ${langId}`);
|
||||
const items = await db
|
||||
.select({ id: entries.id, title: entries.title, isXrated: entries.isXrated, machinetypeId: entries.machinetypeId, languageId: entries.languageId })
|
||||
.from(entries)
|
||||
.where(eq(entries.languageId, langId as any))
|
||||
.orderBy(entries.title)
|
||||
.limit(pageSize)
|
||||
.offset(offset);
|
||||
return { items: items as any, page, pageSize, total: Number(total ?? 0) };
|
||||
}
|
||||
|
||||
export async function entriesByMachinetype(mtId: number, page: number, pageSize: number): Promise<PagedResult<SearchResultItem>> {
|
||||
const offset = (page - 1) * pageSize;
|
||||
const [{ total }]: any = await db.execute(sql`select count(*) as total from ${entries} where ${entries.machinetypeId} = ${mtId}`);
|
||||
const items = await db
|
||||
.select({ id: entries.id, title: entries.title, isXrated: entries.isXrated, machinetypeId: entries.machinetypeId, languageId: entries.languageId })
|
||||
.from(entries)
|
||||
.where(eq(entries.machinetypeId, mtId as any))
|
||||
.orderBy(entries.title)
|
||||
.limit(pageSize)
|
||||
.offset(offset);
|
||||
return { items: items as any, page, pageSize, total: Number(total ?? 0) };
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ export const entries = mysqlTable("entries", {
|
||||
isXrated: tinyint("is_xrated").notNull(),
|
||||
machinetypeId: tinyint("machinetype_id"),
|
||||
languageId: char("language_id", { length: 2 }),
|
||||
genretypeId: tinyint("genretype_id"),
|
||||
});
|
||||
|
||||
// Helper table created by ZXDB_help_search.sql
|
||||
@@ -16,3 +17,59 @@ export const searchByTitles = mysqlTable("search_by_titles", {
|
||||
});
|
||||
|
||||
export type Entry = typeof entries.$inferSelect;
|
||||
|
||||
// ZXDB labels (people/companies/teams)
|
||||
export const labels = mysqlTable("labels", {
|
||||
id: int("id").notNull().primaryKey(),
|
||||
name: varchar("name", { length: 100 }).notNull(),
|
||||
labeltypeId: char("labeltype_id", { length: 1 }),
|
||||
});
|
||||
|
||||
// Helper table for names search
|
||||
export const searchByNames = mysqlTable("search_by_names", {
|
||||
labelName: varchar("label_name", { length: 100 }).notNull(),
|
||||
labelId: int("label_id").notNull(),
|
||||
});
|
||||
|
||||
// Helper: entries by authors
|
||||
export const searchByAuthors = mysqlTable("search_by_authors", {
|
||||
labelId: int("label_id").notNull(),
|
||||
entryId: int("entry_id").notNull(),
|
||||
});
|
||||
|
||||
// Helper: entries by publishers
|
||||
export const searchByPublishers = mysqlTable("search_by_publishers", {
|
||||
labelId: int("label_id").notNull(),
|
||||
entryId: int("entry_id").notNull(),
|
||||
});
|
||||
|
||||
// Relations tables
|
||||
export const authors = mysqlTable("authors", {
|
||||
entryId: int("entry_id").notNull(),
|
||||
labelId: int("label_id").notNull(),
|
||||
teamId: int("team_id"),
|
||||
});
|
||||
|
||||
export const publishers = mysqlTable("publishers", {
|
||||
entryId: int("entry_id").notNull(),
|
||||
labelId: int("label_id").notNull(),
|
||||
});
|
||||
|
||||
// Lookups
|
||||
export const languages = mysqlTable("languages", {
|
||||
id: char("id", { length: 2 }).notNull().primaryKey(),
|
||||
// Column name in DB is `text`; map to `name` property for app ergonomics
|
||||
name: varchar("text", { length: 100 }).notNull(),
|
||||
});
|
||||
|
||||
export const machinetypes = mysqlTable("machinetypes", {
|
||||
id: tinyint("id").notNull().primaryKey(),
|
||||
// Column name in DB is `text`
|
||||
name: varchar("text", { length: 50 }).notNull(),
|
||||
});
|
||||
|
||||
export const genretypes = mysqlTable("genretypes", {
|
||||
id: tinyint("id").notNull().primaryKey(),
|
||||
// Column name in DB is `text`
|
||||
name: varchar("text", { length: 50 }).notNull(),
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user