refactor(catalog): unify commands and embed guarded catalog workspace
CI / check (push) Successful in 1m16s
CI / deploy (push) Successful in 28s

This commit is contained in:
Simo committed 2026-09-06 19:40:52 +02:00
1 parent 16f35568f5
commit 193b6686a9
57 files changed
+3268 -728

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
import "server-only";
import { ZodError } from "zod";
import { logger } from "@/lib/logger";
import {
CatalogConflict,
CatalogInputError,
CatalogNotFound,
} from "../domain/hierarchy";
export function catalogFailure(error: unknown) {
if (error instanceof CatalogConflict)
return { message: error.message, status: 409 };
if (error instanceof CatalogNotFound)
return { message: error.message, status: 404 };
if (error instanceof CatalogInputError)
return { message: error.message, status: 400 };
if (error instanceof ZodError)
return {
message: error.issues
.map((issue) => `${issue.path.join(".")}: ${issue.message}`)
.join("; "),
status: 400,
};
const reference = logger.error("Catalog operation failed", {
module: "catalog",
error,
});
return { message: `Catalog operation failed (${reference})`, status: 500 };
}
@@ -0,0 +1,319 @@
import { type SQL, sql } from "drizzle-orm";
import { MySqlDialect } from "drizzle-orm/mysql-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
offers: [
{ id: 1, pageId: 10, itemIds: "12;23;12" },
{ id: 2, pageId: 10, itemIds: "23" },
],
pages: [{ id: 10 }, { id: 11 }],
bases: [{ id: 12 }, { id: 23 }],
queries: [] as { sql: string; params: unknown[] }[],
writes: 0,
failAt: 0,
commits: 0,
rollbacks: 0,
}));
vi.mock("@/lib/db", async () => ({
...(await import("@/db/schema")),
db: {
transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
const saved = state.writes;
try {
const result = await fn({
execute: async (query: SQL) => {
const compiled = new MySqlDialect().sqlToQuery(query);
state.queries.push(compiled);
if (compiled.sql.startsWith("SELECT")) {
if (compiled.sql.includes("catalog_pages"))
return [state.pages, []];
if (compiled.sql.includes("items_base"))
return [
state.bases.filter((row) => compiled.params.includes(row.id)),
[],
];
return [
state.offers.filter((row) => compiled.params.includes(row.id)),
[],
];
}
state.writes++;
if (state.failAt === state.writes)
throw Error("database unavailable");
return [{ affectedRows: 1, insertId: 40 }, []];
},
});
state.commits++;
return result;
} catch (error) {
state.writes = saved;
state.rollbacks++;
throw error;
}
},
},
}));
import {
createBcOfferCommand,
createOfferCommand,
moveOffersCommand,
reorderOffersCommand,
updateBcOfferCommand,
updateOfferCommand,
} from "./offer-commands";
beforeEach(() => {
state.pages = [{ id: 10 }, { id: 11 }];
state.queries = [];
state.writes = 0;
state.failAt = 0;
state.commits = 0;
state.rollbacks = 0;
});
describe("transactional offer commands", () => {
it("rolls back offer and shared furniture writes together", async () => {
state.failAt = 2;
await expect(
updateOfferCommand({
id: 1,
catalogFields: { pageId: 11, costCredits: 0 },
baseItem: { id: 23, fields: { publicName: "New" } },
}),
).rejects.toThrow("database unavailable");
expect(state.writes).toBe(0);
expect(state.rollbacks).toBe(1);
});
it("prevents writing an unrelated shared furniture even when submitted as a new bundle member", async () => {
await expect(
updateOfferCommand({
id: 1,
catalogFields: { itemIds: "12;99" },
baseItem: { id: 99, fields: { publicName: "Wrong" } },
}),
).rejects.toThrow("belong");
expect(state.writes).toBe(0);
});
it("updates a non-first bundle component and preserves zero values", async () => {
await updateOfferCommand({
id: 1,
catalogFields: { pageId: 11, amount: 0, offerId: 0, orderNumber: 0 },
baseItem: { id: 23, fields: { publicName: "New" } },
});
expect(state.commits).toBe(1);
expect(state.writes).toBe(2);
expect(
state.queries.find((q) => q.sql.startsWith("UPDATE `catalog_items`"))
?.params,
).toContain("11");
});
it("rejects missing destination pages before writes", async () => {
state.pages = [];
await expect(
updateOfferCommand({ id: 1, catalogFields: { pageId: 11 } }),
).rejects.toThrow("page");
expect(state.writes).toBe(0);
});
it("rejects missing bundle references before writes", async () => {
await expect(
updateOfferCommand({ id: 1, catalogFields: { itemIds: "12;99" } }),
).rejects.toThrow("Furniture");
expect(state.writes).toBe(0);
});
it("rolls back the entire reorder after an intermediate failure", async () => {
state.failAt = 2;
await expect(
reorderOffersCommand([
{ id: 1, orderNumber: 0 },
{ id: 2, orderNumber: 1 },
]),
).rejects.toThrow("database unavailable");
expect(state.writes).toBe(0);
expect(state.rollbacks).toBe(1);
});
it("rejects duplicate reorder ids", async () => {
await expect(
reorderOffersCommand([
{ id: 1, orderNumber: 0 },
{ id: 1, orderNumber: 1 },
]),
).rejects.toThrow();
expect(state.writes).toBe(0);
});
it("rejects missing offers in a batch", async () => {
await expect(
reorderOffersCommand([
{ id: 1, orderNumber: 0 },
{ id: 99, orderNumber: 1 },
]),
).rejects.toThrow("not found");
expect(state.writes).toBe(0);
});
it("validates all source offers before moving a batch", async () => {
await expect(moveOffersCommand([1, 99], 11)).rejects.toThrow("not found");
expect(state.writes).toBe(0);
});
});
it("does not update furniture removed from the submitted bundle", async () => {
await expect(
updateOfferCommand({
id: 1,
catalogFields: { itemIds: "12" },
baseItem: { id: 23, fields: { publicName: "Wrong" } },
}),
).rejects.toThrow("belong");
expect(state.writes).toBe(0);
});
it("keeps explicit zeros in the actual offer SQL parameters", async () => {
await updateOfferCommand({
id: 1,
catalogFields: {
amount: 0,
orderNumber: 0,
offerId: 0,
limitedSells: 0,
limitedStack: 0,
},
});
const update = state.queries.find((query) => query.sql.startsWith("UPDATE"));
expect(update?.params).toEqual([0, 0, 0, 0, 0, 1]);
});
it("rejects unsafe ids before opening a transaction", async () => {
await expect(
updateOfferCommand({ id: 0, catalogFields: { amount: 1 } }),
).rejects.toThrow();
await expect(
reorderOffersCommand([{ id: 1.5, orderNumber: 1 }]),
).rejects.toThrow();
expect(state.queries).toHaveLength(0);
});
it("uses one atomic update and a string page parameter for a move", async () => {
await moveOffersCommand([1, 2], 11);
expect(state.commits).toBe(1);
expect(state.writes).toBe(1);
expect(
state.queries.find((query) => query.sql.startsWith("UPDATE"))?.params,
).toEqual(["11", 1, 2]);
});
it("ignores noneditable furniture and offer fields", async () => {
await expect(
updateOfferCommand({
id: 1,
catalogFields: { id: 2 },
baseItem: { id: 12, fields: { spriteId: 99 } },
}),
).rejects.toThrow("No valid fields");
expect(state.queries).toHaveLength(0);
});
// Creation must retain the parent lock until its insert commits.
it.each(["normal", "bc"] as const)(
"rejects a deleted parent before a %s creation callback runs",
async (kind) => {
state.pages = [];
const insert = vi.fn();
await expect(
createOfferCommand(kind, { pageId: 11, itemIds: "12;23" }, insert),
).rejects.toThrow("page");
expect(insert).not.toHaveBeenCalled();
expect(state.writes).toBe(0);
},
);
it("rejects missing furniture before creating an offer", async () => {
const insert = vi.fn();
await expect(
createOfferCommand("normal", { pageId: 11, itemIds: "12;99" }, insert),
).rejects.toThrow("Furniture");
expect(insert).not.toHaveBeenCalled();
});
it("runs creation insert under the same transaction after reference locks", async () => {
await createOfferCommand(
"normal",
{ pageId: 11, itemIds: "12;23;12" },
async (tx) => {
await tx.execute(sql`INSERT INTO catalog_items (id) VALUES (40)`);
return 40;
},
);
expect(state.queries.map((query) => query.sql.split(" ")[0])).toEqual([
"SELECT",
"SELECT",
"INSERT",
]);
expect(state.queries[0].sql).toContain("FOR UPDATE");
expect(state.queries[1].sql).toContain("FOR UPDATE");
expect(state.commits).toBe(1);
});
it("rolls back a failed creation insert", async () => {
state.failAt = 1;
await expect(
createOfferCommand("normal", { pageId: 11, itemIds: "12" }, (tx) =>
tx.execute(sql`INSERT INTO catalog_items (id) VALUES (40)`),
),
).rejects.toThrow("database unavailable");
expect(state.rollbacks).toBe(1);
expect(state.writes).toBe(0);
});
it("rejects missing BC offers instead of reporting a zero-row update as saved", async () => {
await expect(
updateBcOfferCommand(99, { catalogName: "Missing" }),
).rejects.toThrow("not found");
expect(state.writes).toBe(0);
});
it("keeps BC fields explicit and uses BC destination locks", async () => {
await updateBcOfferCommand(1, {
pageId: 11,
itemIds: "12;23",
orderNumber: 0,
costCredits: 99,
});
expect(state.queries[0].sql).toContain("catalog_pages_bc");
const update = state.queries.find((query) => query.sql.startsWith("UPDATE"));
expect(update?.sql).toContain("catalog_items_bc");
expect(update?.sql).not.toContain("cost_credits");
expect(update?.params).toEqual(["11", "12;23", 0, 1]);
});
it("creates a BC offer with only its supported fields and returns the committed id", async () => {
const id = await createBcOfferCommand({
pageId: 11,
itemIds: "12;23;12",
catalogName: "Bundle",
orderNumber: 0,
extradata: "x;y",
costCredits: 99,
});
expect(id).toBe(40);
expect(state.commits).toBe(1);
const insert = state.queries.find((query) => query.sql.startsWith("INSERT"));
expect(insert?.sql).toContain("catalog_items_bc");
expect(insert?.sql).not.toContain("cost_credits");
expect(insert?.params).toEqual(["11", "12;23;12", "Bundle", 0, "x;y"]);
});
it("rejects incomplete BC creation before beginning a transaction", async () => {
await expect(
createBcOfferCommand({ pageId: 11, itemIds: "12" }),
).rejects.toThrow();
expect(state.queries).toHaveLength(0);
});
it("rolls back failed BC creation and does not return an id", async () => {
state.failAt = 1;
await expect(
createBcOfferCommand({
pageId: 11,
itemIds: "12",
catalogName: "One",
orderNumber: 0,
extradata: "",
}),
).rejects.toThrow("database unavailable");
expect(state.commits).toBe(0);
expect(state.rollbacks).toBe(1);
});
it("rejects a missing BC destination before touching the offer", async () => {
state.pages = [];
await expect(updateBcOfferCommand(1, { pageId: 11 })).rejects.toThrow("page");
expect(state.writes).toBe(0);
});
@@ -0,0 +1,206 @@
import "server-only";
import { getTableColumns, type SQL, sql } from "drizzle-orm";
import { CatalogItems, CatalogItemsBc, db, ItemsBase } from "@/lib/db";
import {
distinctOfferIds,
furniturePatchSchema,
offerIdSchema,
offerInteger,
offerPatchSchema,
parseFurnitureIds,
} from "../domain/offer-input";
type OfferKind = "normal" | "bc";
function offerTable(kind: OfferKind) {
if (kind !== "normal" && kind !== "bc") throw Error("Invalid catalog type");
return kind === "bc" ? CatalogItemsBc : CatalogItems;
}
type Transaction = Parameters<Parameters<typeof db.transaction>[0]>[0];
export type UpdateOfferInput = {
id: number;
catalogFields: Record<string, unknown>;
baseItem?: { id: number; fields: Record<string, unknown> };
};
type OfferRow = { id: number; pageId: number | string; itemIds: string };
async function lockedOffers(
tx: Transaction,
ids: number[],
kind: OfferKind = "normal",
): Promise<OfferRow[]> {
const [rows] = await tx.execute(
sql`SELECT id, page_id AS pageId, item_ids AS itemIds FROM ${offerTable(kind)} WHERE id IN (${sql.join(
[...ids].sort((a, b) => a - b),
sql`, `,
)}) ORDER BY id FOR UPDATE`,
);
const offers = rows as unknown as OfferRow[];
if (
offers.length !== ids.length ||
ids.some((id) => !offers.some((offer) => Number(offer.id) === id))
)
throw Error("Catalog item not found");
return offers;
}
async function lockPage(
tx: Transaction,
id: number,
kind: OfferKind = "normal",
) {
offerTable(kind);
const [rows] = await tx.execute(
sql`SELECT id FROM ${sql.identifier(kind === "bc" ? "catalog_pages_bc" : "catalog_pages")} WHERE id=${id} FOR UPDATE`,
);
if (
!(rows as unknown as { id: number }[]).some((row) => Number(row.id) === id)
)
throw Error("Catalog page not found");
}
async function lockFurniture(tx: Transaction, ids: number[]) {
const unique = [...new Set(ids)].sort((a, b) => a - b);
const [rows] = await tx.execute(
sql`SELECT id FROM items_base WHERE id IN (${sql.join(unique, sql`, `)}) ORDER BY id FOR UPDATE`,
);
const found = new Set(
(rows as unknown as { id: number }[]).map((row) => Number(row.id)),
);
if (unique.some((id) => !found.has(id)))
throw Error("Furniture reference not found");
}
function assignments(
table: typeof CatalogItems | typeof CatalogItemsBc | typeof ItemsBase,
fields: Record<string, unknown>,
): SQL[] {
const columns = getTableColumns(table);
return Object.entries(fields)
.filter(([, value]) => value !== undefined)
.map(([key, value]) => {
const column = columns[key as keyof typeof columns];
// SQL string parameters work with both deployed page_id column types.
return sql`${sql.identifier(column.name)}=${key === "pageId" ? String(value) : value}`;
});
}
export async function updateOfferCommand(input: UpdateOfferInput) {
offerIdSchema.parse(input.id);
const fields = offerPatchSchema.parse(input.catalogFields);
const baseFields = input.baseItem
? furniturePatchSchema.parse(input.baseItem.fields)
: {};
if (input.baseItem) offerIdSchema.parse(input.baseItem.id);
if (
!Object.values(fields).some((value) => value !== undefined) &&
!Object.values(baseFields).some((value) => value !== undefined)
)
throw Error("No valid fields to update");
return db.transaction(async (tx) => {
// Page locks precede offer locks, matching category deletion's lock order.
if (fields.pageId !== undefined) await lockPage(tx, fields.pageId);
const [offer] = await lockedOffers(tx, [input.id]);
if (input.baseItem) {
const currentIds = String(offer.itemIds).split(";").map(Number);
const nextIds =
fields.itemIds === undefined
? currentIds
: parseFurnitureIds(fields.itemIds);
if (
!currentIds.includes(input.baseItem.id) ||
!nextIds.includes(input.baseItem.id)
)
throw Error("Shared furniture must belong to the catalog offer");
}
const references =
fields.itemIds === undefined ? [] : parseFurnitureIds(fields.itemIds);
if (input.baseItem) references.push(input.baseItem.id);
if (references.length) await lockFurniture(tx, references);
const offerAssignments = assignments(CatalogItems, fields);
if (offerAssignments.length)
await tx.execute(
sql`UPDATE ${CatalogItems} SET ${sql.join(offerAssignments, sql`, `)} WHERE id=${input.id}`,
);
const baseAssignments = assignments(ItemsBase, baseFields);
if (input.baseItem && baseAssignments.length)
await tx.execute(
sql`UPDATE ${ItemsBase} SET ${sql.join(baseAssignments, sql`, `)} WHERE id=${input.baseItem.id}`,
);
});
}
export async function reorderOffersCommand(
orders: Array<{ id: number; orderNumber: number }>,
) {
const ids = distinctOfferIds(orders.map((row) => row.id));
for (const row of orders) offerInteger.parse(row.orderNumber);
if (!ids.length) return;
return db.transaction(async (tx) => {
await lockedOffers(tx, ids);
for (const row of orders)
await tx.execute(
sql`UPDATE ${CatalogItems} SET order_number=${row.orderNumber} WHERE id=${row.id}`,
);
});
}
export async function moveOffersCommand(ids: number[], targetPageId: number) {
distinctOfferIds(ids);
offerIdSchema.parse(targetPageId);
if (!ids.length) return;
return db.transaction(async (tx) => {
await lockPage(tx, targetPageId);
await lockedOffers(tx, ids);
await tx.execute(
sql`UPDATE ${CatalogItems} SET page_id=${String(targetPageId)} WHERE id IN (${sql.join(ids, sql`, `)})`,
);
});
}
/** Call the allocator before entering this command; only the insert uses its transaction. */
export async function createOfferCommand<T>(
kind: OfferKind,
references: { pageId: number; itemIds: string },
insert: (tx: Transaction) => Promise<T>,
): Promise<T> {
offerTable(kind);
offerIdSchema.parse(references.pageId);
const ids = parseFurnitureIds(references.itemIds);
return db.transaction(async (tx) => {
await lockPage(tx, references.pageId, kind);
await lockFurniture(tx, ids);
return insert(tx);
});
}
// BC deliberately has no currencies, amount, LTD values or shared-furniture mutation.
const bcOfferPatchSchema = offerPatchSchema.pick({
pageId: true,
itemIds: true,
catalogName: true,
orderNumber: true,
extradata: true,
});
export async function updateBcOfferCommand(
id: number,
input: Record<string, unknown>,
) {
offerIdSchema.parse(id);
const fields = bcOfferPatchSchema.parse(input);
const updates = assignments(CatalogItemsBc, fields);
if (!updates.length) throw Error("No valid fields to update");
return db.transaction(async (tx) => {
if (fields.pageId !== undefined) await lockPage(tx, fields.pageId, "bc");
await lockedOffers(tx, [id], "bc");
if (fields.itemIds !== undefined)
await lockFurniture(tx, parseFurnitureIds(fields.itemIds));
await tx.execute(
sql`UPDATE ${CatalogItemsBc} SET ${sql.join(updates, sql`, `)} WHERE id=${id}`,
);
});
}
export async function createBcOfferCommand(
input: Record<string, unknown>,
): Promise<number> {
const fields = bcOfferPatchSchema.required().parse(input);
return createOfferCommand("bc", fields, async (tx) => {
const [result] = await tx.execute(
sql`INSERT INTO ${CatalogItemsBc} SET ${sql.join(assignments(CatalogItemsBc, fields), sql`, `)}`,
);
const id = Number((result as unknown as { insertId: number }).insertId);
offerIdSchema.parse(id);
return id;
});
}
+38
View File
@@ -0,0 +1,38 @@
import "server-only";
import { asc, count, eq, or } from "drizzle-orm";
import {
CatalogItems,
CatalogItemsBc,
CatalogPages,
CatalogPagesBc,
db,
} from "@/lib/db";
import type { CatalogKind } from "./page-commands";
export async function loadCatalogOverview(kind: CatalogKind) {
const pages = kind === "bc" ? CatalogPagesBc : CatalogPages;
const items = kind === "bc" ? CatalogItemsBc : CatalogItems;
const [roots, pageCount, itemCount, enabledCount] = await Promise.all([
db
.select({
id: pages.id,
caption: pages.caption,
iconImage: pages.iconImage,
enabled: pages.enabled,
orderNum: pages.orderNum,
})
.from(pages)
.where(or(eq(pages.parentId, -1), eq(pages.parentId, 0)))
.orderBy(asc(pages.orderNum), asc(pages.id)),
db.select({ total: count() }).from(pages),
db.select({ total: count() }).from(items),
db.select({ total: count() }).from(pages).where(eq(pages.enabled, "1")),
]);
return {
roots,
totals: {
totalPages: Number(pageCount[0]?.total ?? 0),
totalItems: Number(itemCount[0]?.total ?? 0),
enabledPages: Number(enabledCount[0]?.total ?? 0),
},
};
}
@@ -0,0 +1,120 @@
import type { SQL } from "drizzle-orm";
import { MySqlDialect } from "drizzle-orm/mysql-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
rows: [
{ id: 1, parentId: -1, orderNum: 1, caption: "Current" },
{ id: 2, parentId: 1, orderNum: 1 },
{ id: 3, parentId: 1, orderNum: 2 },
],
queries: [] as string[],
writes: 0,
failAt: 0,
commits: 0,
rollbacks: 0,
}));
vi.mock("@/lib/db", async () => ({
...(await import("@/db/schema")),
db: {
transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
const saved = state.writes;
try {
const result = await fn({
execute: async (query: SQL) => {
const text = new MySqlDialect().sqlToQuery(query).sql;
state.queries.push(text);
if (text.startsWith("SELECT")) return [state.rows, []];
state.writes++;
if (state.failAt === state.writes)
throw Error("database unavailable");
return [{ insertId: 4, affectedRows: 1 }, []];
},
});
state.commits++;
return result;
} catch (error) {
state.writes = saved;
state.rollbacks++;
throw error;
}
},
},
}));
import {
deletePageCommand,
reorderPagesCommand,
updatePageCommand,
} from "./page-commands";
beforeEach(() => {
state.queries = [];
state.writes = 0;
state.failAt = 0;
state.commits = 0;
state.rollbacks = 0;
});
describe.each(["normal", "bc"] as const)(
"%s transactional commands",
(kind) => {
it("rejects stale category fields without overwriting another operator", async () => {
await expect(
updatePageCommand(kind, 1, { caption: "Mine" }, { caption: "Old" }),
).rejects.toThrow("another operator");
expect(state.writes).toBe(0);
});
it("accepts matching category fields", async () => {
await updatePageCommand(
kind,
1,
{ caption: "Mine" },
{ caption: "Current" },
);
expect(state.commits).toBe(1);
});
it("rolls back an interrupted reorder", async () => {
state.failAt = 2;
await expect(
reorderPagesCommand(kind, {
parentId: 1,
ids: [3, 2],
expectedIds: [2, 3],
}),
).rejects.toThrow("database unavailable");
expect(state.writes).toBe(0);
expect(state.rollbacks).toBe(1);
expect(state.queries[0]).toContain("ORDER BY id FOR UPDATE");
});
it("rejects stale reorder before any writes", async () => {
await expect(
reorderPagesCommand(kind, {
parentId: 1,
ids: [3, 2],
expectedIds: [3, 2],
}),
).rejects.toThrow("changed");
expect(state.writes).toBe(0);
});
it("rejects a cyclic parent through ordinary page editing", async () => {
await expect(updatePageCommand(kind, 1, { parentId: 2 })).rejects.toThrow(
"circular",
);
expect(state.writes).toBe(0);
});
it("rolls back reparent plus delete if the offer delete fails", async () => {
state.failAt = 2;
await expect(deletePageCommand(kind, 2, "reparent")).rejects.toThrow();
expect(state.writes).toBe(0);
expect(state.commits).toBe(0);
});
it("commits the entire subtree delete once", async () => {
expect(await deletePageCommand(kind, 1, "cascade")).toEqual({
deletedPages: 3,
movedChildren: 0,
});
expect(state.commits).toBe(1);
expect(state.writes).toBe(2);
});
},
);
@@ -0,0 +1,169 @@
import "server-only";
import { getTableColumns, type SQL, sql } from "drizzle-orm";
import { CatalogPages, CatalogPagesBc, db } from "@/lib/db";
import {
assertParent,
CatalogConflict,
CatalogInputError,
CatalogNotFound,
collectSubtree,
type HierarchyPage,
positiveId,
validateSiblingOrder,
} from "../domain/hierarchy";
import { pagePatchSchema } from "../domain/page-input";
export type CatalogKind = "normal" | "bc";
type Transaction = Parameters<Parameters<typeof db.transaction>[0]>[0];
function tables(kind: CatalogKind) {
if (kind !== "normal" && kind !== "bc")
throw new CatalogInputError("Invalid catalog type");
return {
pages: kind === "bc" ? CatalogPagesBc : CatalogPages,
items: sql.identifier(kind === "bc" ? "catalog_items_bc" : "catalog_items"),
};
}
async function lockedPages(
tx: Transaction,
kind: CatalogKind,
): Promise<HierarchyPage[]> {
const { pages } = tables(kind);
// Structural commands serialize against a stable lock order, including parent validation.
const [rows] = await tx.execute(
sql`SELECT id, parent_id AS parentId, order_num AS orderNum FROM ${pages} ORDER BY id FOR UPDATE`,
);
return (rows as unknown as HierarchyPage[]).map((row) => ({
id: Number(row.id),
parentId: Number(row.parentId),
orderNum: Number(row.orderNum),
}));
}
function fieldsSql(kind: CatalogKind, fields: Record<string, unknown>): SQL[] {
const columns = getTableColumns(tables(kind).pages);
return Object.entries(fields)
.filter(
([key, value]) => value !== undefined && Object.hasOwn(columns, key),
)
.map(
([key, value]) =>
sql`${sql.identifier(columns[key as keyof typeof columns].name)} = ${value}`,
);
}
export async function updatePageCommand(
kind: CatalogKind,
id: number,
fields: Record<string, unknown>,
expected?: Record<string, unknown>,
) {
positiveId(id);
const data = pagePatchSchema.parse(fields);
return db.transaction(async (tx) => {
const rows = await lockedPages(tx, kind);
if (!rows.some((row) => row.id === id))
throw new CatalogNotFound("Catalog page not found");
if (expected) {
const columns = getTableColumns(tables(kind).pages);
const [existingRows] = await tx.execute(
sql`SELECT * FROM ${tables(kind).pages} WHERE id=${id} FOR UPDATE`,
);
const current = (existingRows as unknown as Record<string, unknown>[])[0];
if (!current) throw new CatalogNotFound("Catalog page not found");
for (const [key, value] of Object.entries(expected)) {
if (!Object.hasOwn(data, key) || !Object.hasOwn(columns, key)) continue;
const column = columns[key as keyof typeof columns];
if (String(current[column.name] ?? "") !== String(value ?? ""))
throw new CatalogConflict(
"This category was modified by another operator. Reload before saving.",
);
}
}
if (data.parentId !== undefined) assertParent(rows, id, data.parentId);
const assignments = fieldsSql(kind, data);
if (!assignments.length)
throw new CatalogInputError("No valid fields to update");
await tx.execute(
sql`UPDATE ${tables(kind).pages} SET ${sql.join(assignments, sql`, `)} WHERE id=${id}`,
);
});
}
export async function createPageCommand(
kind: CatalogKind,
fields: Record<string, unknown>,
): Promise<number> {
const data = pagePatchSchema.parse(fields);
if (!data.caption) throw new CatalogInputError("Category name is required");
return db.transaction(async (tx) => {
const rows = await lockedPages(tx, kind);
assertParent(rows, 0, data.parentId ?? -1);
const assignments = fieldsSql(kind, {
...data,
captionSave: data.captionSave ?? data.caption?.slice(0, 25),
});
const [result] = await tx.execute(
sql`INSERT INTO ${tables(kind).pages} SET ${sql.join(assignments, sql`, `)}`,
);
return Number((result as unknown as { insertId: number }).insertId);
});
}
export async function reorderPagesCommand(
kind: CatalogKind,
input: { parentId: number; ids: number[]; expectedIds: number[] },
) {
return db.transaction(async (tx) => {
const rows = await lockedPages(tx, kind);
validateSiblingOrder(rows, input.parentId, input.ids, input.expectedIds);
for (const [index, id] of input.ids.entries())
await tx.execute(
sql`UPDATE ${tables(kind).pages} SET order_num=${index + 1} WHERE id=${id}`,
);
});
}
export async function deletePageCommand(
kind: CatalogKind,
id: number,
mode: "reparent" | "cascade",
) {
positiveId(id);
if (mode !== "reparent" && mode !== "cascade")
throw new CatalogInputError("Invalid delete mode");
return db.transaction(async (tx) => {
const rows = await lockedPages(tx, kind);
const page = rows.find((row) => row.id === id);
if (!page) return { deletedPages: 0, movedChildren: 0 };
const { pages, items } = tables(kind);
let ids = [id];
let movedChildren = 0;
if (mode === "reparent") {
assertParent(rows, id, page.parentId);
movedChildren = rows.filter((row) => row.parentId === id).length;
await tx.execute(
sql`UPDATE ${pages} SET parent_id=${page.parentId} WHERE parent_id=${id}`,
);
} else ids = collectSubtree(rows, id);
// String literals match INT and VARCHAR page_id installations without casting the indexed column.
await tx.execute(
sql`DELETE FROM ${items} WHERE page_id IN (${sql.join(ids.map(String), sql`, `)})`,
);
await tx.execute(
sql`DELETE FROM ${pages} WHERE id IN (${sql.join(ids, sql`, `)})`,
);
return { deletedPages: ids.length, movedChildren };
});
}
export async function togglePageCommand(
kind: CatalogKind,
id: number,
field: "enabled" | "visible",
) {
positiveId(id);
if (field !== "enabled" && field !== "visible")
throw new CatalogInputError("Invalid toggle field");
return db.transaction(async (tx) => {
const rows = await lockedPages(tx, kind);
if (!rows.some((row) => row.id === id))
throw new CatalogNotFound("Catalog page not found");
const column = sql.identifier(field);
await tx.execute(
sql`UPDATE ${tables(kind).pages} SET ${column}=CASE WHEN ${column}='1' THEN '0' ELSE '1' END WHERE id=${id}`,
);
});
}
@@ -0,0 +1,47 @@
import { beforeEach, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
reorder: vi.fn(),
send: vi.fn(),
permission: vi.fn(),
}));
vi.mock("@/features/catalog/server/page-commands", () => ({
reorderPagesCommand: state.reorder,
}));
vi.mock("@/features/catalog/server/sync-status", () => ({
sendCatalogUpdate: state.send,
}));
vi.mock("@/features/catalog/server/errors", () => ({
catalogFailure: vi.fn(),
}));
vi.mock("@/lib/admin/guard", () => ({ requirePermission: state.permission }));
vi.mock("@/lib/permissions", () => ({
PERMS: { CATALOG_EDIT: "catalog.edit" },
}));
vi.mock("@/lib/services/catalog-git-queue", () => ({
withCatalogExport: async (fn: () => unknown) => fn(),
}));
vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
import { reorderCatalogPages } from "@/actions/catalog";
beforeEach(() => {
vi.clearAllMocks();
state.reorder.mockResolvedValue(undefined);
state.permission.mockResolvedValue({ id: 1 });
});
it("requests only one hotel update after a complete sibling reorder", async () => {
const input = { parentId: 1, ids: [3, 2], expectedIds: [2, 3] };
await reorderCatalogPages(input);
expect(state.reorder).toHaveBeenCalledWith("normal", input);
expect(state.send).toHaveBeenCalledOnce();
expect(state.permission).toHaveBeenCalledWith("catalog.edit");
});
it("does not request a hotel update for a rejected reorder", async () => {
state.reorder.mockRejectedValueOnce(Error("conflict"));
await expect(
reorderCatalogPages({ parentId: 1, ids: [3, 2], expectedIds: [2, 3] }),
).rejects.toThrow();
expect(state.send).not.toHaveBeenCalled();
});
+136
View File
@@ -0,0 +1,136 @@
import type { SQL } from "drizzle-orm";
import { MySqlDialect } from "drizzle-orm/mysql-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
queries: [] as { sql: string; params: unknown[] }[],
categories: [] as unknown[],
furniture: [] as unknown[],
offers: [] as unknown[],
parents: [] as unknown[],
}));
vi.mock("@/lib/db", () => ({
db: {
execute: vi.fn(async (query: SQL) => {
const result = new MySqlDialect().sqlToQuery(query);
state.queries.push(result);
if (result.sql.includes("FROM items_base")) return [state.furniture];
if (result.sql.includes("SELECT i.id")) return [state.offers];
if (result.sql.includes("WHERE id IN")) return [state.parents];
return [state.categories];
}),
},
}));
import { searchCatalog } from "./search";
beforeEach(() => {
state.queries = [];
state.categories = [];
state.furniture = [];
state.offers = [];
state.parents = [];
});
describe("bounded catalog search", () => {
it.each(["", "a", " ", "x".repeat(101)])(
"rejects invalid query before SQL: %s",
async (query) => {
await expect(searchCatalog("normal", query)).rejects.toThrow(
"Search requires",
);
expect(state.queries).toHaveLength(0);
},
);
it("rejects an unknown catalog instead of interpolating a table name", async () => {
await expect(searchCatalog("other", "chair")).rejects.toThrow(
"Invalid catalog type",
);
expect(state.queries).toHaveLength(0);
});
it("binds text and treats user wildcard characters literally", async () => {
await searchCatalog("normal", "chair_%' OR 1=1");
expect(state.queries.every((query) => !query.sql.includes("chair"))).toBe(
true,
);
expect(state.queries[0].params).toContain("chair=_=%' OR 1==1%");
expect(state.queries.every((query) => query.sql.includes("LIMIT"))).toBe(
true,
);
});
it.each(["normal", "bc"])(
"finds numeric IDs and resolves bounded category paths for %s",
async (kind) => {
state.furniture = [{ id: 7, classname: "chair", spriteId: 9 }];
state.offers = [
{
id: 50,
caption: "Offer",
pageId: "11",
pageCaption: "Chairs",
parentId: 1,
itemIds: "3;7",
},
];
state.parents = [{ id: 1, caption: "Shop", parentId: -1 }];
const results = await searchCatalog(kind, "9");
expect(results).toEqual([
{
kind: "offer",
id: 50,
pageId: 11,
caption: "Offer",
path: ["Shop", "Chairs"],
href:
kind === "bc"
? "/admin/catalog/builder-club/11"
: "/admin/catalog/11",
furniture: { id: 7, classname: "chair", spriteId: 9 },
},
]);
const offers = state.queries.find((query) =>
query.sql.includes("SELECT i.id"),
);
expect(offers?.sql.includes("i.offer_id")).toBe(kind === "normal");
expect(offers?.sql).toContain(
kind === "bc" ? "catalog_items_bc" : "catalog_items",
);
expect(offers?.params).toContain("7");
expect(offers?.params).toContain("9");
expect(state.queries).toHaveLength(4);
},
);
it("caps final results and furniture bundle predicates even if a repository overreturns", async () => {
state.categories = Array.from({ length: 40 }, (_, index) => ({
id: index + 1,
caption: "Category",
parentId: -1,
}));
state.furniture = Array.from({ length: 80 }, (_, index) => ({
id: index + 1,
classname: "chair",
spriteId: index,
}));
state.offers = Array.from({ length: 80 }, (_, index) => ({
id: index + 1,
caption: "Offer",
pageId: 1,
pageCaption: "Category",
parentId: -1,
itemIds: "1",
}));
const results = await searchCatalog("normal", "chair");
expect(results).toHaveLength(25);
const query = state.queries.find((query) =>
query.sql.includes("SELECT i.id"),
);
expect(query?.sql.match(/FIND_IN_SET/g)).toHaveLength(25);
expect(query?.params.at(-1)).toBe(13);
});
it("terminates corrupt cyclic category paths without repeated parent queries", async () => {
state.categories = [{ id: 1, caption: "One", parentId: 2 }];
state.parents = [{ id: 2, caption: "Two", parentId: 1 }];
const results = await searchCatalog("normal", "One");
expect(results[0].path).toEqual(["Two", "One"]);
expect(state.queries).toHaveLength(4);
});
});
+149
View File
@@ -0,0 +1,149 @@
import "server-only";
import { sql } from "drizzle-orm";
import { db } from "@/lib/db";
import { type CatalogSearchResult, parseCatalogSearch } from "../domain/search";
const LIMIT = 25;
const MAX_PATH_DEPTH = 16;
type PageRow = { id: number; caption: string; parentId: number };
type FurnitureRow = { id: number; classname: string; spriteId: number };
type OfferRow = {
id: number;
caption: string;
pageId: number;
pageCaption: string;
parentId: number;
itemIds: string;
};
export async function searchCatalog(
kind: string,
query: string,
): Promise<CatalogSearchResult[]> {
const input = parseCatalogSearch(kind, query);
const pages = sql.identifier(
input.kind === "bc" ? "catalog_pages_bc" : "catalog_pages",
);
const offers = sql.identifier(
input.kind === "bc" ? "catalog_items_bc" : "catalog_items",
);
const number = input.numericId;
const [categoryData, furnitureData] = await Promise.all([
db.execute(
sql`SELECT id, caption, parent_id AS parentId FROM ${pages} WHERE caption LIKE ${input.pattern} ESCAPE '=' ${number === null ? sql`` : sql`OR id=${number}`} ORDER BY ${number === null ? sql`id` : sql`CASE WHEN id=${number} THEN 0 ELSE 1 END, id`} LIMIT 12`,
),
db.execute(
sql`SELECT id, item_name AS classname, sprite_id AS spriteId FROM items_base WHERE item_name LIKE ${input.pattern} ESCAPE '=' OR public_name LIKE ${input.pattern} ESCAPE '=' ${number === null ? sql`` : sql`OR id=${number} OR sprite_id=${number}`} ORDER BY ${number === null ? sql`id` : sql`CASE WHEN id=${number} THEN 0 ELSE 1 END, id`} LIMIT 25`,
),
]);
const categories = (categoryData[0] as unknown as PageRow[]).slice(0, 12);
const furniture = (furnitureData[0] as unknown as FurnitureRow[]).slice(
0,
LIMIT,
);
const predicates = [sql`i.catalog_name LIKE ${input.pattern} ESCAPE '='`];
if (number !== null) {
predicates.push(sql`i.id=${number}`, sql`i.page_id=${String(number)}`);
if (input.kind === "normal") predicates.push(sql`i.offer_id=${number}`);
}
// The legacy semicolon relation has no join index. Cap candidate furniture IDs before testing bundle membership.
for (const base of furniture)
predicates.push(
sql`FIND_IN_SET(${String(base.id)}, REPLACE(i.item_ids, ';', ',')) > 0`,
);
const [offerData] = await db.execute(
sql`SELECT i.id, i.catalog_name AS caption, i.page_id AS pageId, i.item_ids AS itemIds, p.caption AS pageCaption, p.parent_id AS parentId FROM ${offers} i INNER JOIN ${pages} p ON p.id=i.page_id WHERE ${sql.join(predicates, sql` OR `)} ORDER BY ${number === null ? sql`i.id` : sql`CASE WHEN i.id=${number} THEN 0 ELSE 1 END, i.id`} LIMIT ${LIMIT - categories.length}`,
);
const matches = (offerData as unknown as OfferRow[]).slice(
0,
LIMIT - categories.length,
);
const pageMap = new Map<number, PageRow>();
for (const page of categories)
pageMap.set(Number(page.id), {
...page,
id: Number(page.id),
parentId: Number(page.parentId),
});
for (const offer of matches)
pageMap.set(Number(offer.pageId), {
id: Number(offer.pageId),
caption: offer.pageCaption,
parentId: Number(offer.parentId),
});
let frontier = [...pageMap.values()].map((page) => page.parentId);
const attempted = new Set(pageMap.keys());
for (let depth = 0; depth < MAX_PATH_DEPTH; depth++) {
const ids = [...new Set(frontier)]
.filter((id) => id > 0 && !attempted.has(id))
.slice(0, LIMIT);
if (!ids.length) break;
for (const id of ids) attempted.add(id);
const [parents] = await db.execute(
sql`SELECT id, caption, parent_id AS parentId FROM ${pages} WHERE id IN (${sql.join(ids, sql`, `)}) LIMIT 25`,
);
frontier = [];
for (const raw of parents as unknown as PageRow[]) {
const page = {
...raw,
id: Number(raw.id),
parentId: Number(raw.parentId),
};
pageMap.set(page.id, page);
frontier.push(page.parentId);
}
}
function pagePath(id: number) {
const path: string[] = [];
const seen = new Set<number>();
let current = id;
while (current > 0 && !seen.has(current) && seen.size <= MAX_PATH_DEPTH) {
seen.add(current);
const page = pageMap.get(current);
if (!page) {
path.unshift(`#${current}`);
break;
}
path.unshift(page.caption);
current = page.parentId;
}
return path;
}
const href = (id: number) =>
input.kind === "bc"
? `/admin/catalog/builder-club/${id}`
: `/admin/catalog/${id}`;
return [
...categories.map(
(page): CatalogSearchResult => ({
kind: "category",
id: Number(page.id),
pageId: Number(page.id),
caption: page.caption,
path: pagePath(Number(page.id)),
href: href(Number(page.id)),
}),
),
...matches.map((offer): CatalogSearchResult => {
const ids = new Set(String(offer.itemIds).split(";").map(Number));
const base = furniture.find((item) => ids.has(Number(item.id)));
return {
kind: "offer",
id: Number(offer.id),
pageId: Number(offer.pageId),
caption: offer.caption,
path: pagePath(Number(offer.pageId)),
href: href(Number(offer.pageId)),
...(base
? {
furniture: {
id: Number(base.id),
classname: base.classname,
spriteId: Number(base.spriteId),
},
}
: {}),
};
}),
];
}
@@ -0,0 +1,42 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({
root: "",
send: vi.fn(),
log: vi.fn(() => "catalog-error-1"),
}));
vi.mock("@/lib/services/catalog-git-config", () => ({
catalogStateRoot: () => state.root,
}));
vi.mock("@/lib/services/rcon", () => ({ rcon: { updateCatalog: state.send } }));
vi.mock("@/lib/logger", () => ({ logger: { error: state.log } }));
import { readCatalogHotelStatus, sendCatalogUpdate } from "./sync-status";
beforeEach(async () => {
state.root = await fs.mkdtemp(path.join(os.tmpdir(), "catalog-status-"));
vi.clearAllMocks();
});
afterEach(async () => {
await fs.rm(state.root, { recursive: true, force: true });
});
it("records a failed delivery without rejecting an already saved operation", async () => {
state.send.mockResolvedValue(false);
const result = await sendCatalogUpdate();
expect(result.sent).toBe(false);
expect(result.reference).toBe("catalog-error-1");
expect(await readCatalogHotelStatus()).toEqual(result);
});
it("allows retrying the update without reapplying catalog mutations", async () => {
state.send
.mockRejectedValueOnce(Error("offline"))
.mockResolvedValueOnce(true);
expect((await sendCatalogUpdate()).sent).toBe(false);
expect((await sendCatalogUpdate()).sent).toBe(true);
expect((await readCatalogHotelStatus())?.sent).toBe(true);
});
it("returns no status before the first attempt", async () =>
expect(await readCatalogHotelStatus()).toBeNull());
@@ -0,0 +1,59 @@
import "server-only";
import { randomUUID } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { logger } from "@/lib/logger";
import { catalogStateRoot } from "@/lib/services/catalog-git-config";
import { rcon } from "@/lib/services/rcon";
export interface CatalogHotelStatus {
sent: boolean;
checkedAt: string;
reference?: string;
}
export async function readCatalogHotelStatus(): Promise<CatalogHotelStatus | null> {
try {
return JSON.parse(
await fs.readFile(
path.join(catalogStateRoot(), "hotel-status.json"),
"utf8",
),
);
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
throw error;
}
}
/** An acknowledged socket write is an update request, not proof of client application. */
export async function sendCatalogUpdate(): Promise<CatalogHotelStatus> {
let sent = false;
let reference: string | undefined;
try {
sent = await rcon.updateCatalog();
if (!sent)
reference = logger.error(
"Catalog saved; hotel update was not delivered",
{ module: "catalog" },
);
} catch (error) {
reference = logger.error("Catalog saved; hotel update failed", {
module: "catalog",
error,
});
}
const status = { sent, checkedAt: new Date().toISOString(), reference };
const root = catalogStateRoot();
const temp = path.join(root, `hotel-${randomUUID()}.tmp`);
try {
await fs.mkdir(root, { recursive: true });
await fs.writeFile(temp, JSON.stringify(status), { flag: "wx" });
await fs.rename(temp, path.join(root, "hotel-status.json"));
} catch (error) {
logger.error("Cannot persist catalog hotel update status", {
module: "catalog",
error,
});
} finally {
await fs.rm(temp, { force: true }).catch(() => {});
}
return status;
}