- Replace Prisma client runtime with Drizzle ORM (zero Prisma engine/query engine in production) - Add Prisma-compatible facade (@/lib/prisma-facade.ts) backed by Drizzle for backwards compatibility - Runtime queries route through Drizzle ORM; @prisma/client is now devDependency (types only) - Remove @prisma/adapter-mariadb dependency; delete prisma-pool.ts and types/prisma.ts - New Drizzle schema layer: src/db/schema.ts (176 tables) and src/lib/db.ts (connection) - Update README documenting the dual-layer ORM architecture - Restore src/generated/ gitignore (build artifact for local type generation) - 0 TypeScript errors, 583 tests passing The facade intentionally uses `any` types to match the Prisma Client API surface, allowing existing code to run unmodified while routing queries through Drizzle at runtime.
311 lines
8.1 KiB
TypeScript
311 lines
8.1 KiB
TypeScript
import { asc, eq, inArray, sql } from "drizzle-orm";
|
|
import { CatalogPages, db } from "@/lib/db";
|
|
|
|
export type { NestedTreeNode, TreeNode } from "@/types/catalog";
|
|
|
|
import type { NestedTreeNode, TreeNode } from "@/types/catalog";
|
|
|
|
function toInt(value: unknown, fallback = 0): number {
|
|
const n = typeof value === "number" ? value : Number(value);
|
|
return Number.isFinite(n) ? n : fallback;
|
|
}
|
|
|
|
/**
|
|
* Count catalog_items per page via raw SQL.
|
|
* Real Habbo DBs often store page_id as VARCHAR; Prisma Int groupBy fails or returns 0.
|
|
*/
|
|
export async function getCatalogItemCounts(
|
|
pageIds?: number[],
|
|
): Promise<Map<number, number>> {
|
|
const map = new Map<number, number>();
|
|
try {
|
|
if (pageIds && pageIds.length === 0) return map;
|
|
|
|
if (pageIds) {
|
|
const idStrs = pageIds.map(String);
|
|
const [rows] = (await db.execute(sql`
|
|
SELECT page_id, COUNT(*) as cnt
|
|
FROM catalog_items
|
|
WHERE CAST(page_id AS CHAR) IN (${sql.join(idStrs, sql`, `)})
|
|
GROUP BY page_id
|
|
`)) as unknown as [
|
|
{ page_id: string | number; cnt: number | bigint }[],
|
|
unknown[],
|
|
];
|
|
for (const r of rows) {
|
|
map.set(toInt(r.page_id), toInt(r.cnt));
|
|
}
|
|
return map;
|
|
}
|
|
|
|
const [rows] = (await db.execute(sql`
|
|
SELECT page_id, COUNT(*) as cnt FROM catalog_items GROUP BY page_id
|
|
`)) as unknown as [
|
|
{ page_id: string | number; cnt: number | bigint }[],
|
|
unknown[],
|
|
];
|
|
for (const r of rows) {
|
|
map.set(toInt(r.page_id), toInt(r.cnt));
|
|
}
|
|
} catch {
|
|
// Best-effort — never break the tree for count failures.
|
|
}
|
|
return map;
|
|
}
|
|
|
|
/**
|
|
* Get the full catalog tree as a flat list with depth info.
|
|
*/
|
|
export async function getTreeFlat(): Promise<TreeNode[]> {
|
|
const allPages = await db
|
|
.select()
|
|
.from(CatalogPages)
|
|
.orderBy(asc(CatalogPages.orderNum));
|
|
const itemCountMap = await getCatalogItemCounts();
|
|
|
|
const childCountMap = new Map<number, number>();
|
|
for (const p of allPages) {
|
|
const parentId = toInt(p.parentId, -1);
|
|
childCountMap.set(parentId, (childCountMap.get(parentId) ?? 0) + 1);
|
|
}
|
|
|
|
const pageMap = new Map(allPages.map((p) => [p.id, p]));
|
|
const depthCache = new Map<number, number>();
|
|
|
|
function computeDepth(pageId: number, visiting = new Set<number>()): number {
|
|
if (depthCache.has(pageId)) return depthCache.get(pageId) ?? 0;
|
|
// Cycle guard: parent chains may loop in messy DBs — stop at 0 instead of overflowing.
|
|
if (visiting.has(pageId)) {
|
|
depthCache.set(pageId, 0);
|
|
return 0;
|
|
}
|
|
visiting.add(pageId);
|
|
const page = pageMap.get(pageId);
|
|
const parentId = page ? toInt(page.parentId, -1) : -1;
|
|
if (!page || parentId <= 0) {
|
|
visiting.delete(pageId);
|
|
depthCache.set(pageId, 0);
|
|
return 0;
|
|
}
|
|
const d = computeDepth(parentId, visiting) + 1;
|
|
visiting.delete(pageId);
|
|
depthCache.set(pageId, d);
|
|
return d;
|
|
}
|
|
|
|
return allPages.map((p) => {
|
|
const id = toInt(p.id);
|
|
const parentId = toInt(p.parentId, -1);
|
|
return {
|
|
id,
|
|
caption: p.caption,
|
|
parentId,
|
|
depth: computeDepth(id),
|
|
orderNum: toInt(p.orderNum),
|
|
enabled: String(p.enabled ?? "0"),
|
|
visible: String(p.visible ?? "0"),
|
|
iconImage: toInt(p.iconImage),
|
|
iconColor: toInt(p.iconColor),
|
|
pageLayout: p.pageLayout,
|
|
childCount: childCountMap.get(id) ?? 0,
|
|
itemCount: itemCountMap.get(id) ?? 0,
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Build a nested tree structure from flat nodes.
|
|
*/
|
|
export function buildNestedTree(
|
|
flat: TreeNode[],
|
|
rootParentId = -1,
|
|
): NestedTreeNode[] {
|
|
const childrenMap = new Map<number, NestedTreeNode[]>();
|
|
const nodeMap = new Map<number, NestedTreeNode>();
|
|
|
|
for (const node of flat) {
|
|
const nested: NestedTreeNode = { ...node, children: [] };
|
|
nodeMap.set(node.id, nested);
|
|
if (!childrenMap.has(node.parentId)) childrenMap.set(node.parentId, []);
|
|
childrenMap.get(node.parentId)?.push(nested);
|
|
}
|
|
|
|
for (const node of nodeMap.values()) {
|
|
node.children = childrenMap.get(node.id) ?? [];
|
|
}
|
|
|
|
return childrenMap.get(rootParentId) ?? [];
|
|
}
|
|
|
|
/**
|
|
* Create a new catalog page.
|
|
*/
|
|
export async function createPage(data: {
|
|
parentId: number;
|
|
caption: string;
|
|
captionSave?: string;
|
|
pageLayout?: string;
|
|
iconImage?: number;
|
|
iconColor?: number;
|
|
minRank?: number;
|
|
orderNum?: number;
|
|
visible?: string | boolean;
|
|
enabled?: string | boolean;
|
|
}) {
|
|
const visible =
|
|
typeof data.visible === "boolean"
|
|
? data.visible
|
|
? "1"
|
|
: "0"
|
|
: (data.visible ?? "1");
|
|
const enabled =
|
|
typeof data.enabled === "boolean"
|
|
? data.enabled
|
|
? "1"
|
|
: "0"
|
|
: (data.enabled ?? "1");
|
|
|
|
return db.insert(CatalogPages).values({
|
|
caption: data.caption,
|
|
parentId: data.parentId,
|
|
pageLayout: data.pageLayout ?? "default_3x3",
|
|
captionSave: (data.captionSave ?? data.caption).slice(0, 25),
|
|
iconColor: data.iconColor ?? 0,
|
|
iconImage: data.iconImage ?? 0,
|
|
minRank: data.minRank ?? 1,
|
|
orderNum: data.orderNum ?? 0,
|
|
visible,
|
|
enabled,
|
|
clubOnly: "0",
|
|
vipOnly: "0",
|
|
pageHeadline: "",
|
|
pageTeaser: "",
|
|
includes: "",
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Move a page to a new parent.
|
|
* Validates against circular hierarchy (A → B → C → A).
|
|
*/
|
|
export async function movePage(
|
|
pageId: number,
|
|
newParentId: number,
|
|
): Promise<void> {
|
|
if (newParentId > 0) {
|
|
let currentId = newParentId;
|
|
for (let i = 0; i < 50; i++) {
|
|
if (currentId === pageId) {
|
|
throw new Error("Cannot move page: would create a circular hierarchy");
|
|
}
|
|
const [parent] = await db
|
|
.select({ parentId: CatalogPages.parentId })
|
|
.from(CatalogPages)
|
|
.where(eq(CatalogPages.id, currentId))
|
|
.limit(1);
|
|
if (!parent || parent.parentId <= 0) break;
|
|
currentId = parent.parentId;
|
|
}
|
|
}
|
|
|
|
await db
|
|
.update(CatalogPages)
|
|
.set({ parentId: newParentId })
|
|
.where(eq(CatalogPages.id, pageId));
|
|
}
|
|
|
|
/**
|
|
* Delete catalog_items for the given page ids.
|
|
* Habbo DBs often store page_id as VARCHAR; Prisma Int deleteMany misses rows.
|
|
*/
|
|
async function deleteCatalogItemsByPageIds(pageIds: number[]): Promise<void> {
|
|
if (pageIds.length === 0) return;
|
|
const idStrs = pageIds.map(String);
|
|
await db.execute(sql`
|
|
DELETE FROM catalog_items
|
|
WHERE CAST(page_id AS CHAR) IN (${sql.join(idStrs, sql`, `)})
|
|
`);
|
|
}
|
|
|
|
/**
|
|
* Delete a page with cascade or reparent mode.
|
|
*/
|
|
export async function deletePage(
|
|
pageId: number,
|
|
mode: "cascade" | "reparent" = "reparent",
|
|
): Promise<{ deletedPages: number; movedChildren: number }> {
|
|
const [page] = await db
|
|
.select({ parentId: CatalogPages.parentId })
|
|
.from(CatalogPages)
|
|
.where(eq(CatalogPages.id, pageId))
|
|
.limit(1);
|
|
if (!page) return { deletedPages: 0, movedChildren: 0 };
|
|
|
|
if (mode === "reparent") {
|
|
const [result] = await db
|
|
.update(CatalogPages)
|
|
.set({ parentId: page.parentId })
|
|
.where(eq(CatalogPages.parentId, pageId));
|
|
|
|
await deleteCatalogItemsByPageIds([pageId]);
|
|
await db.delete(CatalogPages).where(eq(CatalogPages.id, pageId));
|
|
|
|
return { deletedPages: 1, movedChildren: result.affectedRows };
|
|
}
|
|
|
|
const deleted = await cascadeDelete(pageId);
|
|
return { deletedPages: deleted, movedChildren: 0 };
|
|
}
|
|
|
|
async function cascadeDelete(pageId: number): Promise<number> {
|
|
const toDelete: number[] = [pageId];
|
|
const queue: number[] = [pageId];
|
|
|
|
while (queue.length > 0) {
|
|
const children = await db
|
|
.select({ id: CatalogPages.id })
|
|
.from(CatalogPages)
|
|
.where(inArray(CatalogPages.parentId, queue));
|
|
queue.length = 0;
|
|
for (const child of children) {
|
|
toDelete.push(child.id);
|
|
queue.push(child.id);
|
|
}
|
|
}
|
|
|
|
await deleteCatalogItemsByPageIds(toDelete);
|
|
for (let i = toDelete.length - 1; i >= 0; i--) {
|
|
await db.delete(CatalogPages).where(eq(CatalogPages.id, toDelete[i]));
|
|
}
|
|
|
|
return toDelete.length;
|
|
}
|
|
|
|
/**
|
|
* Get ancestors (breadcrumb) from root to the given page.
|
|
*/
|
|
export async function getAncestors(
|
|
pageId: number,
|
|
): Promise<Array<{ id: number; caption: string }>> {
|
|
const ancestors: Array<{ id: number; caption: string }> = [];
|
|
let currentId = pageId;
|
|
for (let i = 0; i < 50; i++) {
|
|
const [page] = await db
|
|
.select({
|
|
id: CatalogPages.id,
|
|
caption: CatalogPages.caption,
|
|
parentId: CatalogPages.parentId,
|
|
})
|
|
.from(CatalogPages)
|
|
.where(eq(CatalogPages.id, currentId))
|
|
.limit(1);
|
|
if (!page) break;
|
|
ancestors.unshift({ id: page.id, caption: page.caption });
|
|
if (page.parentId <= 0) break;
|
|
currentId = page.parentId;
|
|
}
|
|
return ancestors;
|
|
}
|
|
|
|
export { toInt };
|