refactor(db): typed query helpers, shared test FormData helper
CI / check (push) Successful in 4m10s
CI / preflight (push) Skipped
CI / deploy (push) Successful in 2m4s

Replace raw db.execute tuple casts with queryRows/rowsFrom/execResult/
affectedRows helpers from lib/db, drop redundant mysql2 casts on typed
query builders, and centralize per-test fakeForm into test/fake-form.
Update db mocks in tests so helpers resolve against mocked execute.
This commit is contained in:
openhands committed 2026-09-17 21:02:57 +02:00
1 parent 2e25b39364
commit 8638e81444
116 files changed
+768 -799

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
# http://editorconfig.org
root = true
[*]
indent_style = tab
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.{js,ts,tsx,jsx,json,md}]
indent_style = space
indent_size = 2
+4 -7
View File
@@ -6,6 +6,7 @@ import { requirePermission } from "@/lib/admin/guard";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { ActionError } from "@/lib/safe-action-shared"; import { ActionError } from "@/lib/safe-action-shared";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
import { fakeForm } from "@/test/fake-form";
import { createAd, deleteAd } from "./admin-ads"; import { createAd, deleteAd } from "./admin-ads";
const { insertValues, deleteWhere } = vi.hoisted(() => { const { insertValues, deleteWhere } = vi.hoisted(() => {
@@ -41,10 +42,6 @@ vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
vi.mock("next/navigation", () => ({ redirect: vi.fn() })); vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
const staff = { id: 1, rank: 7, username: "admin" }; const staff = { id: 1, rank: 7, username: "admin" };
const fakeForm = (data: Record<string, string>) => ({
get: (k: string) => data[k] ?? null,
});
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue(staff as never); vi.mocked(requirePermission).mockResolvedValue(staff as never);
@@ -55,7 +52,7 @@ beforeEach(() => {
describe("createAd", () => { describe("createAd", () => {
it("creates ad and redirects", async () => { it("creates ad and redirects", async () => {
await createAd( await createAd(
fakeForm({ image: "https://example.com/ad.png" }) as unknown as FormData, fakeForm({ image: "https://example.com/ad.png" }) as FormData,
); );
expect(insertValues).toHaveBeenCalled(); expect(insertValues).toHaveBeenCalled();
expect(logStaffActivity).toHaveBeenCalled(); expect(logStaffActivity).toHaveBeenCalled();
@@ -63,13 +60,13 @@ describe("createAd", () => {
}); });
it("returns early when image empty", async () => { it("returns early when image empty", async () => {
await createAd(fakeForm({ image: "" }) as unknown as FormData); await createAd(fakeForm({ image: "" }) as FormData);
expect(insertValues).not.toHaveBeenCalled(); expect(insertValues).not.toHaveBeenCalled();
}); });
it("logs error on db failure", async () => { it("logs error on db failure", async () => {
insertValues.mockRejectedValue(new Error("db")); insertValues.mockRejectedValue(new Error("db"));
await createAd(fakeForm({ image: "x" }) as unknown as FormData); await createAd(fakeForm({ image: "x" }) as FormData);
expect(logger.error).toHaveBeenCalled(); expect(logger.error).toHaveBeenCalled();
}); });
}); });
+3 -6
View File
@@ -1,7 +1,6 @@
"use server"; "use server";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { z } from "zod"; import { z } from "zod";
@@ -26,11 +25,11 @@ export async function createAd(formData: FormData): Promise<void> {
const now = new Date(); const now = new Date();
try { try {
const [result] = (await db.insert(WebsiteAds).values({ const [result] = await db.insert(WebsiteAds).values({
image, image,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
})) as unknown as [ResultSetHeader]; });
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "ad_create", action: "ad_create",
@@ -95,9 +94,7 @@ export const deleteAd = adminAction(
async (ctx) => { async (ctx) => {
const id = ctx.data.id; const id = ctx.data.id;
try { try {
const [result] = (await db const [result] = await db.delete(WebsiteAds).where(eq(WebsiteAds.id, id));
.delete(WebsiteAds)
.where(eq(WebsiteAds.id, id))) as unknown as [ResultSetHeader];
if (!result.affectedRows) { if (!result.affectedRows) {
throw new ActionError("Advertisement not found"); throw new ActionError("Advertisement not found");
} }
+3 -8
View File
@@ -3,6 +3,7 @@ import { revalidatePath } from "next/cache";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { rcon } from "@/lib/services/rcon"; import { rcon } from "@/lib/services/rcon";
import { fakeForm } from "@/test/fake-form";
import { sendHotelAlert } from "./admin-alerts"; import { sendHotelAlert } from "./admin-alerts";
vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() })); vi.mock("@/lib/admin/guard", () => ({ requirePermission: vi.fn() }));
@@ -18,10 +19,6 @@ vi.mock("@/lib/db", () => ({
vi.mock("@/lib/services/rcon", () => ({ rcon: { send: vi.fn() } })); vi.mock("@/lib/services/rcon", () => ({ rcon: { send: vi.fn() } }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
const fakeForm = (data: Record<string, string>) => ({
get: (key: string) => data[key] ?? null,
});
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue({ vi.mocked(requirePermission).mockResolvedValue({
@@ -33,15 +30,13 @@ beforeEach(() => {
describe("sendHotelAlert", () => { describe("sendHotelAlert", () => {
it("sends hotel alert and revalidates", async () => { it("sends hotel alert and revalidates", async () => {
await sendHotelAlert( await sendHotelAlert(fakeForm({ message: "Hello!" }) as FormData);
fakeForm({ message: "Hello!" }) as unknown as FormData,
);
expect(rcon.send).toHaveBeenCalledWith("hotelalert", { message: "Hello!" }); expect(rcon.send).toHaveBeenCalledWith("hotelalert", { message: "Hello!" });
expect(revalidatePath).toHaveBeenCalledWith("/admin/alerts"); expect(revalidatePath).toHaveBeenCalledWith("/admin/alerts");
}); });
it("returns early when message is empty", async () => { it("returns early when message is empty", async () => {
await sendHotelAlert(fakeForm({ message: "" }) as unknown as FormData); await sendHotelAlert(fakeForm({ message: "" }) as FormData);
expect(rcon.send).not.toHaveBeenCalled(); expect(rcon.send).not.toHaveBeenCalled();
}); });
}); });
+6 -1
View File
@@ -37,6 +37,7 @@ vi.mock("next/navigation", () => ({
}, },
})); }));
vi.mock("@/lib/db", async () => { vi.mock("@/lib/db", async () => {
const { createDbHelpers } = await import("@/test/db-helpers");
const { MySqlDialect } = await import("drizzle-orm/mysql-core"); const { MySqlDialect } = await import("drizzle-orm/mysql-core");
const dialect = new MySqlDialect(); const dialect = new MySqlDialect();
const database = { const database = {
@@ -85,7 +86,11 @@ vi.mock("@/lib/db", async () => {
transaction: async (fn: (tx: unknown) => unknown): Promise<unknown> => transaction: async (fn: (tx: unknown) => unknown): Promise<unknown> =>
fn(database), fn(database),
}; };
return { ...(await import("@/db/schema")), db: database }; return {
...(await import("@/db/schema")),
...createDbHelpers(database.execute),
db: database,
};
}); });
import { articleEditToken } from "@/lib/article-edit-token"; import { articleEditToken } from "@/lib/article-edit-token";
+4 -5
View File
@@ -18,6 +18,7 @@ import {
} from "@/lib/article-input"; } from "@/lib/article-input";
import { import {
db, db,
rowsFrom,
WebsiteArticleComments, WebsiteArticleComments,
WebsiteArticleReactions, WebsiteArticleReactions,
WebsiteArticles, WebsiteArticles,
@@ -128,11 +129,9 @@ export async function createArticle(
updatedAt: now, updatedAt: now,
publishedAt: fields.status === "published" ? now : null, publishedAt: fields.status === "published" ? now : null,
}); });
const [createdRows] = await tx.execute( const articleId = rowsFrom<{ id: string }>(
sql`SELECT CAST(LAST_INSERT_ID() AS CHAR) AS id`, await tx.execute(sql`SELECT CAST(LAST_INSERT_ID() AS CHAR) AS id`),
); )[0]?.id;
const articleId = (createdRows as unknown as Array<{ id: string }>)[0]
?.id;
if (!articleId || !/^[1-9][0-9]*$/.test(articleId)) if (!articleId || !/^[1-9][0-9]*$/.test(articleId))
throw new Error("Article identity unavailable"); throw new Error("Article identity unavailable");
await enqueueEffect(tx, operationId, "news.refresh"); await enqueueEffect(tx, operationId, "news.refresh");
+3 -6
View File
@@ -3,6 +3,7 @@ import { revalidatePath } from "next/cache";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermissionRateLimited } from "@/lib/admin/guard"; import { requirePermissionRateLimited } from "@/lib/admin/guard";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
import { fakeForm } from "@/test/fake-form";
import { disbandGuild } from "./admin-guilds"; import { disbandGuild } from "./admin-guilds";
const { selectLimit, transactionFn, deleteWhere, updateSet } = vi.hoisted( const { selectLimit, transactionFn, deleteWhere, updateSet } = vi.hoisted(
@@ -42,10 +43,6 @@ vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
const staff = { id: 1, rank: 7, username: "admin" }; const staff = { id: 1, rank: 7, username: "admin" };
const fakeForm = (data: Record<string, string>) => ({
get: (key: string) => data[key] ?? null,
});
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(requirePermissionRateLimited).mockResolvedValue(staff as never); vi.mocked(requirePermissionRateLimited).mockResolvedValue(staff as never);
@@ -79,13 +76,13 @@ describe("disbandGuild", () => {
await fn(tx); await fn(tx);
}, },
); );
await disbandGuild(fakeForm({ id: "1" }) as unknown as FormData); await disbandGuild(fakeForm({ id: "1" }) as FormData);
expect(logStaffActivity).toHaveBeenCalled(); expect(logStaffActivity).toHaveBeenCalled();
expect(revalidatePath).toHaveBeenCalledWith("/admin/guilds"); expect(revalidatePath).toHaveBeenCalledWith("/admin/guilds");
}); });
it("returns early when id is not positive", async () => { it("returns early when id is not positive", async () => {
await disbandGuild(fakeForm({ id: "0" }) as unknown as FormData); await disbandGuild(fakeForm({ id: "0" }) as FormData);
expect(selectLimit).not.toHaveBeenCalled(); expect(selectLimit).not.toHaveBeenCalled();
}); });
}); });
+1 -3
View File
@@ -60,9 +60,7 @@ export const liftBanFromHelpTicket = adminAction(
} }
const result = await db.delete(Ban).where(eq(Ban.userId, ticket.userId)); const result = await db.delete(Ban).where(eq(Ban.userId, ticket.userId));
const removed = Number( const removed = Number(result[0]?.affectedRows ?? 0);
(result as unknown as [{ affectedRows: number }])[0]?.affectedRows ?? 0,
);
const now = new Date(); const now = new Date();
if (ticket.open) { if (ticket.open) {
+4 -7
View File
@@ -1,6 +1,7 @@
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { fakeForm } from "@/test/fake-form";
import { import {
createHelpQuestion, createHelpQuestion,
deleteHelpQuestion, deleteHelpQuestion,
@@ -29,10 +30,6 @@ vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
vi.mock("next/navigation", () => ({ redirect: vi.fn() })); vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
const staff = { id: 1, rank: 7, username: "admin" }; const staff = { id: 1, rank: 7, username: "admin" };
const fakeForm = (data: Record<string, string | null>) => ({
get: (key: string) => (key in data ? data[key] : null),
});
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue(staff as never); vi.mocked(requirePermission).mockResolvedValue(staff as never);
@@ -47,7 +44,7 @@ describe("createHelpQuestion", () => {
fakeForm({ fakeForm({
name: "FAQ", name: "FAQ",
content: "<p>Answer</p>", content: "<p>Answer</p>",
}) as unknown as FormData, }) as FormData,
); );
expect(insertValues).toHaveBeenCalled(); expect(insertValues).toHaveBeenCalled();
expect(redirect).toHaveBeenCalledWith("/admin/help-questions"); expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
@@ -61,7 +58,7 @@ describe("updateHelpQuestion", () => {
id: "42", id: "42",
name: "Updated", name: "Updated",
content: "New", content: "New",
}) as unknown as FormData, }) as FormData,
); );
expect(updateWhere).toHaveBeenCalled(); expect(updateWhere).toHaveBeenCalled();
expect(redirect).toHaveBeenCalledWith("/admin/help-questions"); expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
@@ -70,7 +67,7 @@ describe("updateHelpQuestion", () => {
describe("deleteHelpQuestion", () => { describe("deleteHelpQuestion", () => {
it("deletes and redirects", async () => { it("deletes and redirects", async () => {
await deleteHelpQuestion(fakeForm({ id: "42" }) as unknown as FormData); await deleteHelpQuestion(fakeForm({ id: "42" }) as FormData);
expect(deleteWhere).toHaveBeenCalled(); expect(deleteWhere).toHaveBeenCalled();
expect(redirect).toHaveBeenCalledWith("/admin/help-questions"); expect(redirect).toHaveBeenCalledWith("/admin/help-questions");
}); });
+2 -3
View File
@@ -1,7 +1,6 @@
"use server"; "use server";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
@@ -34,7 +33,7 @@ export async function createHelpQuestion(formData: FormData): Promise<void> {
sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15"; sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15";
try { try {
const [result] = (await db.insert(WebsiteHelpCenterCategories).values({ const [result] = await db.insert(WebsiteHelpCenterCategories).values({
name, name,
content, content,
position: parsePosition(formData.get("position")), position: parsePosition(formData.get("position")),
@@ -44,7 +43,7 @@ export async function createHelpQuestion(formData: FormData): Promise<void> {
buttonColor, buttonColor,
buttonBorderColor, buttonBorderColor,
smallBox: formData.get("smallBox") != null, smallBox: formData.get("smallBox") != null,
})) as unknown as [ResultSetHeader]; });
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "help_create", action: "help_create",
+6 -13
View File
@@ -2,6 +2,7 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { fakeForm } from "@/test/fake-form";
import { import {
addBlacklist, addBlacklist,
addWhitelist, addWhitelist,
@@ -30,10 +31,6 @@ vi.mock("@/lib/db", () => ({
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
const staff = { id: 1, rank: 7, username: "admin" }; const staff = { id: 1, rank: 7, username: "admin" };
const fakeForm = (data: Record<string, string>) => ({
get: (key: string) => data[key] ?? null,
});
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue(staff as never); vi.mocked(requirePermission).mockResolvedValue(staff as never);
@@ -43,9 +40,7 @@ beforeEach(() => {
describe("addWhitelist", () => { describe("addWhitelist", () => {
it("creates whitelist entry", async () => { it("creates whitelist entry", async () => {
await addWhitelist( await addWhitelist(fakeForm({ ipAddress: "192.168.1.1" }) as FormData);
fakeForm({ ipAddress: "192.168.1.1" }) as unknown as FormData,
);
expect(insertValues).toHaveBeenCalledWith({ expect(insertValues).toHaveBeenCalledWith({
ipAddress: "192.168.1.1", ipAddress: "192.168.1.1",
asn: null, asn: null,
@@ -55,23 +50,21 @@ describe("addWhitelist", () => {
}); });
it("returns early when ip is empty", async () => { it("returns early when ip is empty", async () => {
await addWhitelist(fakeForm({ ipAddress: "" }) as unknown as FormData); await addWhitelist(fakeForm({ ipAddress: "" }) as FormData);
expect(insertValues).not.toHaveBeenCalled(); expect(insertValues).not.toHaveBeenCalled();
}); });
}); });
describe("deleteWhitelist", () => { describe("deleteWhitelist", () => {
it("deletes whitelist entry", async () => { it("deletes whitelist entry", async () => {
await deleteWhitelist(fakeForm({ id: "42" }) as unknown as FormData); await deleteWhitelist(fakeForm({ id: "42" }) as FormData);
expect(deleteWhere).toHaveBeenCalled(); expect(deleteWhere).toHaveBeenCalled();
}); });
}); });
describe("addBlacklist", () => { describe("addBlacklist", () => {
it("creates blacklist entry", async () => { it("creates blacklist entry", async () => {
await addBlacklist( await addBlacklist(fakeForm({ ipAddress: "203.0.113.1" }) as FormData);
fakeForm({ ipAddress: "203.0.113.1" }) as unknown as FormData,
);
expect(insertValues).toHaveBeenCalledWith({ expect(insertValues).toHaveBeenCalledWith({
ipAddress: "203.0.113.1", ipAddress: "203.0.113.1",
asn: null, asn: null,
@@ -82,7 +75,7 @@ describe("addBlacklist", () => {
describe("deleteBlacklist", () => { describe("deleteBlacklist", () => {
it("deletes blacklist entry", async () => { it("deletes blacklist entry", async () => {
await deleteBlacklist(fakeForm({ id: "99" }) as unknown as FormData); await deleteBlacklist(fakeForm({ id: "99" }) as FormData);
expect(deleteWhere).toHaveBeenCalled(); expect(deleteWhere).toHaveBeenCalled();
}); });
}); });
+1 -4
View File
@@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files"; import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
import { fakeForm } from "@/test/fake-form";
import { deletePhoto } from "./admin-photos"; import { deletePhoto } from "./admin-photos";
const { select, deleteFn, limit, whereDelete } = vi.hoisted(() => { const { select, deleteFn, limit, whereDelete } = vi.hoisted(() => {
@@ -33,10 +34,6 @@ vi.mock("@/lib/db", () => ({
CameraWeb: { id: "id", url: "url" }, CameraWeb: { id: "id", url: "url" },
})); }));
const fakeForm = (data) => ({
get: (key) => data[key] ?? null,
});
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
limit.mockResolvedValue([{ id: 42, url: "/uploads/cam/42.png" }]); limit.mockResolvedValue([{ id: 42, url: "/uploads/cam/42.png" }]);
+2 -3
View File
@@ -1,7 +1,6 @@
"use server"; "use server";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
@@ -44,7 +43,7 @@ export async function createShopArticle(formData: FormData): Promise<void> {
const now = new Date(); const now = new Date();
const costs = reqUInt(formData, "costs"); const costs = reqUInt(formData, "costs");
try { try {
const [result] = (await db.insert(WebsiteShopArticles).values({ const [result] = await db.insert(WebsiteShopArticles).values({
name, name,
info: String(formData.get("info") ?? "") info: String(formData.get("info") ?? "")
.normalize("NFC") .normalize("NFC")
@@ -71,7 +70,7 @@ export async function createShopArticle(formData: FormData): Promise<void> {
position: reqUInt(formData, "position"), position: reqUInt(formData, "position"),
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
})) as unknown as [ResultSetHeader]; });
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "shop_create", action: "shop_create",
+3 -1
View File
@@ -23,13 +23,15 @@ vi.mock("@/lib/report-error", () => ({ reportError: vi.fn() }));
vi.mock("@/lib/foundation/security", () => ({ vi.mock("@/lib/foundation/security", () => ({
extractClientIpAsync: async () => "127.0.0.1", extractClientIpAsync: async () => "127.0.0.1",
})); }));
vi.mock("@/lib/db", () => { vi.mock("@/lib/db", async () => {
const { createDbHelpers } = await import("@/test/db-helpers");
const execute = async (sql: SQL) => { const execute = async (sql: SQL) => {
const query = new MySqlDialect().sqlToQuery(sql); const query = new MySqlDialect().sqlToQuery(sql);
state.queries.push(query); state.queries.push(query);
return [state.rows]; return [state.rows];
}; };
return { return {
...createDbHelpers(execute),
db: { db: {
execute, execute,
transaction: async (fn: (tx: { execute: typeof execute }) => unknown) => transaction: async (fn: (tx: { execute: typeof execute }) => unknown) =>
+7 -7
View File
@@ -2,7 +2,7 @@
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { namedTableView, tableViewPath } from "@/lib/admin/table-view-state"; import { namedTableView, tableViewPath } from "@/lib/admin/table-view-state";
import { db } from "@/lib/db"; import { db, queryRows, rowsFrom } from "@/lib/db";
import { PERMS } from "@/lib/permission-slugs"; import { PERMS } from "@/lib/permission-slugs";
import { adminAction } from "@/lib/safe-action"; import { adminAction } from "@/lib/safe-action";
@@ -25,11 +25,10 @@ export const listTableViews = adminAction(
schema: z.object({ path: tableViewPath }), schema: z.object({ path: tableViewPath }),
}, },
async ({ session, data }) => { async ({ session, data }) => {
const [rows] = await db.execute(
sql`SELECT name, state FROM website_admin_table_views WHERE user_id = ${Number(session.user.id)} AND path = ${data.path} ORDER BY name LIMIT 20`,
);
const views = ( const views = (
rows as unknown as { name: string; state: string }[] await queryRows<{ name: string; state: string }>(
sql`SELECT name, state FROM website_admin_table_views WHERE user_id = ${Number(session.user.id)} AND path = ${data.path} ORDER BY name LIMIT 20`,
)
).flatMap((row) => { ).flatMap((row) => {
try { try {
const parsed = namedTableView.safeParse({ const parsed = namedTableView.safeParse({
@@ -58,10 +57,11 @@ export const saveTableView = adminAction(
await tx.execute( await tx.execute(
sql`SELECT id FROM users WHERE id = ${userId} FOR UPDATE`, sql`SELECT id FROM users WHERE id = ${userId} FOR UPDATE`,
); );
const [rows] = await tx.execute( const names = rowsFrom<{ name: string }>(
await tx.execute(
sql`SELECT name FROM website_admin_table_views WHERE user_id = ${userId} AND path = ${data.path}`, sql`SELECT name FROM website_admin_table_views WHERE user_id = ${userId} AND path = ${data.path}`,
),
); );
const names = rows as unknown as { name: string }[];
if ( if (
names.length >= 20 && names.length >= 20 &&
!names.some( !names.some(
+10 -13
View File
@@ -3,6 +3,7 @@ import { revalidatePath } from "next/cache";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { logStaffActivity } from "@/lib/services/staff-activity"; import { logStaffActivity } from "@/lib/services/staff-activity";
import { fakeForm } from "@/test/fake-form";
import { createTag, deleteTag, updateTag } from "./admin-tags"; import { createTag, deleteTag, updateTag } from "./admin-tags";
const { insertValues, updateWhere, deleteWhere, transaction } = vi.hoisted( const { insertValues, updateWhere, deleteWhere, transaction } = vi.hoisted(
@@ -35,10 +36,6 @@ vi.mock("@/lib/services/staff-activity", () => ({ logStaffActivity: vi.fn() }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
const staff = { id: 1, rank: 7, username: "admin" }; const staff = { id: 1, rank: 7, username: "admin" };
const fakeForm = (data: Record<string, string>) => ({
get: (key: string) => data[key] ?? null,
});
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue(staff as never); vi.mocked(requirePermission).mockResolvedValue(staff as never);
@@ -58,7 +55,7 @@ describe("createTag", () => {
fakeForm({ fakeForm({
name: "News", name: "News",
backgroundColor: "#ff0000", backgroundColor: "#ff0000",
}) as unknown as FormData, }) as FormData,
); );
expect(insertValues).toHaveBeenCalledWith( expect(insertValues).toHaveBeenCalledWith(
@@ -69,12 +66,12 @@ describe("createTag", () => {
}); });
it("returns early when name is empty", async () => { it("returns early when name is empty", async () => {
await createTag(fakeForm({ name: "" }) as unknown as FormData); await createTag(fakeForm({ name: "" }) as FormData);
expect(insertValues).not.toHaveBeenCalled(); expect(insertValues).not.toHaveBeenCalled();
}); });
it("uses default color when not provided", async () => { it("uses default color when not provided", async () => {
await createTag(fakeForm({ name: "Test" }) as unknown as FormData); await createTag(fakeForm({ name: "Test" }) as FormData);
expect(insertValues).toHaveBeenCalledWith( expect(insertValues).toHaveBeenCalledWith(
expect.objectContaining({ backgroundColor: "#888888" }), expect.objectContaining({ backgroundColor: "#888888" }),
@@ -85,7 +82,7 @@ describe("createTag", () => {
insertValues.mockRejectedValue(new Error("DB error")); insertValues.mockRejectedValue(new Error("DB error"));
await expect( await expect(
createTag(fakeForm({ name: "News" }) as unknown as FormData), createTag(fakeForm({ name: "News" }) as FormData),
).resolves.toBeUndefined(); ).resolves.toBeUndefined();
expect(revalidatePath).toHaveBeenCalledWith("/admin/tags"); expect(revalidatePath).toHaveBeenCalledWith("/admin/tags");
}); });
@@ -98,7 +95,7 @@ describe("updateTag", () => {
id: "42", id: "42",
name: "Updated", name: "Updated",
backgroundColor: "#00ff00", backgroundColor: "#00ff00",
}) as unknown as FormData, }) as FormData,
); );
expect(updateWhere).toHaveBeenCalled(); expect(updateWhere).toHaveBeenCalled();
@@ -107,19 +104,19 @@ describe("updateTag", () => {
}); });
it("returns early when id is invalid", async () => { it("returns early when id is invalid", async () => {
await updateTag(fakeForm({ id: "", name: "Test" }) as unknown as FormData); await updateTag(fakeForm({ id: "", name: "Test" }) as FormData);
expect(updateWhere).not.toHaveBeenCalled(); expect(updateWhere).not.toHaveBeenCalled();
}); });
it("returns early when name is empty after update", async () => { it("returns early when name is empty after update", async () => {
await updateTag(fakeForm({ id: "42", name: "" }) as unknown as FormData); await updateTag(fakeForm({ id: "42", name: "" }) as FormData);
expect(updateWhere).not.toHaveBeenCalled(); expect(updateWhere).not.toHaveBeenCalled();
}); });
}); });
describe("deleteTag", () => { describe("deleteTag", () => {
it("deletes a tag and its taggables", async () => { it("deletes a tag and its taggables", async () => {
await deleteTag(fakeForm({ id: "42" }) as unknown as FormData); await deleteTag(fakeForm({ id: "42" }) as FormData);
expect(transaction).toHaveBeenCalled(); expect(transaction).toHaveBeenCalled();
expect(deleteWhere).toHaveBeenCalled(); expect(deleteWhere).toHaveBeenCalled();
@@ -128,7 +125,7 @@ describe("deleteTag", () => {
}); });
it("returns early when id is invalid", async () => { it("returns early when id is invalid", async () => {
await deleteTag(fakeForm({ id: "" }) as unknown as FormData); await deleteTag(fakeForm({ id: "" }) as FormData);
expect(transaction).not.toHaveBeenCalled(); expect(transaction).not.toHaveBeenCalled();
}); });
}); });
+2 -3
View File
@@ -1,7 +1,6 @@
"use server"; "use server";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { db, Taggables, Tags } from "@/lib/db"; import { db, Taggables, Tags } from "@/lib/db";
@@ -42,12 +41,12 @@ export async function createTag(formData: FormData): Promise<void> {
const now = new Date(); const now = new Date();
try { try {
const [result] = (await db.insert(Tags).values({ const [result] = await db.insert(Tags).values({
name, name,
backgroundColor, backgroundColor,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
})) as unknown as [ResultSetHeader]; });
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "tag_create", action: "tag_create",
+4 -9
View File
@@ -2,6 +2,7 @@
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { fakeForm } from "@/test/fake-form";
import { createTeam, deleteTeam } from "./admin-teams"; import { createTeam, deleteTeam } from "./admin-teams";
const { insertValues, deleteWhere } = vi.hoisted(() => { const { insertValues, deleteWhere } = vi.hoisted(() => {
@@ -22,10 +23,6 @@ vi.mock("@/lib/db", () => ({
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
const staff = { id: 1, rank: 7, username: "admin" }; const staff = { id: 1, rank: 7, username: "admin" };
const fakeForm = (data: Record<string, string | null>) => ({
get: (key: string) => (key in data ? data[key] : null),
});
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue(staff as never); vi.mocked(requirePermission).mockResolvedValue(staff as never);
@@ -35,9 +32,7 @@ beforeEach(() => {
describe("createTeam", () => { describe("createTeam", () => {
it("creates a team entry", async () => { it("creates a team entry", async () => {
await createTeam( await createTeam(fakeForm({ rankName: "Moderator" }) as FormData);
fakeForm({ rankName: "Moderator" }) as unknown as FormData,
);
expect(insertValues).toHaveBeenCalledWith( expect(insertValues).toHaveBeenCalledWith(
expect.objectContaining({ rankName: "Moderator" }), expect.objectContaining({ rankName: "Moderator" }),
); );
@@ -45,14 +40,14 @@ describe("createTeam", () => {
}); });
it("returns early when rankName is empty", async () => { it("returns early when rankName is empty", async () => {
await createTeam(fakeForm({ rankName: "" }) as unknown as FormData); await createTeam(fakeForm({ rankName: "" }) as FormData);
expect(insertValues).not.toHaveBeenCalled(); expect(insertValues).not.toHaveBeenCalled();
}); });
}); });
describe("deleteTeam", () => { describe("deleteTeam", () => {
it("deletes a team entry", async () => { it("deletes a team entry", async () => {
await deleteTeam(fakeForm({ id: "42" }) as unknown as FormData); await deleteTeam(fakeForm({ id: "42" }) as FormData);
expect(deleteWhere).toHaveBeenCalled(); expect(deleteWhere).toHaveBeenCalled();
expect(revalidatePath).toHaveBeenCalledWith("/admin/teams"); expect(revalidatePath).toHaveBeenCalledWith("/admin/teams");
}); });
+2 -3
View File
@@ -1,7 +1,6 @@
"use server"; "use server";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { positiveBigInt } from "@/lib/api"; import { positiveBigInt } from "@/lib/api";
@@ -47,7 +46,7 @@ export async function createVoucher(input: {
const now = new Date(); const now = new Date();
try { try {
const [result] = (await db.insert(WebsiteShopVouchers).values({ const [result] = await db.insert(WebsiteShopVouchers).values({
code, code,
amount: Math.floor(amount), amount: Math.floor(amount),
maxUses, maxUses,
@@ -55,7 +54,7 @@ export async function createVoucher(input: {
expiresAt, expiresAt,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
})) as unknown as [ResultSetHeader]; });
revalidatePath("/admin/vouchers"); revalidatePath("/admin/vouchers");
return actionOk({ id: String(result.insertId) }); return actionOk({ id: String(result.insertId) });
} catch (error) { } catch (error) {
+2 -5
View File
@@ -2,6 +2,7 @@ import { redirect } from "next/navigation";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
import { fakeForm } from "@/test/fake-form";
import { saveVpn } from "./admin-vpn"; import { saveVpn } from "./admin-vpn";
const { mockValues, mockOnDuplicateKeyUpdate } = vi.hoisted(() => { const { mockValues, mockOnDuplicateKeyUpdate } = vi.hoisted(() => {
@@ -30,10 +31,6 @@ vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
vi.mock("next/navigation", () => ({ redirect: vi.fn() })); vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
const staff = { id: 1, rank: 7, username: "admin" }; const staff = { id: 1, rank: 7, username: "admin" };
const fakeForm = (data: Record<string, string | null>) => ({
get: (key: string) => (key in data ? data[key] : null),
});
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(requirePermission).mockResolvedValue(staff as never); vi.mocked(requirePermission).mockResolvedValue(staff as never);
@@ -50,7 +47,7 @@ describe("saveVpn", () => {
vpn_block_enabled: "1", vpn_block_enabled: "1",
vpn_provider: "proxycheck", vpn_provider: "proxycheck",
vpn_api_key: "abc123", vpn_api_key: "abc123",
}) as unknown as FormData, }) as FormData,
); );
expect(mockValues).toHaveBeenCalledTimes(4); expect(mockValues).toHaveBeenCalledTimes(4);
expect(mockOnDuplicateKeyUpdate).toHaveBeenCalledTimes(4); expect(mockOnDuplicateKeyUpdate).toHaveBeenCalledTimes(4);
+1 -4
View File
@@ -1,7 +1,6 @@
"use server"; "use server";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteWordfilter } from "@/lib/db"; import { db, WebsiteWordfilter } from "@/lib/db";
@@ -25,9 +24,7 @@ export async function addWord(input: {
if (!word) return actionError("Word is required"); if (!word) return actionError("Word is required");
try { try {
const [result] = (await db const [result] = await db.insert(WebsiteWordfilter).values({ word });
.insert(WebsiteWordfilter)
.values({ word })) as unknown as [ResultSetHeader];
reloadWordFilter(); reloadWordFilter();
await rcon.updateWordFilter(); await rcon.updateWordFilter();
revalidatePath("/admin/wordfilter"); revalidatePath("/admin/wordfilter");
+2 -3
View File
@@ -1,7 +1,6 @@
"use server"; "use server";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { db, WebsiteWriteableBoxes } from "@/lib/db"; import { db, WebsiteWriteableBoxes } from "@/lib/db";
@@ -53,7 +52,7 @@ export async function createBox(formData: FormData): Promise<void> {
const now = new Date(); const now = new Date();
try { try {
const [result] = (await db.insert(WebsiteWriteableBoxes).values({ const [result] = await db.insert(WebsiteWriteableBoxes).values({
title, title,
icon: icon:
String(formData.get("icon") ?? "") String(formData.get("icon") ?? "")
@@ -65,7 +64,7 @@ export async function createBox(formData: FormData): Promise<void> {
isActive: String(formData.get("isActive") ?? "").normalize("NFC") === "1", isActive: String(formData.get("isActive") ?? "").normalize("NFC") === "1",
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
})) as unknown as [ResultSetHeader]; });
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "writeable_box_create", action: "writeable_box_create",
+1 -4
View File
@@ -3,6 +3,7 @@ import { redirect } from "next/navigation";
import { beforeEach, describe, expect, it, vi } from "vitest"; import { beforeEach, describe, expect, it, vi } from "vitest";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { clientIp, rateLimit } from "@/lib/rate-limit"; import { clientIp, rateLimit } from "@/lib/rate-limit";
import { fakeForm } from "@/test/fake-form";
import { applyStaff, applyTeam } from "./applications"; import { applyStaff, applyTeam } from "./applications";
const { selectLimit, insertValues } = vi.hoisted(() => { const { selectLimit, insertValues } = vi.hoisted(() => {
@@ -33,10 +34,6 @@ vi.mock("@/lib/rate-limit", () => ({ clientIp: vi.fn(), rateLimit: vi.fn() }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
vi.mock("next/navigation", () => ({ redirect: vi.fn() })); vi.mock("next/navigation", () => ({ redirect: vi.fn() }));
const fakeForm = (data) => ({
get: (key) => data[key] ?? null,
});
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
vi.mocked(auth).mockResolvedValue({ user: { id: "42" } }); vi.mocked(auth).mockResolvedValue({ user: { id: "42" } });
+1 -4
View File
@@ -1,7 +1,6 @@
"use server"; "use server";
import { eq } from "drizzle-orm"; import { eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { z } from "zod"; import { z } from "zod";
import { db, WebsiteBanner } from "@/lib/db"; import { db, WebsiteBanner } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
@@ -24,9 +23,7 @@ const bannerSchema = z.object({
export const createBanner = adminAction( export const createBanner = adminAction(
{ permission: PERMS.BANNERS_EDIT, schema: bannerSchema }, { permission: PERMS.BANNERS_EDIT, schema: bannerSchema },
async (ctx) => { async (ctx) => {
const [result] = (await db const [result] = await db.insert(WebsiteBanner).values(ctx.data);
.insert(WebsiteBanner)
.values(ctx.data)) as unknown as [ResultSetHeader];
const id = Number(result.insertId); const id = Number(result.insertId);
logAudit({ logAudit({
userId: ctx.session.user.id, userId: ctx.session.user.id,
+1 -3
View File
@@ -23,9 +23,7 @@ export async function bulkUnban({
}): Promise<ActionResult<{ unbanned: number; total: number }>> { }): Promise<ActionResult<{ unbanned: number; total: number }>> {
const staff = await requirePermission(PERMS.USERS_EDIT); const staff = await requirePermission(PERMS.USERS_EDIT);
const result = await db.delete(Ban).where(inArray(Ban.userId, userIds)); const result = await db.delete(Ban).where(inArray(Ban.userId, userIds));
const unbanned = Number( const unbanned = Number(result[0]?.affectedRows ?? 0);
(result as unknown as [{ affectedRows: number }])[0]?.affectedRows ?? 0,
);
await logStaffActivity({ await logStaffActivity({
staffId: staff.id, staffId: staff.id,
action: "bulk_unban", action: "bulk_unban",
+4 -4
View File
@@ -11,7 +11,7 @@ import {
} from "@/features/catalog/server/offer-commands"; } from "@/features/catalog/server/offer-commands";
import { sendCatalogUpdate } from "@/features/catalog/server/sync-status"; import { sendCatalogUpdate } from "@/features/catalog/server/sync-status";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { CatalogItems, db, ItemsBase } from "@/lib/db"; import { CatalogItems, db, ItemsBase, queryRows } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit"; import { logAudit } from "@/lib/services/audit";
import { withCatalogExport } from "@/lib/services/catalog-git-queue"; import { withCatalogExport } from "@/lib/services/catalog-git-queue";
@@ -145,10 +145,10 @@ export async function bulkCreateCatalogItems({
let failed = 0; let failed = 0;
// Find the next order number on the target page to avoid collisions. // Find the next order number on the target page to avoid collisions.
const [maxOrder] = (await db.execute(sql` const maxOrder = await queryRows<{ maxOrder: number }>(sql`
SELECT COALESCE(MAX(order_number), 0) AS maxOrder FROM catalog_items WHERE page_id = ${String(pageId)} SELECT COALESCE(MAX(order_number), 0) AS maxOrder FROM catalog_items WHERE page_id = ${String(pageId)}
`)) as unknown as [{ maxOrder: number }, unknown]; `);
let nextOrder = Number(maxOrder?.maxOrder ?? 0) + 1; let nextOrder = Number(maxOrder[0]?.maxOrder ?? 0) + 1;
for (const row of rows) { for (const row of rows) {
const base = baseMap.get(row.baseId); const base = baseMap.get(row.baseId);
+11 -11
View File
@@ -13,7 +13,7 @@ import {
} from "@/features/catalog/server/page-commands"; } from "@/features/catalog/server/page-commands";
import { sendCatalogUpdate } from "@/features/catalog/server/sync-status"; import { sendCatalogUpdate } from "@/features/catalog/server/sync-status";
import { requirePermission } from "@/lib/admin/guard"; import { requirePermission } from "@/lib/admin/guard";
import { db, ItemsBase } from "@/lib/db"; import { db, ItemsBase, queryRows } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import type { ActionResult } from "@/lib/safe-action-shared"; import type { ActionResult } from "@/lib/safe-action-shared";
import { withCatalogExport } from "@/lib/services/catalog-git-queue"; import { withCatalogExport } from "@/lib/services/catalog-git-queue";
@@ -290,10 +290,10 @@ export async function createAutoCategory(input: {
.where(inArray(ItemsBase.id, baseIds)); .where(inArray(ItemsBase.id, baseIds));
const baseMap = new Map(bases.map((b) => [b.id, b])); const baseMap = new Map(bases.map((b) => [b.id, b]));
const [maxOrder] = (await db.execute(sql` const maxOrder = await queryRows<{ maxOrder: number }>(sql`
SELECT COALESCE(MAX(order_number), 0) AS maxOrder FROM catalog_items WHERE page_id = ${String(pageId)} SELECT COALESCE(MAX(order_number), 0) AS maxOrder FROM catalog_items WHERE page_id = ${String(pageId)}
`)) as unknown as [{ maxOrder: number }, unknown]; `);
let nextOrder = Number(maxOrder?.maxOrder ?? 0) + 1; let nextOrder = Number(maxOrder[0]?.maxOrder ?? 0) + 1;
let created = 0; let created = 0;
for (const row of rows) { for (const row of rows) {
@@ -455,9 +455,9 @@ export async function organizeImportFurni(input: {
); );
let destinationCaption: string | null = null; let destinationCaption: string | null = null;
if (destinationPageId) { if (destinationPageId) {
const [captionRows] = (await db.execute(sql` const captionRows = await queryRows<{ caption: string }>(sql`
SELECT caption FROM catalog_pages WHERE id = ${destinationPageId} SELECT caption FROM catalog_pages WHERE id = ${destinationPageId}
`)) as unknown as [Array<{ caption: string }>, unknown]; `);
destinationCaption = captionRows[0]?.caption ?? null; destinationCaption = captionRows[0]?.caption ?? null;
} }
const caption = const caption =
@@ -465,11 +465,11 @@ export async function organizeImportFurni(input: {
// Existing destinations append after their current offers; freshly // Existing destinations append after their current offers; freshly
// created pages start at order 1. // created pages start at order 1.
const [maxOrder] = (await db.execute(sql` const maxOrder = await queryRows<{ maxOrder: number }>(sql`
SELECT COALESCE(MAX(order_number), 0) AS maxOrder SELECT COALESCE(MAX(order_number), 0) AS maxOrder
FROM catalog_items WHERE page_id = ${pageId} FROM catalog_items WHERE page_id = ${pageId}
`)) as unknown as [{ maxOrder: number }, unknown]; `);
let nextOrder = Number(maxOrder?.maxOrder ?? 0) + 1; let nextOrder = Number(maxOrder[0]?.maxOrder ?? 0) + 1;
const baseIds = [...new Set(group.items.map((r) => r.itemId))]; const baseIds = [...new Set(group.items.map((r) => r.itemId))];
const bases = await db const bases = await db
@@ -493,12 +493,12 @@ export async function organizeImportFurni(input: {
]; ];
const validMoveIds = new Set<number>(); const validMoveIds = new Set<number>();
if (moverIds.length > 0) { if (moverIds.length > 0) {
const [hostRows] = (await db.execute(sql` const hostRows = await queryRows<{ id: number; page_id: number }>(sql`
SELECT id, page_id FROM catalog_items WHERE id IN (${sql.join( SELECT id, page_id FROM catalog_items WHERE id IN (${sql.join(
moverIds.map((id) => sql`${id}`), moverIds.map((id) => sql`${id}`),
sql`, `, sql`, `,
)}) )})
`)) as unknown as [Array<{ id: number; page_id: number }>, unknown]; `);
for (const row of hostRows) { for (const row of hostRows) {
if (importPageIds.has(Number(row.page_id))) { if (importPageIds.has(Number(row.page_id))) {
validMoveIds.add(Number(row.id)); validMoveIds.add(Number(row.id));
+2 -3
View File
@@ -3,7 +3,7 @@
import { eq, sql } from "drizzle-orm"; import { eq, sql } from "drizzle-orm";
import { revalidatePath } from "next/cache"; import { revalidatePath } from "next/cache";
import { z } from "zod"; import { z } from "zod";
import { db, User } from "@/lib/db"; import { db, queryRows, User } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { adminAction } from "@/lib/safe-action"; import { adminAction } from "@/lib/safe-action";
import { ActionError, actionOk } from "@/lib/safe-action-shared"; import { ActionError, actionOk } from "@/lib/safe-action-shared";
@@ -209,10 +209,9 @@ export const setRank = adminAction(
let rankExists: { id: number }[] = []; let rankExists: { id: number }[] = [];
try { try {
const [rows] = await db.execute( rankExists = await queryRows<{ id: number }>(
sql`SELECT id FROM permission_ranks WHERE id = ${ctx.data.rank} LIMIT 1`, sql`SELECT id FROM permission_ranks WHERE id = ${ctx.data.rank} LIMIT 1`,
); );
rankExists = rows as unknown as { id: number }[];
} catch { } catch {
rankExists = []; rankExists = [];
} }
+1 -4
View File
@@ -297,10 +297,7 @@ export async function removeFriendship(formData: FormData): Promise<void> {
), ),
); );
return Number( return Number(result[0]?.affectedRows ?? 0);
(result as unknown as [{ affectedRows: number }])[0]
?.affectedRows ?? 0,
);
}); });
outcome = deleted > 0 ? "removed" : "not_found"; outcome = deleted > 0 ? "removed" : "not_found";
+4 -2
View File
@@ -16,7 +16,8 @@ vi.mock("next/navigation", () => ({
}, },
})); }));
vi.mock("@/lib/db", () => ({ vi.mock("@/lib/db", () => {
return {
db: { db: {
select: vi.fn(() => ({ select: vi.fn(() => ({
from: vi.fn(() => ({ from: vi.fn(() => ({
@@ -33,7 +34,8 @@ vi.mock("@/lib/db", () => ({
}, },
User: { id: "id", mail: "mail" }, User: { id: "id", mail: "mail" },
PasswordReset: { email: "email", token: "token", createdAt: "createdAt" }, PasswordReset: { email: "email", token: "token", createdAt: "createdAt" },
})); };
});
vi.mock("@/lib/services/email", () => ({ vi.mock("@/lib/services/email", () => ({
sendMail: mockSendMail, sendMail: mockSendMail,
+3 -4
View File
@@ -1,7 +1,6 @@
"use server"; "use server";
import { and, count, eq, inArray, sql } from "drizzle-orm"; import { and, count, eq, inArray, sql } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { revalidateTag } from "next/cache"; import { revalidateTag } from "next/cache";
import { z } from "zod"; import { z } from "zod";
import { import {
@@ -254,9 +253,9 @@ export const repairAdminNavAclGrants = adminAction(
`); `);
const inserted = const inserted =
Number((dashboardFillResult as ResultSetHeader).affectedRows) + Number(dashboardFillResult.affectedRows) +
Number((midRankViewsResult as ResultSetHeader).affectedRows) + Number(midRankViewsResult.affectedRows) +
Number((highRankToolsResult as ResultSetHeader).affectedRows); Number(highRankToolsResult.affectedRows);
await logStaffActivity({ await logStaffActivity({
staffId: ctx.session.user.id, staffId: ctx.session.user.id,
+5 -7
View File
@@ -79,7 +79,8 @@ const hoistedUpdateSet = vi.hoisted(() =>
vi.fn(() => ({ where: hoistedUpdateWhere })), vi.fn(() => ({ where: hoistedUpdateWhere })),
); );
vi.mock("@/lib/db", () => ({ vi.mock("@/lib/db", () => {
return {
db: { db: {
select: vi.fn(() => ({ from: hoistedSelectFrom })), select: vi.fn(() => ({ from: hoistedSelectFrom })),
insert: vi.fn(() => ({ values: hoistedInsertValues })), insert: vi.fn(() => ({ values: hoistedInsertValues })),
@@ -91,20 +92,17 @@ vi.mock("@/lib/db", () => ({
twoFactorConfirmedAt: "user.twoFactorConfirmedAt", twoFactorConfirmedAt: "user.twoFactorConfirmedAt",
twoFactorRecoveryCodes: "user.twoFactorRecoveryCodes", twoFactorRecoveryCodes: "user.twoFactorRecoveryCodes",
}, },
})); };
});
import { db, User } from "@/lib/db"; import { db, User } from "@/lib/db";
import { fakeForm } from "@/test/fake-form";
import { import {
beginTwoFactor, beginTwoFactor,
confirmTwoFactor, confirmTwoFactor,
disableTwoFactor, disableTwoFactor,
} from "./twofactor"; } from "./twofactor";
const fakeForm = (data: Record<string, string>) =>
({
get: (key: string) => data[key] ?? null,
}) as unknown as FormData;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockAuth.mockResolvedValue({ user: { id: "42" } }); mockAuth.mockResolvedValue({ user: { id: "42" } });
+2 -5
View File
@@ -6,6 +6,8 @@ vi.mock("next/server", () => ({
}, },
})); }));
import { fakeForm } from "@/test/fake-form";
vi.mock("next-auth", () => ({ vi.mock("next-auth", () => ({
default: vi.fn(() => ({ default: vi.fn(() => ({
handlers: {}, handlers: {},
@@ -82,11 +84,6 @@ vi.mock("next/cache", () => ({
import { db } from "@/lib/db"; import { db } from "@/lib/db";
import { redeem } from "./voucher"; import { redeem } from "./voucher";
const fakeForm = (data: Record<string, string>) =>
({
get: (key: string) => data[key] ?? null,
}) as unknown as FormData;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockAuth.mockResolvedValue({ user: { id: "1" } }); mockAuth.mockResolvedValue({ user: { id: "1" } });
+3 -3
View File
@@ -3,7 +3,7 @@ import { getTranslations } from "next-intl/server";
import Link from "@/components/link"; import Link from "@/components/link";
import { ContentCard, EmptyState, RankBadge } from "@/components/public/ui"; import { ContentCard, EmptyState, RankBadge } from "@/components/public/ui";
import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail"; import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail";
import { db } from "@/lib/db"; import { queryRows } from "@/lib/db";
type LeaderRow = { type LeaderRow = {
username: string; username: string;
@@ -15,13 +15,13 @@ async function loadRows(): Promise<
{ username: string; look: string; points: number }[] { username: string; look: string; points: number }[]
> { > {
try { try {
const [rows] = (await db.execute(sql` const rows = await queryRows<LeaderRow>(sql`
SELECT u.username AS username, u.look AS look, p.points AS points SELECT u.username AS username, u.look AS look, p.points AS points
FROM radio_listener_points p FROM radio_listener_points p
INNER JOIN users u ON u.id = p.user_id INNER JOIN users u ON u.id = p.user_id
ORDER BY p.points DESC ORDER BY p.points DESC
LIMIT 50 LIMIT 50
`)) as unknown as [LeaderRow[], unknown]; `);
return (rows ?? []).map((r) => ({ return (rows ?? []).map((r) => ({
username: r.username, username: r.username,
look: r.look, look: r.look,
+3 -4
View File
@@ -4,7 +4,7 @@ import { getTranslations } from "next-intl/server";
import { signOutEverywhere } from "@/actions/sessions"; import { signOutEverywhere } from "@/actions/sessions";
import { ContentCard, EmptyState } from "@/components/public/ui"; import { ContentCard, EmptyState } from "@/components/public/ui";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { db, WebsiteLoginLogs } from "@/lib/db"; import { db, queryRows, WebsiteLoginLogs } from "@/lib/db";
import { formatDate } from "@/lib/format-date"; import { formatDate } from "@/lib/format-date";
type SearchParams = Promise<{ signedOutAll?: string }>; type SearchParams = Promise<{ signedOutAll?: string }>;
@@ -69,11 +69,10 @@ export default async function SessionsPage({
type Failed = { id: number; ip_address: string; attempted_at: Date | string }; type Failed = { id: number; ip_address: string; attempted_at: Date | string };
let failed: Failed[] = []; let failed: Failed[] = [];
try { try {
const [rows] = (await db.execute(sql` failed = await queryRows<Failed>(sql`
SELECT id, ip_address, attempted_at FROM failed_logins SELECT id, ip_address, attempted_at FROM failed_logins
WHERE username = ${session.user.name} ORDER BY id DESC LIMIT 15 WHERE username = ${session.user.name} ORDER BY id DESC LIMIT 15
`)) as unknown as [Failed[], unknown]; `);
failed = rows ?? [];
} catch { } catch {
failed = []; failed = [];
} }
+2 -3
View File
@@ -4,7 +4,7 @@ import { redirect } from "next/navigation";
import { getLocale, getTranslations } from "next-intl/server"; import { getLocale, getTranslations } from "next-intl/server";
import { StatsCard } from "@/components/admin/stats-card"; import { StatsCard } from "@/components/admin/stats-card";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Ban, db, User } from "@/lib/db"; import { Ban, db, queryRows, User } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { redisCache } from "@/lib/redis-cache"; import { redisCache } from "@/lib/redis-cache";
@@ -36,8 +36,7 @@ async function safeCount(
} }
async function rawRows<T>(query: ReturnType<typeof sql>): Promise<T[]> { async function rawRows<T>(query: ReturnType<typeof sql>): Promise<T[]> {
const [rows] = (await db.execute(query)) as unknown as [T[], unknown]; return queryRows<T>(query);
return rows ?? [];
} }
async function safeRawCount( async function safeRawCount(
+2 -2
View File
@@ -10,6 +10,7 @@ import {
db, db,
LogsShopPurchases, LogsShopPurchases,
MarketplaceItems, MarketplaceItems,
queryRows,
RoomTradeLog, RoomTradeLog,
User, User,
} from "@/lib/db"; } from "@/lib/db";
@@ -63,8 +64,7 @@ async function safeCount(fn: () => Promise<number>): Promise<number> {
} }
async function rawRows<T>(query: ReturnType<typeof sql>): Promise<T[]> { async function rawRows<T>(query: ReturnType<typeof sql>): Promise<T[]> {
const [rows] = (await db.execute(query)) as unknown as [T[], unknown]; return queryRows<T>(query);
return rows ?? [];
} }
async function getEconomyData( async function getEconomyData(
+2 -3
View File
@@ -12,7 +12,7 @@ import { redirect } from "next/navigation";
import { getLocale, getTranslations } from "next-intl/server"; import { getLocale, getTranslations } from "next-intl/server";
import { StatsCard } from "@/components/admin/stats-card"; import { StatsCard } from "@/components/admin/stats-card";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Ban, db, Rooms, RoomTradeLog, User } from "@/lib/db"; import { Ban, db, queryRows, Rooms, RoomTradeLog, User } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { redisCache } from "@/lib/redis-cache"; import { redisCache } from "@/lib/redis-cache";
@@ -82,8 +82,7 @@ async function safeCount(
} }
async function rawRows<T>(query: ReturnType<typeof sql>): Promise<T[]> { async function rawRows<T>(query: ReturnType<typeof sql>): Promise<T[]> {
const [rows] = (await db.execute(query)) as unknown as [T[], unknown]; return queryRows<T>(query);
return rows ?? [];
} }
async function getAnalyticsData( async function getAnalyticsData(
+2 -3
View File
@@ -1,7 +1,7 @@
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { calcPagination, parseListParams } from "@/lib/admin-helpers"; import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import { db } from "@/lib/db"; import { queryRows } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
type ListParamsInput = { type ListParamsInput = {
@@ -37,8 +37,7 @@ function parsePageParams(
} }
async function rawRows<T>(query: SqlFragment): Promise<T[]> { async function rawRows<T>(query: SqlFragment): Promise<T[]> {
const [rows] = (await db.execute(query)) as unknown as [T[], unknown]; return queryRows<T>(query);
return rows ?? [];
} }
/** /**
+9 -4
View File
@@ -2,7 +2,13 @@ import { asc, inArray, sql } from "drizzle-orm";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import { StatusCard } from "@/components/admin/dashboard"; import { StatusCard } from "@/components/admin/dashboard";
import { db, NavigatorFlatcats, NavigatorPubliccats, Rooms } from "@/lib/db"; import {
db,
NavigatorFlatcats,
NavigatorPubliccats,
queryRows,
Rooms,
} from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
// navigator_flatcats — emulator-owned flat (private-room) categories shown in the // navigator_flatcats — emulator-owned flat (private-room) categories shown in the
@@ -65,12 +71,11 @@ export default async function AdminNavigation() {
let publics: PublicRow[] = []; let publics: PublicRow[] = [];
try { try {
const [rows] = (await db.execute(sql` publics = await queryRows<PublicRow>(sql`
SELECT public_cat_id, room_id, visible SELECT public_cat_id, room_id, visible
FROM navigator_publics FROM navigator_publics
ORDER BY public_cat_id ASC, room_id ASC ORDER BY public_cat_id ASC, room_id ASC
`)) as unknown as [PublicRow[], unknown]; `);
publics = rows ?? [];
} catch { } catch {
publics = []; publics = [];
} }
+3 -3
View File
@@ -2,7 +2,7 @@ import { sql } from "drizzle-orm";
import { redirect } from "next/navigation"; import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server"; import { getTranslations } from "next-intl/server";
import Link from "@/components/link"; import Link from "@/components/link";
import { db } from "@/lib/db"; import { db, queryRows } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
import { fetchEmulatorRankSummaries } from "@/lib/services/permission-ranks"; import { fetchEmulatorRankSummaries } from "@/lib/services/permission-ranks";
import { PermissionsList } from "./permissions-list"; import { PermissionsList } from "./permissions-list";
@@ -25,9 +25,9 @@ export default async function PermissionsPage({
const ranks = await fetchEmulatorRankSummaries(db); const ranks = await fetchEmulatorRankSummaries(db);
// User counts per rank // User counts per rank
const [rawCounts] = (await db.execute( const rawCounts = await queryRows<{ rank: number; cnt: number }>(
sql`SELECT \`rank\`, COUNT(*) as cnt FROM users GROUP BY \`rank\``, sql`SELECT \`rank\`, COUNT(*) as cnt FROM users GROUP BY \`rank\``,
)) as unknown as [{ rank: number; cnt: number }[], unknown[]]; );
const countMap: Record<number, number> = {}; const countMap: Record<number, number> = {};
for (const row of rawCounts) { for (const row of rawCounts) {
countMap[Number(row.rank)] = Number(row.cnt); countMap[Number(row.rank)] = Number(row.cnt);
@@ -2,7 +2,10 @@ import { renderToStaticMarkup } from "react-dom/server";
import { beforeEach, expect, it, vi } from "vitest"; import { beforeEach, expect, it, vi } from "vitest";
const state = vi.hoisted(() => ({ access: vi.fn(), execute: vi.fn() })); const state = vi.hoisted(() => ({ access: vi.fn(), execute: vi.fn() }));
vi.mock("@/lib/db", () => ({ db: { execute: state.execute } })); vi.mock("@/lib/db", async () => ({
...(await import("@/test/db-helpers")).createDbHelpers(state.execute),
db: { execute: state.execute },
}));
vi.mock("@/lib/permissions", () => ({ vi.mock("@/lib/permissions", () => ({
PERMS: { PERMISSIONS_MANAGE: "admin.permissions.manage" }, PERMS: { PERMISSIONS_MANAGE: "admin.permissions.manage" },
getAdminContext: async () => ({ getAdminContext: async () => ({
+9 -17
View File
@@ -4,7 +4,7 @@ import { getTranslations } from "next-intl/server";
import Link from "@/components/link"; import Link from "@/components/link";
import { isDynamicSuperAdmin } from "@/lib/admin/authorization-policy"; import { isDynamicSuperAdmin } from "@/lib/admin/authorization-policy";
import { previewRoleAccess } from "@/lib/admin/permission-preview"; import { previewRoleAccess } from "@/lib/admin/permission-preview";
import { db } from "@/lib/db"; import { queryRows } from "@/lib/db";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
export default async function PermissionPreview({ export default async function PermissionPreview({
@@ -17,14 +17,11 @@ export default async function PermissionPreview({
redirect("/admin"); redirect("/admin");
const t = await getTranslations("pages.admin.permissionPreview"); const t = await getTranslations("pages.admin.permissionPreview");
const nav = await getTranslations("pages.admin.nav"); const nav = await getTranslations("pages.admin.nav");
const [roleRows] = await db.execute( const roles = await queryRows<{
sql`SELECT id, slug, title FROM acl_roles ORDER BY title, id`,
);
const roles = roleRows as unknown as {
id: number; id: number;
slug: string; slug: string;
title: string; title: string;
}[]; }>(sql`SELECT id, slug, title FROM acl_roles ORDER BY title, id`);
const params = await searchParams; const params = await searchParams;
const selected = const selected =
roles.find((r) => String(r.id) === params.role) ?? roles.find((r) => String(r.id) === params.role) ??
@@ -33,15 +30,13 @@ export default async function PermissionPreview({
let superAdmin = false; let superAdmin = false;
let permissionTitles: Record<string, string> = {}; let permissionTitles: Record<string, string> = {};
if (selected) { if (selected) {
const [grantRows] = await db.execute( const grants = await queryRows<{ slug: string; title: string }>(
sql`SELECT p.slug, p.title FROM acl_model_permissions mp JOIN acl_permissions p ON p.id = mp.permission_id WHERE mp.model_type = 'Role' AND mp.model_id = ${selected.id}`, sql`SELECT p.slug, p.title FROM acl_model_permissions mp JOIN acl_permissions p ON p.id = mp.permission_id WHERE mp.model_type = 'Role' AND mp.model_id = ${selected.id}`,
); );
const grants = grantRows as unknown as { slug: string; title: string }[];
const [highestRows] = await db.execute(
sql`SELECT COALESCE((SELECT MAX(u.\`rank\`) FROM users u INNER JOIN permission_ranks pr ON pr.id = u.\`rank\`), (SELECT MAX(id) FROM permission_ranks)) AS highest_rank`,
);
const highest = ( const highest = (
highestRows as unknown as { highest_rank: number | null }[] await queryRows<{ highest_rank: number | null }>(
sql`SELECT COALESCE((SELECT MAX(u.\`rank\`) FROM users u INNER JOIN permission_ranks pr ON pr.id = u.\`rank\`), (SELECT MAX(id) FROM permission_ranks)) AS highest_rank`,
)
)[0]?.highest_rank; )[0]?.highest_rank;
const rankMatch = /^rank_([1-9]\d*)$/.exec(selected.slug); const rankMatch = /^rank_([1-9]\d*)$/.exec(selected.slug);
superAdmin = superAdmin =
@@ -54,14 +49,11 @@ export default async function PermissionPreview({
grants.map((g) => g.slug), grants.map((g) => g.slug),
superAdmin, superAdmin,
); );
const [allRows] = await db.execute( const allRows = await queryRows<{ slug: string; title: string }>(
sql`SELECT slug, title FROM acl_permissions ORDER BY slug`, sql`SELECT slug, title FROM acl_permissions ORDER BY slug`,
); );
permissionTitles = Object.fromEntries( permissionTitles = Object.fromEntries(
(allRows as unknown as { slug: string; title: string }[]).map((p) => [ allRows.map((p) => [p.slug, p.title]),
p.slug,
p.title,
]),
); );
} }
return ( return (
+2 -3
View File
@@ -4,7 +4,7 @@ import { getTranslations } from "next-intl/server";
import { AdminSimplePager } from "@/components/admin/admin-simple-pager"; import { AdminSimplePager } from "@/components/admin/admin-simple-pager";
import { StatusCard } from "@/components/admin/dashboard"; import { StatusCard } from "@/components/admin/dashboard";
import { calcPagination, parseListParams } from "@/lib/admin-helpers"; import { calcPagination, parseListParams } from "@/lib/admin-helpers";
import { db } from "@/lib/db"; import { queryRows } from "@/lib/db";
import { formatDate } from "@/lib/format-date"; import { formatDate } from "@/lib/format-date";
import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions";
@@ -24,8 +24,7 @@ type SubSqlRow = {
}; };
async function rawRows<T>(query: ReturnType<typeof sql>): Promise<T[]> { async function rawRows<T>(query: ReturnType<typeof sql>): Promise<T[]> {
const [rows] = (await db.execute(query)) as unknown as [T[], unknown]; return queryRows<T>(query);
return rows ?? [];
} }
export default async function AdminSubscriptions({ export default async function AdminSubscriptions({
@@ -7,7 +7,10 @@ const state = vi.hoisted(() => ({
allowed: new Set<string>(), allowed: new Set<string>(),
execute: vi.fn(), execute: vi.fn(),
})); }));
vi.mock("@/lib/db", () => ({ db: { execute: state.execute } })); vi.mock("@/lib/db", async () => ({
...(await import("@/test/db-helpers")).createDbHelpers(state.execute),
db: { execute: state.execute },
}));
vi.mock("@/lib/permissions", async () => ({ vi.mock("@/lib/permissions", async () => ({
...(await import("@/lib/permission-slugs")), ...(await import("@/lib/permission-slugs")),
canAccess: (_: unknown, permission: string) => state.allowed.has(permission), canAccess: (_: unknown, permission: string) => state.allowed.has(permission),
@@ -1,5 +1,5 @@
import { type SQL, sql } from "drizzle-orm"; import { type SQL, sql } from "drizzle-orm";
import { db } from "@/lib/db"; import { queryRows } from "@/lib/db";
import { canAccess, PERMS, type PermissionSet } from "@/lib/permissions"; import { canAccess, PERMS, type PermissionSet } from "@/lib/permissions";
export type OverviewRow = { export type OverviewRow = {
@@ -27,8 +27,7 @@ export async function loadUserOverview(
rank: number, rank: number,
): Promise<OverviewSection[]> { ): Promise<OverviewSection[]> {
const read = async (query: SQL): Promise<OverviewRow[]> => { const read = async (query: SQL): Promise<OverviewRow[]> => {
const [rows] = await db.execute(query); return queryRows<OverviewRow>(query);
return rows as unknown as OverviewRow[];
}; };
const sources: { const sources: {
key: string; key: string;
+7 -5
View File
@@ -11,15 +11,13 @@ vi.mock("next/navigation", () => ({
throw new Error("not found"); throw new Error("not found");
}, },
})); }));
vi.mock("@/lib/db", () => { vi.mock("@/lib/db", async () => {
const tables = Object.fromEntries( const tables = Object.fromEntries(
["User", "Rooms", "RoomTradeLog", "UsersBadges", "UsersCurrency"].map( ["User", "Rooms", "RoomTradeLog", "UsersBadges", "UsersCurrency"].map(
(name) => [name, { name }], (name) => [name, { name }],
), ),
); );
return { const db = {
...tables,
db: {
select() { select() {
let name = ""; let name = "";
const rows = () => const rows = () =>
@@ -58,7 +56,11 @@ vi.mock("@/lib/db", () => {
throw new Error("unavailable"); throw new Error("unavailable");
return [[], []]; return [[], []];
}, },
}, };
return {
...tables,
db,
...(await import("@/test/db-helpers")).createDbHelpers(db.execute),
}; };
}); });
+2 -2
View File
@@ -2,6 +2,7 @@ import { asc, count, desc, eq, or, sql } from "drizzle-orm";
import { notFound } from "next/navigation"; import { notFound } from "next/navigation";
import { import {
db, db,
queryRows,
Rooms, Rooms,
RoomTradeLog, RoomTradeLog,
User, User,
@@ -10,8 +11,7 @@ import {
} from "@/lib/db"; } from "@/lib/db";
async function rawRows<T>(query: ReturnType<typeof sql>): Promise<T[]> { async function rawRows<T>(query: ReturnType<typeof sql>): Promise<T[]> {
const [rows] = (await db.execute(query)) as unknown as [T[], unknown]; return queryRows<T>(query);
return rows ?? [];
} }
export async function loadUserById( export async function loadUserById(
+10 -10
View File
@@ -1,7 +1,7 @@
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { apiError } from "@/lib/api"; import { apiError } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { db } from "@/lib/db"; import { queryRows } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
const VALID_REPORTS = new Set(["registrations", "online-by-hour", "economy"]); const VALID_REPORTS = new Set(["registrations", "online-by-hour", "economy"]);
@@ -20,13 +20,13 @@ function csvRow(values: unknown[]): string {
} }
async function streamRegistrations(): Promise<ReadableStream<Uint8Array>> { async function streamRegistrations(): Promise<ReadableStream<Uint8Array>> {
const [rows] = (await db.execute(sql` const rows = await queryRows<{ day: string; cnt: bigint }>(sql`
SELECT FROM_UNIXTIME(account_created, '%Y-%m-%d') AS day, COUNT(*) AS cnt SELECT FROM_UNIXTIME(account_created, '%Y-%m-%d') AS day, COUNT(*) AS cnt
FROM users FROM users
WHERE account_created > UNIX_TIMESTAMP(NOW() - INTERVAL 90 DAY) WHERE account_created > UNIX_TIMESTAMP(NOW() - INTERVAL 90 DAY)
GROUP BY day GROUP BY day
ORDER BY day ASC ORDER BY day ASC
`)) as unknown as [{ day: string; cnt: bigint }[], unknown]; `);
const encoder = new TextEncoder(); const encoder = new TextEncoder();
return new ReadableStream({ return new ReadableStream({
start(controller) { start(controller) {
@@ -40,13 +40,13 @@ async function streamRegistrations(): Promise<ReadableStream<Uint8Array>> {
} }
async function streamOnlineByHour(): Promise<ReadableStream<Uint8Array>> { async function streamOnlineByHour(): Promise<ReadableStream<Uint8Array>> {
const [rows] = (await db.execute(sql` const rows = await queryRows<{ hour: number; cnt: bigint }>(sql`
SELECT HOUR(FROM_UNIXTIME(last_online)) AS hour, COUNT(*) AS cnt SELECT HOUR(FROM_UNIXTIME(last_online)) AS hour, COUNT(*) AS cnt
FROM users FROM users
WHERE last_online > UNIX_TIMESTAMP(NOW() - INTERVAL 30 DAY) WHERE last_online > UNIX_TIMESTAMP(NOW() - INTERVAL 30 DAY)
GROUP BY hour GROUP BY hour
ORDER BY hour ASC ORDER BY hour ASC
`)) as unknown as [{ hour: number; cnt: bigint }[], unknown]; `);
const encoder = new TextEncoder(); const encoder = new TextEncoder();
return new ReadableStream({ return new ReadableStream({
start(controller) { start(controller) {
@@ -62,12 +62,12 @@ async function streamOnlineByHour(): Promise<ReadableStream<Uint8Array>> {
} }
async function streamEconomy(): Promise<ReadableStream<Uint8Array>> { async function streamEconomy(): Promise<ReadableStream<Uint8Array>> {
const [rows] = (await db.execute(sql` const rows = await queryRows<{
total_credits: bigint;
total_pixels: bigint;
}>(sql`
SELECT SUM(credits) AS total_credits, SUM(pixels) AS total_pixels FROM users SELECT SUM(credits) AS total_credits, SUM(pixels) AS total_pixels FROM users
`)) as unknown as [ `);
{ total_credits: bigint; total_pixels: bigint }[],
unknown,
];
const totals = rows[0]; const totals = rows[0];
const encoder = new TextEncoder(); const encoder = new TextEncoder();
return new ReadableStream({ return new ReadableStream({
@@ -4,7 +4,7 @@ import { eq, sql } from "drizzle-orm";
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { apiError, apiOk } from "@/lib/api"; import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { db, ItemsBase } from "@/lib/db"; import { db, ItemsBase, queryRows } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { logAudit } from "@/lib/services/audit"; import { logAudit } from "@/lib/services/audit";
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs"; import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
@@ -48,17 +48,14 @@ export const GET = withAdmin(
const { json } = parseNitroBundle(buffer); const { json } = parseNitroBundle(buffer);
// Fetch interaction flags from DB // Fetch interaction flags from DB
const [items] = (await db.execute(sql` const items = await queryRows<{
SELECT allow_sit, allow_lay, allow_walk
FROM items_base WHERE item_name = ${rawClassname} LIMIT 1
`)) as unknown as [
Array<{
allow_sit: string; allow_sit: string;
allow_lay: string; allow_lay: string;
allow_walk: string; allow_walk: string;
}>, }>(sql`
unknown, SELECT allow_sit, allow_lay, allow_walk
]; FROM items_base WHERE item_name = ${rawClassname} LIMIT 1
`);
const dbRow = items[0]; const dbRow = items[0];
const flags = { const flags = {
@@ -2,7 +2,7 @@ import { existsSync } from "node:fs";
import { asc, inArray, sql } from "drizzle-orm"; import { asc, inArray, sql } from "drizzle-orm";
import { apiOk } from "@/lib/api"; import { apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { db, ItemsBase } from "@/lib/db"; import { db, ItemsBase, queryRows } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
import { import {
appendFurniEntriesBatch, appendFurniEntriesBatch,
@@ -221,7 +221,7 @@ async function resolveTargetIds(
Date.now() - days * 24 * 60 * 60 * 1000, Date.now() - days * 24 * 60 * 60 * 1000,
).toISOString(); ).toISOString();
const sinceMysql = sinceIso.slice(0, 19).replace("T", " "); const sinceMysql = sinceIso.slice(0, 19).replace("T", " ");
const [auditRows] = (await db.execute(sql` const auditRows = await queryRows<{ target_id: number }>(sql`
SELECT DISTINCT target_id SELECT DISTINCT target_id
FROM admin_audit_log FROM admin_audit_log
WHERE action = 'furni_import' WHERE action = 'furni_import'
@@ -229,6 +229,6 @@ async function resolveTargetIds(
AND target_id IS NOT NULL AND target_id IS NOT NULL
AND created_at >= ${sinceMysql} AND created_at >= ${sinceMysql}
ORDER BY target_id ASC ORDER BY target_id ASC
`)) as unknown as [Array<{ target_id: number }>, unknown]; `);
return auditRows.map((r) => r.target_id).filter(Boolean); return auditRows.map((r) => r.target_id).filter(Boolean);
} }
+8 -11
View File
@@ -3,7 +3,7 @@ import path from "node:path";
import { eq, sql } from "drizzle-orm"; import { eq, sql } from "drizzle-orm";
import { apiError, apiOk } from "@/lib/api"; import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { db, ItemsBase } from "@/lib/db"; import { db, ItemsBase, queryRows } from "@/lib/db";
import { normalizeClassname } from "@/lib/furni/classname"; import { normalizeClassname } from "@/lib/furni/classname";
import { localFurnitureStatus } from "@/lib/furni/local-presence"; import { localFurnitureStatus } from "@/lib/furni/local-presence";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
@@ -184,21 +184,18 @@ export const GET = withAdmin(
); );
const perPage = 500; const perPage = 500;
const [importedItems] = (await db.execute(sql` const importedItems = await queryRows<{
item_name: string;
public_name: string;
sprite_id: number;
type: string;
}>(sql`
SELECT ib.item_name, ib.public_name, ib.sprite_id, ib.type FROM items_base ib SELECT ib.item_name, ib.public_name, ib.sprite_id, ib.type FROM items_base ib
WHERE EXISTS ( WHERE EXISTS (
SELECT 1 FROM catalog_items ci SELECT 1 FROM catalog_items ci
WHERE FIND_IN_SET(ib.id, REPLACE(ci.item_ids, ';', ',')) > 0 WHERE FIND_IN_SET(ib.id, REPLACE(ci.item_ids, ';', ',')) > 0
) )
`)) as unknown as [ `);
Array<{
item_name: string;
public_name: string;
sprite_id: number;
type: string;
}>,
unknown,
];
// Filter to only those missing .nitro on filesystem (memoized — fast) // Filter to only those missing .nitro on filesystem (memoized — fast)
const missing = importedItems.filter((item) => { const missing = importedItems.filter((item) => {
+5 -5
View File
@@ -9,7 +9,7 @@ import {
} from "@/features/catalog/server/import-pages"; } from "@/features/catalog/server/import-pages";
import { apiOk } from "@/lib/api"; import { apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { db, ItemsBase } from "@/lib/db"; import { db, ItemsBase, queryRows } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
interface OrganizeItem { interface OrganizeItem {
@@ -59,7 +59,7 @@ export const GET = withAdmin(
const placedById = new Map<number, OrganizeItem>(); const placedById = new Map<number, OrganizeItem>();
const itemIdsToResolve = new Set<number>(); const itemIdsToResolve = new Set<number>();
if (pageIds.length > 0) { if (pageIds.length > 0) {
const [rows] = (await db.execute(sql` const rows = await queryRows<Record<string, unknown>>(sql`
SELECT ci.id AS catalogItemId, SELECT ci.id AS catalogItemId,
ci.page_id AS sourcePageId, ci.page_id AS sourcePageId,
ci.item_ids AS itemIds, ci.item_ids AS itemIds,
@@ -70,7 +70,7 @@ export const GET = withAdmin(
WHERE ci.page_id IN (${sql.join(pageIds, sql`, `)}) WHERE ci.page_id IN (${sql.join(pageIds, sql`, `)})
ORDER BY ci.id ORDER BY ci.id
LIMIT ${limit} LIMIT ${limit}
`)) as unknown as [Record<string, unknown>[], unknown]; `);
for (const r of rows) { for (const r of rows) {
const parsed = parseItemIds(String(r.itemIds ?? "")); const parsed = parseItemIds(String(r.itemIds ?? ""));
@@ -96,7 +96,7 @@ export const GET = withAdmin(
// 2) Recently imported furniture. Bounded candidate list first, matched // 2) Recently imported furniture. Bounded candidate list first, matched
// against the import tree in JS — never a correlated FIND_IN_SET scan. // against the import tree in JS — never a correlated FIND_IN_SET scan.
const [candidateRows] = (await db.execute(sql` const candidateRows = await queryRows<{ itemId: number }>(sql`
SELECT DISTINCT alog.target_id AS itemId SELECT DISTINCT alog.target_id AS itemId
FROM admin_audit_log alog FROM admin_audit_log alog
WHERE alog.action = 'furni_import' WHERE alog.action = 'furni_import'
@@ -105,7 +105,7 @@ export const GET = withAdmin(
AND alog.created_at >= ${since} AND alog.created_at >= ${since}
ORDER BY itemId DESC ORDER BY itemId DESC
LIMIT 2000 LIMIT 2000
`)) as unknown as [Array<{ itemId: number }>, unknown]; `);
for (const r of candidateRows) { for (const r of candidateRows) {
const itemId = Number(r.itemId); const itemId = Number(r.itemId);
+1 -4
View File
@@ -1,5 +1,4 @@
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { revalidateTag } from "next/cache"; import { revalidateTag } from "next/cache";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { apiError } from "@/lib/api"; import { apiError } from "@/lib/api";
@@ -17,9 +16,7 @@ export const POST = withAdmin(
const title = String(body.title ?? "").trim(); const title = String(body.title ?? "").trim();
if (!/^[a-z0-9._-]{2,64}$/.test(slug) || !title) if (!/^[a-z0-9._-]{2,64}$/.test(slug) || !title)
return apiError("Valid slug and title required", 400); return apiError("Valid slug and title required", 400);
const [result] = (await db const [result] = await db.insert(AclRole).values({ slug, title });
.insert(AclRole)
.values({ slug, title })) as unknown as [ResultSetHeader];
const role = { id: Number(result.insertId), slug, title }; const role = { id: Number(result.insertId), slug, title };
revalidateTag("permissions", { expire: 0 }); revalidateTag("permissions", { expire: 0 });
return NextResponse.json({ role }); return NextResponse.json({ role });
@@ -1,7 +1,7 @@
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { apiError, apiOk } from "@/lib/api"; import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { db } from "@/lib/db"; import { db, queryRows } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
interface BlacklistWord { interface BlacklistWord {
@@ -10,9 +10,9 @@ interface BlacklistWord {
} }
export const GET = withAdmin({ permission: PERMS.PREFIXES_VIEW }, async () => { export const GET = withAdmin({ permission: PERMS.PREFIXES_VIEW }, async () => {
const [words] = (await db.execute( const words = await queryRows<BlacklistWord>(
sql`SELECT id, word FROM custom_prefix_blacklist ORDER BY word ASC`, sql`SELECT id, word FROM custom_prefix_blacklist ORDER BY word ASC`,
)) as unknown as [BlacklistWord[], unknown]; );
return apiOk({ words }); return apiOk({ words });
}); });
+11 -12
View File
@@ -1,7 +1,7 @@
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { apiError, apiOk } from "@/lib/api"; import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { db } from "@/lib/db"; import { db, queryRows } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
interface UserPrefix { interface UserPrefix {
@@ -28,21 +28,20 @@ export const GET = withAdmin(
? sql`WHERE up.text LIKE ${`%${q}%`} OR u.username LIKE ${`%${q}%`}` ? sql`WHERE up.text LIKE ${`%${q}%`} OR u.username LIKE ${`%${q}%`}`
: sql``; : sql``;
const [prefixes] = const prefixes = await queryRows<UserPrefix>(
(await db.execute(sql`SELECT up.id, up.user_id, up.text, up.color, up.icon, up.effect, up.active, u.username sql`SELECT up.id, up.user_id, up.text, up.color, up.icon, up.effect, up.active, u.username
FROM custom_prefixes up FROM custom_prefixes up
LEFT JOIN users u ON u.id = up.user_id LEFT JOIN users u ON u.id = up.user_id
${whereFragment} ${whereFragment}
ORDER BY up.id DESC ORDER BY up.id DESC
LIMIT ${limit} OFFSET ${offset}`)) as unknown as [ LIMIT ${limit} OFFSET ${offset}`,
UserPrefix[], );
unknown,
];
const [countResult] = const countResult = await queryRows<{ total: bigint }>(
(await db.execute(sql`SELECT COUNT(*) as total FROM custom_prefixes up sql`SELECT COUNT(*) as total FROM custom_prefixes up
LEFT JOIN users u ON u.id = up.user_id LEFT JOIN users u ON u.id = up.user_id
${whereFragment}`)) as unknown as [[{ total: bigint }], unknown]; ${whereFragment}`,
);
const total = Number(countResult[0]?.total || 0); const total = Number(countResult[0]?.total || 0);
@@ -70,9 +69,9 @@ export const POST = withAdmin(
return apiError("Missing required fields: username, text, color"); return apiError("Missing required fields: username, text, color");
} }
const [users] = (await db.execute( const users = await queryRows<{ id: number }>(
sql`SELECT id FROM users WHERE username = ${username} LIMIT 1`, sql`SELECT id FROM users WHERE username = ${username} LIMIT 1`,
)) as unknown as [{ id: number }[], unknown]; );
if (!users || users.length === 0) { if (!users || users.length === 0) {
return apiError("User not found"); return apiError("User not found");
+3 -3
View File
@@ -1,7 +1,7 @@
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { apiError, apiOk } from "@/lib/api"; import { apiError, apiOk } from "@/lib/api";
import { withAdmin } from "@/lib/api-handler"; import { withAdmin } from "@/lib/api-handler";
import { db } from "@/lib/db"; import { db, queryRows } from "@/lib/db";
import { PERMS } from "@/lib/permissions"; import { PERMS } from "@/lib/permissions";
interface PrefixSetting { interface PrefixSetting {
@@ -32,9 +32,9 @@ const DEFAULT_SETTINGS: Record<string, string> = {
}; };
export const GET = withAdmin({ permission: PERMS.PREFIXES_VIEW }, async () => { export const GET = withAdmin({ permission: PERMS.PREFIXES_VIEW }, async () => {
const [settings] = (await db.execute( const settings = await queryRows<PrefixSetting>(
sql`SELECT \`key\`, \`value\` FROM custom_prefix_settings`, sql`SELECT \`key\`, \`value\` FROM custom_prefix_settings`,
)) as unknown as [PrefixSetting[], unknown]; );
const result: Record<string, string> = { ...DEFAULT_SETTINGS }; const result: Record<string, string> = { ...DEFAULT_SETTINGS };
for (const s of settings) { for (const s of settings) {
@@ -13,7 +13,10 @@ vi.mock("@/lib/api-handler", () => ({
return handler; return handler;
}, },
})); }));
vi.mock("@/lib/db", () => ({ db: { execute: mocks.execute } })); vi.mock("@/lib/db", async () => ({
...(await import("@/test/db-helpers")).createDbHelpers(mocks.execute),
db: { execute: mocks.execute },
}));
vi.mock("@/lib/services/furni-data", () => ({ readFurniData: mocks.read })); vi.mock("@/lib/services/furni-data", () => ({ readFurniData: mocks.read }));
vi.mock("@/lib/services/furni-asset-dirs", () => ({ vi.mock("@/lib/services/furni-asset-dirs", () => ({
getFurniAssetDirs: async () => ({ getFurniAssetDirs: async () => ({
+2 -3
View File
@@ -3,7 +3,7 @@ import { connection } from "next/server";
import { apiJson } from "@/lib/api"; import { apiJson } from "@/lib/api";
import { bearerUserId } from "@/lib/api-auth"; import { bearerUserId } from "@/lib/api-auth";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { db, User, UsersSettings } from "@/lib/db"; import { db, queryRows, User, UsersSettings } from "@/lib/db";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache"; import { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache";
@@ -68,8 +68,7 @@ function rankEntries(
} }
async function rawRows<T>(query: ReturnType<typeof sql>): Promise<T[]> { async function rawRows<T>(query: ReturnType<typeof sql>): Promise<T[]> {
const [rows] = (await db.execute(query)) as unknown as [T[], unknown]; return queryRows<T>(query);
return rows;
} }
async function loadTotalBadgesBoard(): Promise<BoardCore> { async function loadTotalBadgesBoard(): Promise<BoardCore> {
+2 -3
View File
@@ -9,7 +9,6 @@
// table has no expires_at column, so it is never read or written here. // table has no expires_at column, so it is never read or written here.
import { and, desc, eq } from "drizzle-orm"; import { and, desc, eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { apiError, apiJson, apiUnavailable, positiveBigInt } from "@/lib/api"; import { apiError, apiJson, apiUnavailable, positiveBigInt } from "@/lib/api";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { personalTokenScope } from "@/lib/auth/personal-token-scope"; import { personalTokenScope } from "@/lib/auth/personal-token-scope";
@@ -61,7 +60,7 @@ export async function DELETE(req: Request) {
try { try {
// Scope the delete to the owner so users cannot revoke others' tokens. // Scope the delete to the owner so users cannot revoke others' tokens.
const scope = personalTokenScope(id); const scope = personalTokenScope(id);
const [result] = (await db const [result] = await db
.delete(PersonalAccessTokens) .delete(PersonalAccessTokens)
.where( .where(
and( and(
@@ -69,7 +68,7 @@ export async function DELETE(req: Request) {
eq(PersonalAccessTokens.tokenableId, scope.tokenableId), eq(PersonalAccessTokens.tokenableId, scope.tokenableId),
eq(PersonalAccessTokens.tokenableType, scope.tokenableType), eq(PersonalAccessTokens.tokenableType, scope.tokenableType),
), ),
)) as unknown as [ResultSetHeader]; );
if (!result.affectedRows) { if (!result.affectedRows) {
return apiError("Token not found", 404); return apiError("Token not found", 404);
} }
+6 -7
View File
@@ -1,5 +1,4 @@
import { and, eq } from "drizzle-orm"; import { and, eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { auth } from "@/lib/auth"; import { auth } from "@/lib/auth";
import { sessionUserId } from "@/lib/auth/session-user"; import { sessionUserId } from "@/lib/auth/session-user";
@@ -119,7 +118,7 @@ export async function POST(req: Request): Promise<Response> {
const credits = Math.floor(amount * creditsPerUnit()); const credits = Math.floor(amount * creditsPerUnit());
try { try {
await claimTopupDelivery(async () => { await claimTopupDelivery(async () => {
const [result] = (await db const [result] = await db
.update(WebsitePaypalTransactions) .update(WebsitePaypalTransactions)
.set({ status: "CREDIT_DELIVERING", updatedAt: new Date() }) .set({ status: "CREDIT_DELIVERING", updatedAt: new Date() })
.where( .where(
@@ -128,7 +127,7 @@ export async function POST(req: Request): Promise<Response> {
eq(WebsitePaypalTransactions.transactionId, orderId), eq(WebsitePaypalTransactions.transactionId, orderId),
eq(WebsitePaypalTransactions.status, "CAPTURED_PENDING_CREDIT"), eq(WebsitePaypalTransactions.status, "CAPTURED_PENDING_CREDIT"),
), ),
)) as unknown as [ResultSetHeader]; );
return { count: result.affectedRows }; return { count: result.affectedRows };
}); });
await sendCurrency({ rcon, db: currencyDb }, userId, "credits", credits); await sendCurrency({ rcon, db: currencyDb }, userId, "credits", credits);
@@ -217,7 +216,7 @@ export async function POST(req: Request): Promise<Response> {
// Record the transaction BEFORE crediting so a crash mid-grant can't be // Record the transaction BEFORE crediting so a crash mid-grant can't be
// reprocessed into a double credit (the idempotency check above keys on this). // reprocessed into a double credit (the idempotency check above keys on this).
try { try {
const [claimed] = (await db const [claimed] = await db
.update(WebsitePaypalTransactions) .update(WebsitePaypalTransactions)
.set({ .set({
status: "CAPTURED_PENDING_CREDIT", status: "CAPTURED_PENDING_CREDIT",
@@ -233,7 +232,7 @@ export async function POST(req: Request): Promise<Response> {
eq(WebsitePaypalTransactions.transactionId, orderId), eq(WebsitePaypalTransactions.transactionId, orderId),
eq(WebsitePaypalTransactions.status, "CREATED"), eq(WebsitePaypalTransactions.status, "CREATED"),
), ),
)) as unknown as [ResultSetHeader]; );
if (claimed.affectedRows !== 1) if (claimed.affectedRows !== 1)
throw new Error("Top-up order was already claimed"); throw new Error("Top-up order was already claimed");
} catch (e) { } catch (e) {
@@ -255,7 +254,7 @@ export async function POST(req: Request): Promise<Response> {
// for staff reconciliation instead of automatically risking a second grant. // for staff reconciliation instead of automatically risking a second grant.
try { try {
await claimTopupDelivery(async () => { await claimTopupDelivery(async () => {
const [claimResult] = (await db const [claimResult] = await db
.update(WebsitePaypalTransactions) .update(WebsitePaypalTransactions)
.set({ status: "CREDIT_DELIVERING", updatedAt: new Date() }) .set({ status: "CREDIT_DELIVERING", updatedAt: new Date() })
.where( .where(
@@ -264,7 +263,7 @@ export async function POST(req: Request): Promise<Response> {
eq(WebsitePaypalTransactions.transactionId, orderId), eq(WebsitePaypalTransactions.transactionId, orderId),
eq(WebsitePaypalTransactions.status, "CAPTURED_PENDING_CREDIT"), eq(WebsitePaypalTransactions.status, "CAPTURED_PENDING_CREDIT"),
), ),
)) as unknown as [ResultSetHeader]; );
return { count: claimResult.affectedRows }; return { count: claimResult.affectedRows };
}); });
await sendCurrency({ rcon, db: currencyDb }, userId, "credits", credits); await sendCurrency({ rcon, db: currencyDb }, userId, "credits", credits);
+2 -3
View File
@@ -5,7 +5,6 @@
// DB errors return an apiError envelope, never a 500. // DB errors return an apiError envelope, never a 500.
import { desc, eq } from "drizzle-orm"; import { desc, eq } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import { apiError, apiJson, positiveBigInt } from "@/lib/api"; import { apiError, apiJson, positiveBigInt } from "@/lib/api";
import { bearerUserId } from "@/lib/api-auth"; import { bearerUserId } from "@/lib/api-auth";
import { db, WebsiteHelpCenterTickets } from "@/lib/db"; import { db, WebsiteHelpCenterTickets } from "@/lib/db";
@@ -79,7 +78,7 @@ export async function POST(req: Request) {
try { try {
const now = new Date(); const now = new Date();
const [result] = (await db.insert(WebsiteHelpCenterTickets).values({ const [result] = await db.insert(WebsiteHelpCenterTickets).values({
userId: uid, userId: uid,
categoryId, categoryId,
title, title,
@@ -87,7 +86,7 @@ export async function POST(req: Request) {
open: true, open: true,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
})) as unknown as [ResultSetHeader]; });
return apiJson( return apiJson(
{ {
@@ -32,8 +32,8 @@ const state = vi.hoisted(() => ({
commits: 0, commits: 0,
rollbacks: 0, rollbacks: 0,
})); }));
vi.mock("@/lib/db", () => ({ vi.mock("@/lib/db", async () => {
db: { const db = {
execute: async () => [state.pages, []], execute: async () => [state.pages, []],
transaction: async (fn: (tx: unknown) => Promise<unknown>) => { transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
const before = state.writes; const before = state.writes;
@@ -112,11 +112,16 @@ vi.mock("@/lib/db", () => ({
throw error; throw error;
} }
}, },
}, };
})); return {
db,
...(await import("@/test/db-helpers")).createDbHelpers(db.execute),
};
});
vi.mock("@/features/operations/server", async () => { import { db } from "@/lib/db";
const { db } = await import("@/lib/db");
vi.mock("@/features/operations/server", () => {
return { return {
runOperation: ( runOperation: (
_input: unknown, _input: unknown,
+13 -12
View File
@@ -8,7 +8,7 @@ import {
recordHistory, recordHistory,
} from "@/features/history/server"; } from "@/features/history/server";
import { enqueueEffect, runOperation } from "@/features/operations/server"; import { enqueueEffect, runOperation } from "@/features/operations/server";
import { db } from "@/lib/db"; import { db, queryRows, rowsFrom } from "@/lib/db";
import { import {
type BulkOfferInput, type BulkOfferInput,
type BulkOfferPreview, type BulkOfferPreview,
@@ -29,10 +29,11 @@ async function readOffers(
ids: number[], ids: number[],
lock = false, lock = false,
): Promise<OfferRow[]> { ): Promise<OfferRow[]> {
const [rows] = await tx.execute( const offers = rowsFrom<OfferRow>(
await tx.execute(
sql`SELECT id, catalog_name AS catalogName, page_id AS pageId, cost_credits AS costCredits, cost_points AS costPoints, points_type AS pointsType FROM catalog_items WHERE id IN (${sql.join(ids, sql`, `)}) ORDER BY id ${lock ? sql`FOR UPDATE` : sql``}`, sql`SELECT id, catalog_name AS catalogName, page_id AS pageId, cost_credits AS costCredits, cost_points AS costPoints, points_type AS pointsType FROM catalog_items WHERE id IN (${sql.join(ids, sql`, `)}) ORDER BY id ${lock ? sql`FOR UPDATE` : sql``}`,
); ),
const offers = (rows as unknown as OfferRow[]).map((row) => ({ ).map((row) => ({
...row, ...row,
id: Number(row.id), id: Number(row.id),
pageId: Number(row.pageId), pageId: Number(row.pageId),
@@ -63,10 +64,11 @@ async function readPages(
].sort((a, b) => a - b); ].sort((a, b) => a - b);
if (ids.some((id) => !Number.isInteger(id) || id <= 0)) if (ids.some((id) => !Number.isInteger(id) || id <= 0))
throw new CatalogInputError("An offer has an invalid category reference"); throw new CatalogInputError("An offer has an invalid category reference");
const [rows] = await tx.execute( const pages = rowsFrom<{ id: number; caption: string }>(
await tx.execute(
sql`SELECT id, caption FROM catalog_pages WHERE id IN (${sql.join(ids, sql`, `)}) ORDER BY id ${lock ? sql`FOR UPDATE` : sql``}`, sql`SELECT id, caption FROM catalog_pages WHERE id IN (${sql.join(ids, sql`, `)}) ORDER BY id ${lock ? sql`FOR UPDATE` : sql``}`,
); ),
const pages = (rows as unknown as { id: number; caption: string }[]) )
.filter((row) => ids.includes(Number(row.id))) .filter((row) => ids.includes(Number(row.id)))
.map((row) => ({ id: Number(row.id), caption: row.caption })) .map((row) => ({ id: Number(row.id), caption: row.caption }))
.sort((a, b) => a.id - b.id); .sort((a, b) => a.id - b.id);
@@ -193,12 +195,11 @@ export async function applyBulkOffersCommand(
} }
export async function listBulkOfferDestinationsCommand() { export async function listBulkOfferDestinationsCommand() {
const [rows] = await db.execute( const pages = (
await queryRows<{ id: number; caption: string }>(
sql`SELECT id, caption FROM catalog_pages WHERE id > 0 ORDER BY caption, id LIMIT 5001`, sql`SELECT id, caption FROM catalog_pages WHERE id > 0 ORDER BY caption, id LIMIT 5001`,
); )
const pages = (rows as unknown as Array<{ id: number; caption: string }>).map( ).map((row) => ({ id: Number(row.id), caption: row.caption }));
(row) => ({ id: Number(row.id), caption: row.caption }),
);
if (pages.length > 5000) if (pages.length > 5000)
throw new CatalogInputError( throw new CatalogInputError(
"The catalog has more than 5000 categories. Use bulk editing from a category instead.", "The catalog has more than 5000 categories. Use bulk editing from a category instead.",
@@ -10,7 +10,8 @@ const state = vi.hoisted(() => ({
audit: [] as unknown[], audit: [] as unknown[],
queries: [] as string[], queries: [] as string[],
})); }));
vi.mock("@/lib/db", () => ({ vi.mock("@/lib/db", async () => ({
rowsFrom: (await import("@/test/db-helpers")).rowsFrom,
db: { db: {
transaction: async (fn: (tx: unknown) => Promise<unknown>) => { transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
const saved = structuredClone(state.current); const saved = structuredClone(state.current);
@@ -79,8 +80,9 @@ vi.mock("@/lib/db", () => ({
}, },
})); }));
vi.mock("@/features/operations/server", async () => { import { db } from "@/lib/db";
const { db } = await import("@/lib/db");
vi.mock("@/features/operations/server", () => {
return { return {
runOperation: ( runOperation: (
_input: unknown, _input: unknown,
@@ -27,7 +27,8 @@ const state = vi.hoisted(() => ({
vi.mock("@/lib/services/furni-import", () => ({ vi.mock("@/lib/services/furni-import", () => ({
allocateCatalogItemId: async (fn: (id: number) => unknown) => fn(500), allocateCatalogItemId: async (fn: (id: number) => unknown) => fn(500),
})); }));
vi.mock("@/lib/db", () => ({ vi.mock("@/lib/db", async () => ({
rowsFrom: (await import("@/test/db-helpers")).rowsFrom,
db: { db: {
transaction: async (fn: (tx: unknown) => Promise<unknown>) => { transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
const saved = state.writes; const saved = state.writes;
@@ -2,7 +2,7 @@ import "server-only";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { db } from "@/lib/db"; import { db, queryRows, rowsFrom } from "@/lib/db";
import { allocateCatalogItemId } from "@/lib/services/furni-import"; import { allocateCatalogItemId } from "@/lib/services/furni-import";
import { import {
type DuplicateInput, type DuplicateInput,
@@ -28,10 +28,11 @@ function table(kind: DuplicateInput["kind"], offers = false) {
} }
async function snapshot(tx: Transaction, input: DuplicateInput, lock: boolean) { async function snapshot(tx: Transaction, input: DuplicateInput, lock: boolean) {
const suffix = lock ? sql` FOR UPDATE` : sql``; const suffix = lock ? sql` FOR UPDATE` : sql``;
const [rawPages] = await tx.execute( const pages = rowsFrom<DuplicateRow>(
await tx.execute(
sql`SELECT * FROM ${table(input.kind)} ORDER BY id LIMIT 20001${suffix}`, sql`SELECT * FROM ${table(input.kind)} ORDER BY id LIMIT 20001${suffix}`,
),
); );
const pages = rawPages as unknown as DuplicateRow[];
if (pages.length > 20000) if (pages.length > 20000)
throw new CatalogInputError( throw new CatalogInputError(
"Catalog exceeds duplication safety limit (20000 categories)", "Catalog exceeds duplication safety limit (20000 categories)",
@@ -42,21 +43,23 @@ async function snapshot(tx: Transaction, input: DuplicateInput, lock: boolean) {
for (const page of subtree) for (const page of subtree)
if (input.kind === "normal") if (input.kind === "normal")
remapIncludes(page.includes, new Map(), existing); remapIncludes(page.includes, new Map(), existing);
const [rawOffers] = await tx.execute( const offers = rowsFrom<DuplicateRow>(
await tx.execute(
sql`SELECT * FROM ${table(input.kind, true)} WHERE page_id IN (${sql.join(ids.map(String), sql`, `)}) ORDER BY id LIMIT 5001${suffix}`, sql`SELECT * FROM ${table(input.kind, true)} WHERE page_id IN (${sql.join(ids.map(String), sql`, `)}) ORDER BY id LIMIT 5001${suffix}`,
),
); );
const offers = rawOffers as unknown as DuplicateRow[];
if (offers.length > 5000) if (offers.length > 5000)
throw new CatalogInputError("Duplicate at most 5000 offers at a time"); throw new CatalogInputError("Duplicate at most 5000 offers at a time");
const furniture = [ const furniture = [
...new Set(offers.flatMap((o) => parseFurnitureIds(String(o.item_ids)))), ...new Set(offers.flatMap((o) => parseFurnitureIds(String(o.item_ids)))),
].sort((a, b) => a - b); ].sort((a, b) => a - b);
if (furniture.length) { if (furniture.length) {
const [rows] = await tx.execute(
sql`SELECT id FROM items_base WHERE id IN (${sql.join(furniture, sql`, `)}) ORDER BY id${suffix}`,
);
const found = new Set( const found = new Set(
(rows as unknown as { id: number }[]).map((p) => Number(p.id)), rowsFrom<{ id: number }>(
await tx.execute(
sql`SELECT id FROM items_base WHERE id IN (${sql.join(furniture, sql`, `)}) ORDER BY id${suffix}`,
),
).map((p) => Number(p.id)),
); );
if (furniture.some((id) => !found.has(id))) if (furniture.some((id) => !found.has(id)))
throw new CatalogInputError("Furniture reference not found"); throw new CatalogInputError("Furniture reference not found");
@@ -134,9 +137,7 @@ export async function duplicateCategoryCommand(
sql`, `, sql`, `,
)}`, )}`,
); );
const id = Number( const id = Number(row.id ?? result.insertId);
row.id ?? (result as unknown as { insertId: number }).insertId,
);
if (!id) throw Error("Could not allocate duplicate category ID"); if (!id) throw Error("Could not allocate duplicate category ID");
return id; return id;
}; };
@@ -195,10 +196,9 @@ export async function duplicateDestinationsCommand(
kind: DuplicateInput["kind"], kind: DuplicateInput["kind"],
) { ) {
z.enum(["normal", "bc"]).parse(kind); z.enum(["normal", "bc"]).parse(kind);
const [rows] = await db.execute( const pages = await queryRows<{ id: number; caption: string }>(
sql`SELECT id, caption FROM ${table(kind)} ORDER BY caption, id LIMIT 20001`, sql`SELECT id, caption FROM ${table(kind)} ORDER BY caption, id LIMIT 20001`,
); );
const pages = rows as unknown as { id: number; caption: string }[];
if (pages.length > 20000) if (pages.length > 20000)
throw new CatalogInputError( throw new CatalogInputError(
"Catalog exceeds duplication safety limit (20000 categories)", "Catalog exceeds duplication safety limit (20000 categories)",
+9 -9
View File
@@ -1,7 +1,7 @@
import "server-only"; import "server-only";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { db } from "@/lib/db"; import { queryRows } from "@/lib/db";
/** /**
* Locate the auto-created "Imported Furniture" tree (caption_save * Locate the auto-created "Imported Furniture" tree (caption_save
@@ -10,26 +10,26 @@ import { db } from "@/lib/db";
* knows exactly which furniture is "inbox" work. * knows exactly which furniture is "inbox" work.
*/ */
export async function getImportedCategoryPageIds(): Promise<number[]> { export async function getImportedCategoryPageIds(): Promise<number[]> {
const [rows] = (await db.execute(sql` const rows = await queryRows<{ id: number }>(sql`
SELECT id FROM catalog_pages SELECT id FROM catalog_pages
WHERE caption_save = 'imported_furni' WHERE caption_save = 'imported_furni'
AND parent_id IN (-1, 0) AND parent_id IN (-1, 0)
LIMIT 1 LIMIT 1
`)) as unknown as [Array<{ id: number }>, unknown]; `);
const rootId = Number(rows[0]?.id ?? 0); const rootId = Number(rows[0]?.id ?? 0);
if (!rootId) return []; if (!rootId) return [];
const [childRows] = (await db.execute(sql` const childRows = await queryRows<{ id: number }>(sql`
SELECT id FROM catalog_pages WHERE parent_id = ${rootId} SELECT id FROM catalog_pages WHERE parent_id = ${rootId}
`)) as unknown as [Array<{ id: number }>, unknown]; `);
const children = childRows.map((r) => Number(r.id)); const children = childRows.map((r) => Number(r.id));
const pageIds = [rootId, ...children]; const pageIds = [rootId, ...children];
if (children.length > 0) { if (children.length > 0) {
const [grandRows] = (await db.execute(sql` const grandRows = await queryRows<{ id: number }>(sql`
SELECT id FROM catalog_pages SELECT id FROM catalog_pages
WHERE parent_id IN (${sql.join(children, sql`, `)}) WHERE parent_id IN (${sql.join(children, sql`, `)})
`)) as unknown as [Array<{ id: number }>, unknown]; `);
pageIds.push(...grandRows.map((r) => Number(r.id))); pageIds.push(...grandRows.map((r) => Number(r.id)));
} }
return pageIds; return pageIds;
@@ -37,11 +37,11 @@ export async function getImportedCategoryPageIds(): Promise<number[]> {
/** Caption of the imported-furniture root page, when present. */ /** Caption of the imported-furniture root page, when present. */
export async function getImportedRootCaption(): Promise<string | null> { export async function getImportedRootCaption(): Promise<string | null> {
const [rows] = (await db.execute(sql` const rows = await queryRows<{ caption: string }>(sql`
SELECT caption FROM catalog_pages SELECT caption FROM catalog_pages
WHERE caption_save = 'imported_furni' WHERE caption_save = 'imported_furni'
AND parent_id IN (-1, 0) AND parent_id IN (-1, 0)
LIMIT 1 LIMIT 1
`)) as unknown as [Array<{ caption: string }>, unknown]; `);
return rows[0]?.caption ?? null; return rows[0]?.caption ?? null;
} }
@@ -16,6 +16,7 @@ const state = vi.hoisted(() => ({
rollbacks: 0, rollbacks: 0,
})); }));
vi.mock("@/lib/db", async () => ({ vi.mock("@/lib/db", async () => ({
rowsFrom: (await import("@/test/db-helpers")).rowsFrom,
...(await import("@/db/schema")), ...(await import("@/db/schema")),
db: { db: {
transaction: async (fn: (tx: unknown) => Promise<unknown>) => { transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
+30 -19
View File
@@ -2,7 +2,13 @@ import "server-only";
import { getTableColumns, type SQL, sql } from "drizzle-orm"; import { getTableColumns, type SQL, sql } from "drizzle-orm";
import { z } from "zod"; import { z } from "zod";
import { historySnapshot, recordHistory } from "@/features/history/server"; import { historySnapshot, recordHistory } from "@/features/history/server";
import { CatalogItems, CatalogItemsBc, db, ItemsBase } from "@/lib/db"; import {
CatalogItems,
CatalogItemsBc,
db,
ItemsBase,
rowsFrom,
} from "@/lib/db";
import { CatalogInputError } from "../domain/hierarchy"; import { CatalogInputError } from "../domain/hierarchy";
import { import {
distinctOfferIds, distinctOfferIds,
@@ -31,13 +37,14 @@ async function lockedOffers(
ids: number[], ids: number[],
kind: OfferKind = "normal", kind: OfferKind = "normal",
): Promise<OfferRow[]> { ): Promise<OfferRow[]> {
const [rows] = await tx.execute( const offers = rowsFrom<OfferRow>(
await tx.execute(
sql`SELECT id, page_id AS pageId, item_ids AS itemIds FROM ${offerTable(kind)} WHERE id IN (${sql.join( 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), [...ids].sort((a, b) => a - b),
sql`, `, sql`, `,
)}) ORDER BY id FOR UPDATE`, )}) ORDER BY id FOR UPDATE`,
),
); );
const offers = rows as unknown as OfferRow[];
if ( if (
offers.length !== ids.length || offers.length !== ids.length ||
ids.some((id) => !offers.some((offer) => Number(offer.id) === id)) ids.some((id) => !offers.some((offer) => Number(offer.id) === id))
@@ -51,21 +58,23 @@ async function lockPage(
kind: OfferKind = "normal", kind: OfferKind = "normal",
) { ) {
offerTable(kind); 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 ( if (
!(rows as unknown as { id: number }[]).some((row) => Number(row.id) === id) !rowsFrom<{ id: number }>(
await tx.execute(
sql`SELECT id FROM ${sql.identifier(kind === "bc" ? "catalog_pages_bc" : "catalog_pages")} WHERE id=${id} FOR UPDATE`,
),
).some((row) => Number(row.id) === id)
) )
throw Error("Catalog page not found"); throw Error("Catalog page not found");
} }
async function lockFurniture(tx: Transaction, ids: number[]) { async function lockFurniture(tx: Transaction, ids: number[]) {
const unique = [...new Set(ids)].sort((a, b) => a - b); 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( const found = new Set(
(rows as unknown as { id: number }[]).map((row) => Number(row.id)), rowsFrom<{ id: number }>(
await tx.execute(
sql`SELECT id FROM items_base WHERE id IN (${sql.join(unique, sql`, `)}) ORDER BY id FOR UPDATE`,
),
).map((row) => Number(row.id)),
); );
if (unique.some((id) => !found.has(id))) if (unique.some((id) => !found.has(id)))
throw Error("Furniture reference not found"); throw Error("Furniture reference not found");
@@ -98,10 +107,13 @@ async function assertOrderUnique(
orderNumber: number, orderNumber: number,
excludeId: number, excludeId: number,
) { ) {
const [dups] = await tx.execute( if (
rowsFrom<unknown>(
await tx.execute(
sql`SELECT id FROM catalog_items WHERE page_id = ${String(pageId)} AND order_number = ${orderNumber} AND id != ${excludeId} LIMIT 1`, sql`SELECT id FROM catalog_items WHERE page_id = ${String(pageId)} AND order_number = ${orderNumber} AND id != ${excludeId} LIMIT 1`,
); ),
if ((dups as unknown as unknown[]).length > 0) ).length > 0
)
throw new CatalogInputError( throw new CatalogInputError(
`Order number ${orderNumber} is already used by another offer on this page`, `Order number ${orderNumber} is already used by another offer on this page`,
); );
@@ -156,11 +168,10 @@ export async function updateOfferCommand(
fields.limitedStack !== undefined || fields.limitedStack !== undefined ||
fields.limitedSells !== undefined fields.limitedSells !== undefined
) { ) {
const [current] = await tx.execute( const row = rowsFrom<{ limitedStack: number; limitedSells: number }>(
await tx.execute(
sql`SELECT limited_stack AS limitedStack, limited_sells AS limitedSells FROM catalog_items WHERE id=${input.id}`, sql`SELECT limited_stack AS limitedStack, limited_sells AS limitedSells FROM catalog_items WHERE id=${input.id}`,
); ),
const row = (
current as unknown as { limitedStack: number; limitedSells: number }[]
)[0]; )[0];
if (row) { if (row) {
const stack = fields.limitedStack ?? Number(row.limitedStack); const stack = fields.limitedStack ?? Number(row.limitedStack);
@@ -267,7 +278,7 @@ export async function createBcOfferCommand(
const [result] = await tx.execute( const [result] = await tx.execute(
sql`INSERT INTO ${CatalogItemsBc} SET ${sql.join(assignments(CatalogItemsBc, fields), sql`, `)}`, sql`INSERT INTO ${CatalogItemsBc} SET ${sql.join(assignments(CatalogItemsBc, fields), sql`, `)}`,
); );
const id = Number((result as unknown as { insertId: number }).insertId); const id = Number(result.insertId);
offerIdSchema.parse(id); offerIdSchema.parse(id);
return id; return id;
}); });
@@ -2,6 +2,7 @@ import "server-only";
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import type { db } from "@/lib/db"; import type { db } from "@/lib/db";
import { rowsFrom } from "@/lib/db";
import { import {
assertParent, assertParent,
CatalogInputError, CatalogInputError,
@@ -84,10 +85,11 @@ export async function readPackagePages(
tx: PackageTx, tx: PackageTx,
lock: boolean, lock: boolean,
): Promise<SourceRow[]> { ): Promise<SourceRow[]> {
const [rows] = await tx.execute( const pages = rowsFrom<SourceRow>(
await tx.execute(
sql`SELECT * FROM catalog_pages ORDER BY id LIMIT 20001${lock ? sql` FOR UPDATE` : sql``}`, sql`SELECT * FROM catalog_pages ORDER BY id LIMIT 20001${lock ? sql` FOR UPDATE` : sql``}`,
),
); );
const pages = rows as unknown as SourceRow[];
if (pages.length > 20000) if (pages.length > 20000)
throw new CatalogInputError( throw new CatalogInputError(
"Catalog exceeds package safety limit (20000 categories)", "Catalog exceeds package safety limit (20000 categories)",
@@ -128,10 +130,11 @@ export async function readPackageSource(
} }
const selectedPages = pages.filter((p) => selected.has(Number(p.id))); const selectedPages = pages.filter((p) => selected.has(Number(p.id)));
const ids = selectedPages.map((p) => String(p.id)); const ids = selectedPages.map((p) => String(p.id));
const [rows] = await tx.execute( const offers = rowsFrom<SourceRow>(
await tx.execute(
sql`SELECT * FROM catalog_items WHERE page_id IN (${sql.join(ids, sql`, `)}) ORDER BY id LIMIT 501${lock ? sql` FOR UPDATE` : sql``}`, sql`SELECT * FROM catalog_items WHERE page_id IN (${sql.join(ids, sql`, `)}) ORDER BY id LIMIT 501${lock ? sql` FOR UPDATE` : sql``}`,
),
); );
const offers = rows as unknown as SourceRow[];
if (offers.length > 500) if (offers.length > 500)
throw new CatalogInputError("A package supports at most 500 offers"); throw new CatalogInputError("A package supports at most 500 offers");
const furnitureIds = [ const furnitureIds = [
@@ -142,11 +145,12 @@ export async function readPackageSource(
"Package furniture references exceed safety limit", "Package furniture references exceed safety limit",
); );
if (furnitureIds.length) { if (furnitureIds.length) {
const [foundRows] = await tx.execute(
sql`SELECT id FROM items_base WHERE id IN (${sql.join(furnitureIds, sql`, `)}) ORDER BY id${lock ? sql` FOR UPDATE` : sql``}`,
);
const found = new Set( const found = new Set(
(foundRows as unknown as { id: number }[]).map((row) => Number(row.id)), rowsFrom<{ id: number }>(
await tx.execute(
sql`SELECT id FROM items_base WHERE id IN (${sql.join(furnitureIds, sql`, `)}) ORDER BY id${lock ? sql` FOR UPDATE` : sql``}`,
),
).map((row) => Number(row.id)),
); );
if (furnitureIds.some((id) => !found.has(id))) if (furnitureIds.some((id) => !found.has(id)))
throw new CatalogNotFound("Package furniture reference no longer exists"); throw new CatalogNotFound("Package furniture reference no longer exists");
+2 -1
View File
@@ -18,7 +18,8 @@ vi.mock("@/lib/services/furni-import", () => ({
allocateCatalogItemId: async (fn: (id: number) => unknown) => allocateCatalogItemId: async (fn: (id: number) => unknown) =>
fn(state.nextOffer++), fn(state.nextOffer++),
})); }));
vi.mock("@/lib/db", () => ({ vi.mock("@/lib/db", async () => ({
rowsFrom: (await import("@/test/db-helpers")).rowsFrom,
db: { db: {
transaction: async (fn: (tx: unknown) => Promise<unknown>) => { transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
const before = structuredClone({ const before = structuredClone({
+10 -13
View File
@@ -2,7 +2,7 @@ import "server-only";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { historySnapshot, recordHistory } from "@/features/history/server"; import { historySnapshot, recordHistory } from "@/features/history/server";
import { db } from "@/lib/db"; import { db, queryRows, rowsFrom } from "@/lib/db";
import { allocateCatalogItemId } from "@/lib/services/furni-import"; import { allocateCatalogItemId } from "@/lib/services/furni-import";
import { remapIncludes } from "../domain/duplicate"; import { remapIncludes } from "../domain/duplicate";
import { import {
@@ -55,10 +55,11 @@ async function read(
id: string, id: string,
lock = false, lock = false,
): Promise<StoredPackage> { ): Promise<StoredPackage> {
const [rows] = await tx.execute( const row = rowsFrom<{ payload: string }>(
await tx.execute(
sql`SELECT payload FROM website_catalog_packages WHERE id=${packageIdSchema.parse(id)}${lock ? sql` FOR UPDATE` : sql``}`, sql`SELECT payload FROM website_catalog_packages WHERE id=${packageIdSchema.parse(id)}${lock ? sql` FOR UPDATE` : sql``}`,
); ),
const row = (rows as unknown as { payload: string }[])[0]; )[0];
if (!row) throw new CatalogNotFound("Catalog package not found"); if (!row) throw new CatalogNotFound("Catalog package not found");
return JSON.parse(row.payload) as StoredPackage; return JSON.parse(row.payload) as StoredPackage;
} }
@@ -75,14 +76,12 @@ async function write(tx: PackageTx, stored: StoredPackage, create = false) {
); );
} }
export async function listCatalogPackagesCommand(): Promise<PackageListRow[]> { export async function listCatalogPackagesCommand(): Promise<PackageListRow[]> {
const [rows] = await db.execute( const rows = await queryRows<
Omit<PackageListRow, "updatedAt"> & { updatedAt: Date | string }
>(
sql`SELECT id,name,version,status,mode,updated_at AS updatedAt FROM website_catalog_packages ORDER BY updated_at DESC,id LIMIT 100`, sql`SELECT id,name,version,status,mode,updated_at AS updatedAt FROM website_catalog_packages ORDER BY updated_at DESC,id LIMIT 100`,
); );
return ( return rows.map((row) => ({
rows as unknown as Array<
Omit<PackageListRow, "updatedAt"> & { updatedAt: Date | string }
>
).map((row) => ({
...row, ...row,
version: Number(row.version), version: Number(row.version),
updatedAt: new Date(row.updatedAt).toISOString(), updatedAt: new Date(row.updatedAt).toISOString(),
@@ -238,9 +237,7 @@ async function insertRow(
sql`, `, sql`, `,
)}`, )}`,
); );
const id = Number( const id = Number(row.id ?? result.insertId);
row.id ?? (result as unknown as { insertId: number }).insertId,
);
if (!Number.isSafeInteger(id) || id <= 0) if (!Number.isSafeInteger(id) || id <= 0)
throw Error("Could not allocate package row ID"); throw Error("Could not allocate package row ID");
return id; return id;
@@ -15,6 +15,7 @@ const state = vi.hoisted(() => ({
rollbacks: 0, rollbacks: 0,
})); }));
vi.mock("@/lib/db", async () => ({ vi.mock("@/lib/db", async () => ({
rowsFrom: (await import("@/test/db-helpers")).rowsFrom,
...(await import("@/db/schema")), ...(await import("@/db/schema")),
db: { db: {
transaction: async (fn: (tx: unknown) => Promise<unknown>) => { transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
+10 -7
View File
@@ -1,7 +1,7 @@
import "server-only"; import "server-only";
import { getTableColumns, type SQL, sql } from "drizzle-orm"; import { getTableColumns, type SQL, sql } from "drizzle-orm";
import { historySnapshot, recordHistory } from "@/features/history/server"; import { historySnapshot, recordHistory } from "@/features/history/server";
import { CatalogPages, CatalogPagesBc, db } from "@/lib/db"; import { CatalogPages, CatalogPagesBc, db, rowsFrom } from "@/lib/db";
import { import {
assertParent, assertParent,
CatalogConflict, CatalogConflict,
@@ -29,10 +29,12 @@ async function lockedPages(
): Promise<HierarchyPage[]> { ): Promise<HierarchyPage[]> {
const { pages } = tables(kind); const { pages } = tables(kind);
// Structural commands serialize against a stable lock order, including parent validation. // Structural commands serialize against a stable lock order, including parent validation.
const [rows] = await tx.execute( const rows = rowsFrom<HierarchyPage>(
await tx.execute(
sql`SELECT id, parent_id AS parentId, order_num AS orderNum FROM ${pages} ORDER BY id FOR UPDATE`, 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) => ({ return rows.map((row) => ({
id: Number(row.id), id: Number(row.id),
parentId: Number(row.parentId), parentId: Number(row.parentId),
orderNum: Number(row.orderNum), orderNum: Number(row.orderNum),
@@ -64,10 +66,11 @@ export async function updatePageCommand(
throw new CatalogNotFound("Catalog page not found"); throw new CatalogNotFound("Catalog page not found");
if (expected) { if (expected) {
const columns = getTableColumns(tables(kind).pages); const columns = getTableColumns(tables(kind).pages);
const [existingRows] = await tx.execute( const current = rowsFrom<Record<string, unknown>>(
await tx.execute(
sql`SELECT * FROM ${tables(kind).pages} WHERE id=${id} FOR UPDATE`, sql`SELECT * FROM ${tables(kind).pages} WHERE id=${id} FOR UPDATE`,
); ),
const current = (existingRows as unknown as Record<string, unknown>[])[0]; )[0];
if (!current) throw new CatalogNotFound("Catalog page not found"); if (!current) throw new CatalogNotFound("Catalog page not found");
for (const [key, value] of Object.entries(expected)) { for (const [key, value] of Object.entries(expected)) {
if (!Object.hasOwn(data, key) || !Object.hasOwn(columns, key)) continue; if (!Object.hasOwn(data, key) || !Object.hasOwn(columns, key)) continue;
@@ -107,7 +110,7 @@ export async function createPageCommand(
const [result] = await tx.execute( const [result] = await tx.execute(
sql`INSERT INTO ${tables(kind).pages} SET ${sql.join(assignments, sql`, `)}`, sql`INSERT INTO ${tables(kind).pages} SET ${sql.join(assignments, sql`, `)}`,
); );
return Number((result as unknown as { insertId: number }).insertId); return Number(result.insertId);
}); });
} }
export async function reorderPagesCommand( export async function reorderPagesCommand(
+8 -4
View File
@@ -9,8 +9,8 @@ const state = vi.hoisted(() => ({
offers: [] as unknown[], offers: [] as unknown[],
parents: [] as unknown[], parents: [] as unknown[],
})); }));
vi.mock("@/lib/db", () => ({ vi.mock("@/lib/db", async () => {
db: { const db = {
execute: vi.fn(async (query: SQL) => { execute: vi.fn(async (query: SQL) => {
const result = new MySqlDialect().sqlToQuery(query); const result = new MySqlDialect().sqlToQuery(query);
state.queries.push(result); state.queries.push(result);
@@ -19,8 +19,12 @@ vi.mock("@/lib/db", () => ({
if (result.sql.includes("WHERE id IN")) return [state.parents]; if (result.sql.includes("WHERE id IN")) return [state.parents];
return [state.categories]; return [state.categories];
}), }),
}, };
})); return {
db,
...(await import("@/test/db-helpers")).createDbHelpers(db.execute),
};
});
import { searchCatalog } from "./search"; import { searchCatalog } from "./search";
+9 -15
View File
@@ -1,6 +1,6 @@
import "server-only"; import "server-only";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { db } from "@/lib/db"; import { queryRows } from "@/lib/db";
import { type CatalogSearchResult, parseCatalogSearch } from "../domain/search"; import { type CatalogSearchResult, parseCatalogSearch } from "../domain/search";
const LIMIT = 25; const LIMIT = 25;
@@ -29,18 +29,15 @@ export async function searchCatalog(
); );
const number = input.numericId; const number = input.numericId;
const [categoryData, furnitureData] = await Promise.all([ const [categoryData, furnitureData] = await Promise.all([
db.execute( queryRows<PageRow>(
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`, 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( queryRows<FurnitureRow>(
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`, 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 categories = categoryData.slice(0, 12);
const furniture = (furnitureData[0] as unknown as FurnitureRow[]).slice( const furniture = furnitureData.slice(0, LIMIT);
0,
LIMIT,
);
const predicates = [sql`i.catalog_name LIKE ${input.pattern} ESCAPE '='`]; const predicates = [sql`i.catalog_name LIKE ${input.pattern} ESCAPE '='`];
if (number !== null) { if (number !== null) {
predicates.push(sql`i.id=${number}`, sql`i.page_id=${String(number)}`); predicates.push(sql`i.id=${number}`, sql`i.page_id=${String(number)}`);
@@ -51,13 +48,10 @@ export async function searchCatalog(
predicates.push( predicates.push(
sql`FIND_IN_SET(${String(base.id)}, REPLACE(i.item_ids, ';', ',')) > 0`, sql`FIND_IN_SET(${String(base.id)}, REPLACE(i.item_ids, ';', ',')) > 0`,
); );
const [offerData] = await db.execute( const offerData = await queryRows<OfferRow>(
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}`, 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( const matches = offerData.slice(0, LIMIT - categories.length);
0,
LIMIT - categories.length,
);
const pageMap = new Map<number, PageRow>(); const pageMap = new Map<number, PageRow>();
for (const page of categories) for (const page of categories)
pageMap.set(Number(page.id), { pageMap.set(Number(page.id), {
@@ -79,11 +73,11 @@ export async function searchCatalog(
.slice(0, LIMIT); .slice(0, LIMIT);
if (!ids.length) break; if (!ids.length) break;
for (const id of ids) attempted.add(id); for (const id of ids) attempted.add(id);
const [parents] = await db.execute( const parents = await queryRows<PageRow>(
sql`SELECT id, caption, parent_id AS parentId FROM ${pages} WHERE id IN (${sql.join(ids, sql`, `)}) LIMIT 25`, sql`SELECT id, caption, parent_id AS parentId FROM ${pages} WHERE id IN (${sql.join(ids, sql`, `)}) LIMIT 25`,
); );
frontier = []; frontier = [];
for (const raw of parents as unknown as PageRow[]) { for (const raw of parents) {
const page = { const page = {
...raw, ...raw,
id: Number(raw.id), id: Number(raw.id),
+4 -1
View File
@@ -2,7 +2,10 @@ import type { SQL } from "drizzle-orm";
import { MySqlDialect } from "drizzle-orm/mysql-core"; import { MySqlDialect } from "drizzle-orm/mysql-core";
import { expect, it, vi } from "vitest"; import { expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({ db: {} })); vi.mock("@/lib/db", async () => ({
rowsFrom: (await import("@/test/db-helpers")).rowsFrom,
db: {},
}));
import { applyHistory, type HistoryTransaction } from "./server"; import { applyHistory, type HistoryTransaction } from "./server";
+5 -1
View File
@@ -2,7 +2,11 @@ import type { SQL } from "drizzle-orm";
import { MySqlDialect } from "drizzle-orm/mysql-core"; import { MySqlDialect } from "drizzle-orm/mysql-core";
import { describe, expect, it, vi } from "vitest"; import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", async () => ({ ...(await import("@/db/schema")), db: {} })); vi.mock("@/lib/db", async () => ({
...(await import("@/db/schema")),
rowsFrom: (await import("@/test/db-helpers")).rowsFrom,
db: {},
}));
import { historyChanges, sameSnapshot } from "./model"; import { historyChanges, sameSnapshot } from "./model";
import { import {
+10 -8
View File
@@ -7,7 +7,7 @@ import {
CatalogPagesBc, CatalogPagesBc,
WebsiteArticles, WebsiteArticles,
} from "@/db/schema"; } from "@/db/schema";
import type { db } from "@/lib/db"; import { type db, rowsFrom } from "@/lib/db";
import { getOperationContext } from "@/lib/foundation/request-context"; import { getOperationContext } from "@/lib/foundation/request-context";
import { type HistorySnapshot, sameSnapshot } from "./model"; import { type HistorySnapshot, sameSnapshot } from "./model";
export type HistoryKind = export type HistoryKind =
@@ -84,10 +84,11 @@ export async function historySnapshot(
(key) => (key) =>
sql`${sql.identifier(columns[key as keyof typeof columns].name)} AS ${sql.identifier(key)}`, sql`${sql.identifier(columns[key as keyof typeof columns].name)} AS ${sql.identifier(key)}`,
); );
const [rows] = await tx.execute( const row = rowsFrom<Record<string, unknown>>(
await tx.execute(
sql`SELECT ${sql.join(selected, sql`, `)} FROM ${table} WHERE id=${id} FOR UPDATE`, sql`SELECT ${sql.join(selected, sql`, `)} FROM ${table} WHERE id=${id} FOR UPDATE`,
); ),
const row = (rows as unknown as Record<string, unknown>[])[0]; )[0];
if (!row) throw Error("unavailable"); if (!row) throw Error("unavailable");
return Object.fromEntries( return Object.fromEntries(
fields.map((key) => { fields.map((key) => {
@@ -223,11 +224,12 @@ export async function lockOfferHistoryPages(
].sort((a, b) => a - b); ].sort((a, b) => a - b);
if (ids.some((id) => !Number.isSafeInteger(id) || id < 1)) if (ids.some((id) => !Number.isSafeInteger(id) || id < 1))
throw Error("unavailable"); throw Error("unavailable");
const [rows] = await tx.execute(
sql`SELECT id FROM catalog_pages WHERE id IN (${sql.join(ids, sql`, `)}) ORDER BY id FOR UPDATE`,
);
const found = new Set( const found = new Set(
(rows as unknown as Array<{ id: number }>).map((row) => Number(row.id)), rowsFrom<{ id: number }>(
await tx.execute(
sql`SELECT id FROM catalog_pages WHERE id IN (${sql.join(ids, sql`, `)}) ORDER BY id FOR UPDATE`,
),
).map((row) => Number(row.id)),
); );
if (ids.some((id) => !found.has(id))) throw Error("unavailable"); if (ids.some((id) => !found.has(id))) throw Error("unavailable");
} }
@@ -2,7 +2,10 @@ import { MySqlDialect } from "drizzle-orm/mysql-core";
import { beforeEach, expect, it, vi } from "vitest"; import { beforeEach, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({ execute: vi.fn(), error: vi.fn() })); const mocks = vi.hoisted(() => ({ execute: vi.fn(), error: vi.fn() }));
vi.mock("@/lib/db", () => ({ db: { execute: mocks.execute } })); vi.mock("@/lib/db", async () => ({
...(await import("@/test/db-helpers")).createDbHelpers(mocks.execute),
db: { execute: mocks.execute },
}));
vi.mock("@/lib/logger", () => ({ logger: { error: mocks.error } })); vi.mock("@/lib/logger", () => ({ logger: { error: mocks.error } }));
import { effectRepository, listEffects } from "./server"; import { effectRepository, listEffects } from "./server";
+16 -19
View File
@@ -1,7 +1,7 @@
import "server-only"; import "server-only";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { db } from "@/lib/db"; import { db, queryRows, rowsFrom } from "@/lib/db";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { isDeliveryReference } from "./delivery-diagnostics"; import { isDeliveryReference } from "./delivery-diagnostics";
import { import {
@@ -26,15 +26,14 @@ export async function runOperation<T>(
await tx.execute( await tx.execute(
sql`INSERT INTO cms_operations (id,actor_id,kind,request_key,request_hash) VALUES (${randomUUID()},${input.actorId},${input.kind},${input.key},${hash}) ON DUPLICATE KEY UPDATE id=id`, sql`INSERT INTO cms_operations (id,actor_id,kind,request_key,request_hash) VALUES (${randomUUID()},${input.actorId},${input.kind},${input.key},${hash}) ON DUPLICATE KEY UPDATE id=id`,
); );
const [rows] = await tx.execute( const row = rowsFrom<{
sql`SELECT id,request_hash AS requestHash,result_json AS resultJson FROM cms_operations WHERE actor_id=${input.actorId} AND kind=${input.kind} AND request_key=${input.key} FOR UPDATE`,
);
const row = (
rows as unknown as Array<{
id: string; id: string;
requestHash: string; requestHash: string;
resultJson: string | null; resultJson: string | null;
}> }>(
await tx.execute(
sql`SELECT id,request_hash AS requestHash,result_json AS resultJson FROM cms_operations WHERE actor_id=${input.actorId} AND kind=${input.kind} AND request_key=${input.key} FOR UPDATE`,
),
)[0]; )[0];
if (!row) throw new Error("Operation unavailable"); if (!row) throw new Error("Operation unavailable");
if (row.requestHash !== hash) throw new OperationConflict(); if (row.requestHash !== hash) throw new OperationConflict();
@@ -61,16 +60,15 @@ export async function enqueueEffect(
export const effectRepository = { export const effectRepository = {
async claim(): Promise<EffectClaim | null> { async claim(): Promise<EffectClaim | null> {
return db.transaction(async (tx) => { return db.transaction(async (tx) => {
const [rows] = await tx.execute( const row = rowsFrom<{
sql`SELECT id,operation_id AS operationId,topic,attempts FROM cms_outbox WHERE (status='pending' AND available_at<=UTC_TIMESTAMP(3)) OR (status='running' AND lease_until<UTC_TIMESTAMP(3)) ORDER BY available_at,id LIMIT 1 FOR UPDATE`,
);
const row = (
rows as unknown as Array<{
id: string; id: string;
operationId: string; operationId: string;
topic: EffectTopic; topic: EffectTopic;
attempts: number; attempts: number;
}> }>(
await tx.execute(
sql`SELECT id,operation_id AS operationId,topic,attempts FROM cms_outbox WHERE (status='pending' AND available_at<=UTC_TIMESTAMP(3)) OR (status='running' AND lease_until<UTC_TIMESTAMP(3)) ORDER BY available_at,id LIMIT 1 FOR UPDATE`,
),
)[0]; )[0];
if (!row) return null; if (!row) return null;
const token = randomUUID(); const token = randomUUID();
@@ -105,10 +103,7 @@ export const effectRepository = {
export async function listEffects(id?: string) { export async function listEffects(id?: string) {
if (id !== undefined && !isDeliveryReference(id)) return []; if (id !== undefined && !isDeliveryReference(id)) return [];
const filter = id === undefined ? sql`` : sql`WHERE e.id=${id}`; const filter = id === undefined ? sql`` : sql`WHERE e.id=${id}`;
const [rows] = await db.execute( return queryRows<{
sql`SELECT e.id,e.operation_id AS operationId,e.topic,e.status,e.attempts,e.last_error AS lastError,e.created_at AS createdAt,e.available_at AS availableAt,o.actor_id AS actorId,o.kind,o.result_json AS resultJson FROM cms_outbox e JOIN cms_operations o ON o.id=e.operation_id ${filter} ORDER BY e.created_at DESC,e.id DESC LIMIT 100`,
);
return rows as unknown as Array<{
id: string; id: string;
operationId: string; operationId: string;
topic: EffectTopic; topic: EffectTopic;
@@ -120,7 +115,9 @@ export async function listEffects(id?: string) {
resultJson: string | null; resultJson: string | null;
actorId: number; actorId: number;
kind: string; kind: string;
}>; }>(
sql`SELECT e.id,e.operation_id AS operationId,e.topic,e.status,e.attempts,e.last_error AS lastError,e.created_at AS createdAt,e.available_at AS availableAt,o.actor_id AS actorId,o.kind,o.result_json AS resultJson FROM cms_outbox e JOIN cms_operations o ON o.id=e.operation_id ${filter} ORDER BY e.created_at DESC,e.id DESC LIMIT 100`,
);
} }
export async function retryEffect(id: string) { export async function retryEffect(id: string) {
if ( if (
@@ -130,6 +127,6 @@ export async function retryEffect(id: string) {
const [result] = await db.execute( const [result] = await db.execute(
sql`UPDATE cms_outbox SET status='pending',attempts=0,available_at=UTC_TIMESTAMP(3),last_error=NULL WHERE id=${id} AND status='failed'`, sql`UPDATE cms_outbox SET status='pending',attempts=0,available_at=UTC_TIMESTAMP(3),last_error=NULL WHERE id=${id} AND status='failed'`,
); );
if ((result as unknown as { affectedRows: number }).affectedRows !== 1) if (result.affectedRows !== 1)
throw new Error("Delivery is no longer available for retry"); throw new Error("Delivery is no longer available for retry");
} }
+1 -1
View File
@@ -18,7 +18,7 @@ vi.mock("@/lib/permissions", async () => ({
...(await import("@/lib/permission-slugs")), ...(await import("@/lib/permission-slugs")),
canAccess: (_: unknown, slug: string) => state.allowed.has(slug), canAccess: (_: unknown, slug: string) => state.allowed.has(slug),
})); }));
vi.mock("@/lib/db", () => { vi.mock("@/lib/db", async () => {
const tables = Object.fromEntries( const tables = Object.fromEntries(
[ [
"User", "User",
+4 -7
View File
@@ -9,7 +9,7 @@ import {
workerState, workerState,
} from "@/lib/admin/installation-state"; } from "@/lib/admin/installation-state";
import { fetchOpsHealth } from "@/lib/admin/ops-health"; import { fetchOpsHealth } from "@/lib/admin/ops-health";
import { db } from "@/lib/db"; import { queryRows } from "@/lib/db";
import { redis } from "@/lib/redis"; import { redis } from "@/lib/redis";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
@@ -36,13 +36,10 @@ export async function inspectInstallation() {
.map((name) => name.slice(0, -4)), .map((name) => name.slice(0, -4)),
) )
.catch(() => []), .catch(() => []),
db queryRows<{ migration: string }>(
.execute(sql`SELECT migration FROM cms_migrations`) sql`SELECT migration FROM cms_migrations`,
.then(([rows]) =>
(rows as unknown as { migration: string }[]).map(
(row) => row.migration,
),
) )
.then((rows) => rows.map((row) => row.migration))
.catch(() => null), .catch(() => null),
redis redis
? redis.get("cms:jobs-worker:heartbeat").catch(() => null) ? redis.get("cms:jobs-worker:heartbeat").catch(() => null)
+2 -12
View File
@@ -1,7 +1,7 @@
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { checkLogin } from "@/lib/auth/password"; import { checkLogin } from "@/lib/auth/password";
import { cachedQuery, invalidateKey } from "@/lib/cached-db"; import { cachedQuery, invalidateKey } from "@/lib/cached-db";
import { db } from "@/lib/db"; import { queryRows } from "@/lib/db";
import { siteSettings } from "@/lib/services/site-settings"; import { siteSettings } from "@/lib/services/site-settings";
export interface LoginUser { export interface LoginUser {
@@ -45,7 +45,7 @@ export async function getLoginUser(
return cachedQuery<LoginUser | null>( return cachedQuery<LoginUser | null>(
`login:user:${username}`, `login:user:${username}`,
async () => { async () => {
const [result] = await db.execute<{ const rows = await queryRows<{
id: number; id: number;
username: string; username: string;
password: string | null; password: string | null;
@@ -63,16 +63,6 @@ export async function getLoginUser(
WHERE username = ${username} WHERE username = ${username}
LIMIT 1 LIMIT 1
`); `);
const rows = result as unknown as Array<{
id: number;
username: string;
password: string | null;
rank: number;
mail: string | null;
mail_verified: string | null;
two_factor_confirmed_at: string | null;
two_factor_secret: string | null;
}>;
return rows.length > 0 return rows.length > 0
? { ? {
id: rows[0].id, id: rows[0].id,
+6 -1
View File
@@ -5,6 +5,7 @@ const state = vi.hoisted(() => ({
queries: [] as string[], queries: [] as string[],
})); }));
vi.mock("@/lib/db", async () => { vi.mock("@/lib/db", async () => {
const { createDbHelpers } = await import("@/test/db-helpers");
const schema = await import("@/db/schema"); const schema = await import("@/db/schema");
const { drizzle } = await import("drizzle-orm/mysql-proxy"); const { drizzle } = await import("drizzle-orm/mysql-proxy");
const db = drizzle(async (sql) => { const db = drizzle(async (sql) => {
@@ -27,9 +28,13 @@ vi.mock("@/lib/db", async () => {
} }
return { rows: [] }; return { rows: [] };
}); });
const mockDb = Object.assign(db, {
execute: async () => [[{ cnt: 0n }], []],
});
return { return {
...schema, ...schema,
db: Object.assign(db, { execute: async () => [[{ cnt: 0n }], []] }), ...createDbHelpers(mockDb.execute),
db: mockDb,
}; };
}); });
vi.mock("@/lib/auth", () => ({ auth: async () => ({ user: { id: "77" } }) })); vi.mock("@/lib/auth", () => ({ auth: async () => ({ user: { id: "77" } }) }));
+22
View File
@@ -1,4 +1,6 @@
import type { SQL } from "drizzle-orm";
import { drizzle } from "drizzle-orm/mysql2"; import { drizzle } from "drizzle-orm/mysql2";
import type { ResultSetHeader } from "mysql2";
import mysql from "mysql2/promise"; import mysql from "mysql2/promise";
import * as relations from "@/db/relations"; import * as relations from "@/db/relations";
import * as schema from "@/db/schema"; import * as schema from "@/db/schema";
@@ -62,6 +64,26 @@ export const db = globalForDb.db ?? createDb();
if (env.NODE_ENV !== "production") globalForDb.db = db; if (env.NODE_ENV !== "production") globalForDb.db = db;
export function rowsFrom<T>(result: unknown): T[] {
const [first] = result as [unknown, unknown];
return (Array.isArray(first) ? first : []) as T[];
}
export async function queryRows<T>(query: SQL | string): Promise<T[]> {
return rowsFrom<T>(await db.execute(query));
}
export async function execResult(
query: SQL | string,
): Promise<ResultSetHeader> {
const [first] = (await db.execute(query)) as unknown as [unknown, unknown];
return first as ResultSetHeader;
}
export async function affectedRows(query: SQL | string): Promise<number> {
return (await execResult(query)).affectedRows;
}
export type Db = ReturnType<typeof createDb>; export type Db = ReturnType<typeof createDb>;
export * from "@/db/schema"; export * from "@/db/schema";
+3 -7
View File
@@ -6,7 +6,7 @@ import { isDynamicSuperAdmin } from "./admin/authorization-policy";
import { resolveAuthorizationState } from "./admin/rank-authority"; import { resolveAuthorizationState } from "./admin/rank-authority";
import { auth } from "./auth"; import { auth } from "./auth";
import { sessionUserId } from "./auth/session-user"; import { sessionUserId } from "./auth/session-user";
import { db, User } from "./db"; import { db, queryRows, User } from "./db";
import { redirectSafe } from "./foundation/security"; import { redirectSafe } from "./foundation/security";
// Re-export PERMS from the standalone file (safe for client components) // Re-export PERMS from the standalone file (safe for client components)
@@ -33,7 +33,7 @@ function createEmptySet(): PermissionSet {
*/ */
const getCachedPermissionSlugs = unstable_cache( const getCachedPermissionSlugs = unstable_cache(
async (userId: number, rank: number): Promise<string[]> => { async (userId: number, rank: number): Promise<string[]> => {
const [result] = await db.execute<{ slug: string }>(sql` const rows = await queryRows<{ slug: string }>(sql`
SELECT DISTINCT p.slug SELECT DISTINCT p.slug
FROM acl_model_permissions mp FROM acl_model_permissions mp
JOIN acl_permissions p ON p.id = mp.permission_id JOIN acl_permissions p ON p.id = mp.permission_id
@@ -48,7 +48,6 @@ const getCachedPermissionSlugs = unstable_cache(
WHERE ar.slug = ${`rank_${rank}`} WHERE ar.slug = ${`rank_${rank}`}
) )
`); `);
const rows = result as unknown as { slug: string }[];
return rows.map((r) => r.slug); return rows.map((r) => r.slug);
}, },
["user-permissions"], ["user-permissions"],
@@ -120,7 +119,7 @@ const getCurrentAuthorizationState = cache(async (userId: number) =>
// Prefer the highest rank actually held by a user. Unused high IDs in // Prefer the highest rank actually held by a user. Unused high IDs in
// permission_ranks (common on Habbo DBs) would otherwise lock the real // permission_ranks (common on Habbo DBs) would otherwise lock the real
// owner out of super-admin / permissions management. // owner out of super-admin / permissions management.
const [result] = await db.execute<{ const rows = await queryRows<{
highest_rank: number | bigint | null; highest_rank: number | bigint | null;
}>(sql` }>(sql`
SELECT COALESCE( SELECT COALESCE(
@@ -132,9 +131,6 @@ const getCurrentAuthorizationState = cache(async (userId: number) =>
(SELECT MAX(id) FROM permission_ranks) (SELECT MAX(id) FROM permission_ranks)
) AS highest_rank ) AS highest_rank
`); `);
const rows = result as unknown as {
highest_rank: number | bigint | null;
}[];
return rows[0]?.highest_rank == null return rows[0]?.highest_rank == null
? null ? null
: Number(rows[0].highest_rank); : Number(rows[0].highest_rank);
+27 -52
View File
@@ -1,6 +1,6 @@
import { readdir } from "node:fs/promises"; import { readdir } from "node:fs/promises";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { db } from "@/lib/db"; import { queryRows } from "@/lib/db";
import { fetchSourceFurnidata } from "@/lib/services/clone-import"; import { fetchSourceFurnidata } from "@/lib/services/clone-import";
import { listSources } from "@/lib/services/clone-sources"; import { listSources } from "@/lib/services/clone-sources";
import { import {
@@ -215,20 +215,10 @@ export async function runCatalogAudit(
interaction_type: string; interaction_type: string;
}>; }>;
try { try {
const [rows] = (await db.execute(sql` items = await queryRows<(typeof items)[number]>(sql`
SELECT id, item_name, public_name, type, interaction_type SELECT id, item_name, public_name, type, interaction_type
FROM items_base ORDER BY id FROM items_base ORDER BY id
`)) as unknown as [ `);
Array<{
id: number;
item_name: string;
public_name: string;
type: string;
interaction_type: string;
}>,
unknown,
];
items = rows;
} catch (err) { } catch (err) {
onEvent?.({ onEvent?.({
type: "error", type: "error",
@@ -262,13 +252,9 @@ export async function runCatalogAudit(
catalog_name: string; catalog_name: string;
}>; }>;
try { try {
const [rows] = (await db.execute(sql` catalogItems = await queryRows<(typeof catalogItems)[number]>(sql`
SELECT id, item_ids, catalog_name FROM catalog_items ORDER BY id SELECT id, item_ids, catalog_name FROM catalog_items ORDER BY id
`)) as unknown as [ `);
Array<{ id: number; item_ids: string; catalog_name: string }>,
unknown,
];
catalogItems = rows;
} catch (err) { } catch (err) {
onEvent?.({ onEvent?.({
type: "error", type: "error",
@@ -625,12 +611,13 @@ export async function runCatalogAudit(
orphanedCleaned = orphanResult.cleaned; orphanedCleaned = orphanResult.cleaned;
// Re-derive orphaned_catalog issues from the fixed data. // Re-derive orphaned_catalog issues from the fixed data.
const [afterCatalog] = (await db.execute(sql` const afterCatalog = await queryRows<{
id: number;
item_ids: string;
catalog_name: string;
}>(sql`
SELECT id, item_ids, catalog_name FROM catalog_items ORDER BY id SELECT id, item_ids, catalog_name FROM catalog_items ORDER BY id
`)) as unknown as [ `);
Array<{ id: number; item_ids: string; catalog_name: string }>,
unknown,
];
const orphanedCatalogIds = new Set<number>(); const orphanedCatalogIds = new Set<number>();
for (const ci of afterCatalog) { for (const ci of afterCatalog) {
const ids = ci.item_ids.split(";").map(Number).filter(Boolean); const ids = ci.item_ids.split(";").map(Number).filter(Boolean);
@@ -650,9 +637,9 @@ export async function runCatalogAudit(
remappedReferences = duplicateResult.remapped; remappedReferences = duplicateResult.remapped;
// Re-derive duplicate_classname issues from the fixed data. // Re-derive duplicate_classname issues from the fixed data.
const [afterItems] = (await db.execute(sql` const afterItems = await queryRows<{ item_name: string }>(sql`
SELECT item_name FROM items_base SELECT item_name FROM items_base
`)) as unknown as [Array<{ item_name: string }>, unknown]; `);
const afterCount = new Map<string, number>(); const afterCount = new Map<string, number>();
for (const row of afterItems) { for (const row of afterItems) {
afterCount.set(row.item_name, (afterCount.get(row.item_name) ?? 0) + 1); afterCount.set(row.item_name, (afterCount.get(row.item_name) ?? 0) + 1);
@@ -683,27 +670,19 @@ export async function runCatalogAudit(
// state, otherwise the generated catalog SQL would insert orphaned // state, otherwise the generated catalog SQL would insert orphaned
// catalog_items rows (item_ids pointing at deleted sprite ids). // catalog_items rows (item_ids pointing at deleted sprite ids).
if (duplicateRowsRemoved > 0 || orphanedRemoved > 0) { if (duplicateRowsRemoved > 0 || orphanedRemoved > 0) {
const [postItems] = (await db.execute(sql` const postItems = await queryRows<(typeof items)[number]>(sql`
SELECT id, item_name, public_name, type, interaction_type SELECT id, item_name, public_name, type, interaction_type
FROM items_base ORDER BY id FROM items_base ORDER BY id
`)) as unknown as [ `);
Array<{
id: number;
item_name: string;
public_name: string;
type: string;
interaction_type: string;
}>,
unknown,
];
items = postItems; items = postItems;
const [postCatalog] = (await db.execute(sql` const postCatalog = await queryRows<{
id: number;
item_ids: string;
catalog_name: string;
}>(sql`
SELECT id, item_ids, catalog_name FROM catalog_items ORDER BY id SELECT id, item_ids, catalog_name FROM catalog_items ORDER BY id
`)) as unknown as [ `);
Array<{ id: number; item_ids: string; catalog_name: string }>,
unknown,
];
const postCatalogIds = new Set<number>(); const postCatalogIds = new Set<number>();
for (const ci of postCatalog) { for (const ci of postCatalog) {
const ids = ci.item_ids.split(";").map(Number).filter(Boolean); const ids = ci.item_ids.split(";").map(Number).filter(Boolean);
@@ -971,13 +950,13 @@ export async function inspectLiveCatalogIntegrity(catalog: "normal" | "bc") {
catalog === "bc" ? "catalog_items_bc" : "catalog_items", catalog === "bc" ? "catalog_items_bc" : "catalog_items",
); );
const [pageResult, itemResult, offerResult] = await Promise.all([ const [pageResult, itemResult, offerResult] = await Promise.all([
db.execute( queryRows<import("./catalog-integrity").IntegrityPage>(
sql`SELECT id, parent_id AS parentId, caption FROM ${pagesTable} ORDER BY id`, sql`SELECT id, parent_id AS parentId, caption FROM ${pagesTable} ORDER BY id`,
), ),
db.execute( queryRows<import("./catalog-integrity").IntegrityItem>(
sql`SELECT id, item_name, public_name, type, interaction_type FROM items_base ORDER BY id`, sql`SELECT id, item_name, public_name, type, interaction_type FROM items_base ORDER BY id`,
), ),
db.execute( queryRows<import("./catalog-integrity").IntegrityOffer>(
sql`SELECT id, page_id AS pageId, item_ids AS itemIds, catalog_name AS name, ${catalog === "bc" ? sql`NULL` : sql`offer_id`} AS offerId FROM ${offersTable} ORDER BY id`, sql`SELECT id, page_id AS pageId, item_ids AS itemIds, catalog_name AS name, ${catalog === "bc" ? sql`NULL` : sql`offer_id`} AS offerId FROM ${offersTable} ORDER BY id`,
), ),
]); ]);
@@ -990,13 +969,9 @@ export async function inspectLiveCatalogIntegrity(catalog: "normal" | "bc") {
} }
return inspectCatalogIntegrity({ return inspectCatalogIntegrity({
catalog, catalog,
pages: pages: pageResult,
pageResult[0] as unknown as import("./catalog-integrity").IntegrityPage[], items: itemResult,
items: offers: offerResult.map((row) => ({
itemResult[0] as unknown as import("./catalog-integrity").IntegrityItem[],
offers: (
offerResult[0] as unknown as import("./catalog-integrity").IntegrityOffer[]
).map((row) => ({
...row, ...row,
pageId: Number(row.pageId), pageId: Number(row.pageId),
offerId: row.offerId === null ? null : Number(row.offerId), offerId: row.offerId === null ? null : Number(row.offerId),
@@ -8,7 +8,10 @@ const mocks = vi.hoisted(() => ({
repair: vi.fn(), repair: vi.fn(),
})); }));
vi.mock("node:fs/promises", () => ({ readdir: mocks.readdir })); vi.mock("node:fs/promises", () => ({ readdir: mocks.readdir }));
vi.mock("@/lib/db", () => ({ db: { execute: mocks.execute } })); vi.mock("@/lib/db", async () => ({
...(await import("@/test/db-helpers")).createDbHelpers(mocks.execute),
db: { execute: mocks.execute },
}));
vi.mock("@/lib/services/clone-import", () => ({})); vi.mock("@/lib/services/clone-import", () => ({}));
vi.mock("@/lib/services/clone-sources", () => ({})); vi.mock("@/lib/services/clone-sources", () => ({}));
vi.mock("@/lib/services/furni-asset-dirs", () => ({ vi.mock("@/lib/services/furni-asset-dirs", () => ({
+10 -15
View File
@@ -1,7 +1,7 @@
import { promises as fs } from "node:fs"; import { promises as fs } from "node:fs";
import { asc, sql } from "drizzle-orm"; import { asc, sql } from "drizzle-orm";
import { numericValue } from "@/features/catalog/domain/offer-input"; import { numericValue } from "@/features/catalog/domain/offer-input";
import { CatalogPages, db } from "@/lib/db"; import { CatalogPages, db, queryRows } from "@/lib/db";
import { getFurnitureDataPath } from "@/lib/services/furni-data"; import { getFurnitureDataPath } from "@/lib/services/furni-data";
import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel"; import { getHabboGamedataHotel } from "@/lib/services/habbo-gamedata-hotel";
@@ -140,12 +140,12 @@ export async function loadCatalogItemsData(
// Load items via raw query to work around pageId Int vs VARCHAR mismatch // Load items via raw query to work around pageId Int vs VARCHAR mismatch
const pageIdStr = String(pageId); const pageIdStr = String(pageId);
// CAST: live Habbo DBs often store page_id as VARCHAR while schema maps Int. // CAST: live Habbo DBs often store page_id as VARCHAR while schema maps Int.
const [rawItems] = (await db.execute(sql` const rawItems = await queryRows<Record<string, unknown>>(sql`
SELECT * FROM catalog_items SELECT * FROM catalog_items
WHERE CAST(page_id AS CHAR) = ${pageIdStr} WHERE CAST(page_id AS CHAR) = ${pageIdStr}
ORDER BY order_number ASC, id ASC ORDER BY order_number ASC, id ASC
`)) as unknown as [Record<string, unknown>[], unknown[]]; `);
const items: RawItem[] = rawItems.map((r: Record<string, unknown>) => ({ const items: RawItem[] = rawItems.map((r) => ({
id: Number(r.id), id: Number(r.id),
itemIds: String(r.item_ids ?? ""), itemIds: String(r.item_ids ?? ""),
pageId: numericValue(r.page_id, 0), pageId: numericValue(r.page_id, 0),
@@ -169,14 +169,9 @@ export async function loadCatalogItemsData(
.select({ id: CatalogPages.id, caption: CatalogPages.caption }) .select({ id: CatalogPages.id, caption: CatalogPages.caption })
.from(CatalogPages) .from(CatalogPages)
.orderBy(asc(CatalogPages.caption)), .orderBy(asc(CatalogPages.caption)),
db queryRows<{ interaction_type: string }>(sql`
.execute(sql`
SELECT DISTINCT interaction_type FROM items_base ORDER BY interaction_type ASC SELECT DISTINCT interaction_type FROM items_base ORDER BY interaction_type ASC
`) `),
.then(
(result) =>
(result as unknown as [{ interaction_type: string }[], unknown[]])[0],
),
getHabboGamedataHotel(), getHabboGamedataHotel(),
]); ]);
@@ -199,9 +194,9 @@ export async function loadCatalogItemsData(
// Raw query to work around ENUM('0','1') columns returning numeric values. // Raw query to work around ENUM('0','1') columns returning numeric values.
// baseItemIds is built from numeric splits + filter(n > 0); joined via sql.join. // baseItemIds is built from numeric splits + filter(n > 0); joined via sql.join.
const [baseItemRows] = const baseItemRows =
baseItemIds.length > 0 baseItemIds.length > 0
? ((await db.execute(sql` ? await queryRows<Record<string, unknown>>(sql`
SELECT id, sprite_id, public_name, item_name, type, width, length, SELECT id, sprite_id, public_name, item_name, type, width, length,
stack_height, allow_stack, allow_sit, allow_lay, allow_walk, stack_height, allow_stack, allow_sit, allow_lay, allow_walk,
allow_gift, allow_trade, allow_recycle, allow_marketplace_sell, allow_gift, allow_trade, allow_recycle, allow_marketplace_sell,
@@ -209,8 +204,8 @@ export async function loadCatalogItemsData(
vending_ids, customparams, effect_id_male, effect_id_female, vending_ids, customparams, effect_id_male, effect_id_female,
clothing_on_walk clothing_on_walk
FROM items_base WHERE id IN (${sql.join(baseItemIds, sql`, `)}) FROM items_base WHERE id IN (${sql.join(baseItemIds, sql`, `)})
`)) as unknown as [Record<string, unknown>[], unknown[]]) `)
: [[], []]; : [];
const baseItems: BaseItem[] = baseItemRows.map((r) => ({ const baseItems: BaseItem[] = baseItemRows.map((r) => ({
id: Number(r.id), id: Number(r.id),
@@ -7,7 +7,8 @@ const mocks = vi.hoisted(() => ({
insert: vi.fn(), insert: vi.fn(),
values: vi.fn(), values: vi.fn(),
})); }));
vi.mock("@/lib/db", () => ({ vi.mock("@/lib/db", async () => ({
...(await import("@/test/db-helpers")).createDbHelpers(mocks.execute),
AdminAuditLog: "audit", AdminAuditLog: "audit",
db: { execute: mocks.execute, transaction: mocks.transaction }, db: { execute: mocks.execute, transaction: mocks.transaction },
})); }));
+31 -31
View File
@@ -1,6 +1,6 @@
import { createHash } from "node:crypto"; import { createHash } from "node:crypto";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { AdminAuditLog, db } from "@/lib/db"; import { AdminAuditLog, db, execResult, queryRows, rowsFrom } from "@/lib/db";
import { import {
readFurniData, readFurniData,
withFurniDataLock, withFurniDataLock,
@@ -329,26 +329,28 @@ export async function generateCatalogSql(
let nextPageId = 0; let nextPageId = 0;
if (organizeSql) { if (organizeSql) {
parentPageId = await getOrCreateImportedParentPage(); parentPageId = await getOrCreateImportedParentPage();
const [maxPageResult] = (await db.execute(sql` const maxPageResult = await queryRows<{
maxId: number | bigint | null;
}>(sql`
SELECT MAX(id) as maxId FROM catalog_pages SELECT MAX(id) as maxId FROM catalog_pages
`)) as unknown as [Array<{ maxId: number | bigint | null }>, unknown]; `);
nextPageId = Number(maxPageResult[0]?.maxId ?? 0) + 1; nextPageId = Number(maxPageResult[0]?.maxId ?? 0) + 1;
} }
// The generation pass does not INSERT, so MAX(id) would never advance and // The generation pass does not INSERT, so MAX(id) would never advance and
// per-entry allocateCatalogItemId would hand out the same id every time. // per-entry allocateCatalogItemId would hand out the same id every time.
// Read the current max once and hand out sequential ids from there. // Read the current max once and hand out sequential ids from there.
const [maxIdResult] = (await db.execute(sql` const maxIdResult = await queryRows<{ maxId: number | bigint | null }>(sql`
SELECT MAX(id) as maxId FROM catalog_items SELECT MAX(id) as maxId FROM catalog_items
`)) as unknown as [Array<{ maxId: number | bigint | null }>, unknown]; `);
let nextId = Number(maxIdResult[0]?.maxId ?? 0) + 1; let nextId = Number(maxIdResult[0]?.maxId ?? 0) + 1;
// Skip entries whose sprite id no longer exists in items_base (e.g. because // Skip entries whose sprite id no longer exists in items_base (e.g. because
// a duplicate-classname merge ran between the audit snapshot and this // a duplicate-classname merge ran between the audit snapshot and this
// generation). Inserting them would create orphaned catalog_items rows. // generation). Inserting them would create orphaned catalog_items rows.
const [validIds] = (await db.execute(sql` const validIds = await queryRows<{ id: number }>(sql`
SELECT id FROM items_base SELECT id FROM items_base
`)) as unknown as [Array<{ id: number }>, unknown]; `);
const validItemIds = new Set(validIds.map((r) => Number(r.id))); const validItemIds = new Set(validIds.map((r) => Number(r.id)));
let skipped = 0; let skipped = 0;
@@ -365,9 +367,9 @@ export async function generateCatalogSql(
// Re-check against the live table: the audit snapshot may be stale and // Re-check against the live table: the audit snapshot may be stale and
// an import may have created the entry in the meantime. Inserting would // an import may have created the entry in the meantime. Inserting would
// create a duplicate catalog_items row for the same item_ids. // create a duplicate catalog_items row for the same item_ids.
const [existing] = (await db.execute(sql` const existing = await queryRows<{ id: number }>(sql`
SELECT id FROM catalog_items WHERE item_ids = ${String(entry.itemId)} LIMIT 1 SELECT id FROM catalog_items WHERE item_ids = ${String(entry.itemId)} LIMIT 1
`)) as unknown as [Array<{ id: number }>, unknown]; `);
if (existing.length > 0) { if (existing.length > 0) {
skipped++; skipped++;
continue; continue;
@@ -383,9 +385,9 @@ export async function generateCatalogSql(
); );
let existing = pageMap.get(captionSave); let existing = pageMap.get(captionSave);
if (!existing) { if (!existing) {
const [found] = (await db.execute(sql` const found = await queryRows<{ id: number }>(sql`
SELECT id FROM catalog_pages WHERE caption_save = ${captionSave} AND parent_id = ${parentPageId} SELECT id FROM catalog_pages WHERE caption_save = ${captionSave} AND parent_id = ${parentPageId}
`)) as unknown as [Array<{ id: number }>, unknown]; `);
if (found?.[0]?.id) { if (found?.[0]?.id) {
pageMap.set(captionSave, found[0].id); pageMap.set(captionSave, found[0].id);
} else { } else {
@@ -466,15 +468,15 @@ export interface DuplicateClassnameResult {
* new orphans are left behind. * new orphans are left behind.
*/ */
export async function repairOrphanedCatalog(): Promise<OrphanedCatalogResult> { export async function repairOrphanedCatalog(): Promise<OrphanedCatalogResult> {
const [itemRows] = (await db.execute(sql` const itemRows = await queryRows<{ id: number }>(sql`
SELECT id FROM items_base SELECT id FROM items_base
`)) as unknown as [Array<{ id: number }>, unknown]; `);
const valid = new Set<number>(); const valid = new Set<number>();
for (const row of itemRows) valid.add(Number(row.id)); for (const row of itemRows) valid.add(Number(row.id));
const [catalogRows] = (await db.execute(sql` const catalogRows = await queryRows<{ id: number; item_ids: string }>(sql`
SELECT id, item_ids FROM catalog_items SELECT id, item_ids FROM catalog_items
`)) as unknown as [Array<{ id: number; item_ids: string }>, unknown]; `);
let removed = 0; let removed = 0;
let cleaned = 0; let cleaned = 0;
@@ -513,9 +515,9 @@ export async function repairOrphanedCatalog(): Promise<OrphanedCatalogResult> {
* catalog_items when present, otherwise the lowest id. * catalog_items when present, otherwise the lowest id.
*/ */
export async function repairDuplicateClassnames(): Promise<DuplicateClassnameResult> { export async function repairDuplicateClassnames(): Promise<DuplicateClassnameResult> {
const [itemRows] = (await db.execute(sql` const itemRows = await queryRows<{ id: number; item_name: string }>(sql`
SELECT id, item_name FROM items_base SELECT id, item_name FROM items_base
`)) as unknown as [Array<{ id: number; item_name: string }>, unknown]; `);
const byName = new Map<string, number[]>(); const byName = new Map<string, number[]>();
for (const row of itemRows) { for (const row of itemRows) {
@@ -524,9 +526,9 @@ export async function repairDuplicateClassnames(): Promise<DuplicateClassnameRes
byName.set(row.item_name, arr); byName.set(row.item_name, arr);
} }
const [catalogRows] = (await db.execute(sql` const catalogRows = await queryRows<{ item_ids: string }>(sql`
SELECT item_ids FROM catalog_items SELECT item_ids FROM catalog_items
`)) as unknown as [Array<{ item_ids: string }>, unknown]; `);
const referenced = new Set<number>(); const referenced = new Set<number>();
for (const row of catalogRows) { for (const row of catalogRows) {
for (const part of (row.item_ids ?? "").split(";")) { for (const part of (row.item_ids ?? "").split(";")) {
@@ -558,9 +560,9 @@ export async function repairDuplicateClassnames(): Promise<DuplicateClassnameRes
{ table: "logs_shop_purchases", idCol: "id" }, { table: "logs_shop_purchases", idCol: "id" },
]; ];
for (const { table, idCol } of listTables) { for (const { table, idCol } of listTables) {
const [rows] = (await db.execute( const rows = await queryRows<{ pk: number; v: string | null }>(
sql`SELECT ${sql.raw(quoteIdentifier(idCol))} AS pk, item_ids AS v FROM ${sql.raw(quoteIdentifier(table))}`, sql`SELECT ${sql.raw(quoteIdentifier(idCol))} AS pk, item_ids AS v FROM ${sql.raw(quoteIdentifier(table))}`,
)) as unknown as [Array<{ pk: number; v: string | null }>, unknown]; );
for (const row of rows) { for (const row of rows) {
if (!row.v) continue; if (!row.v) continue;
const parts = row.v const parts = row.v
@@ -625,11 +627,11 @@ export async function repairDuplicateClassnames(): Promise<DuplicateClassnameRes
); );
const dupList = chunk.map(([dup]) => dup); const dupList = chunk.map(([dup]) => dup);
try { try {
const [result] = (await db.execute(sql` const result = await execResult(sql`
UPDATE ${sql.raw(quoteIdentifier(table))} UPDATE ${sql.raw(quoteIdentifier(table))}
SET item_id = CASE item_id ${sql.join(cases, sql` `)} ELSE item_id END SET item_id = CASE item_id ${sql.join(cases, sql` `)} ELSE item_id END
WHERE item_id IN (${sql.join(dupList, sql`, `)}) WHERE item_id IN (${sql.join(dupList, sql`, `)})
`)) as unknown as [Record<string, unknown>, unknown]; `);
remapped += Number(result.affectedRows ?? 0); remapped += Number(result.affectedRows ?? 0);
} catch { } catch {
// Table may not exist on some hotel schemas — skip it. // Table may not exist on some hotel schemas — skip it.
@@ -811,15 +813,13 @@ export async function previewCatalogParentRepair(
const table = sql.raw( const table = sql.raw(
catalog === "bc" ? "catalog_pages_bc" : "catalog_pages", catalog === "bc" ? "catalog_pages_bc" : "catalog_pages",
); );
const [rows] = await db.execute( const rows = await queryRows<import("./catalog-integrity").IntegrityPage>(
sql`SELECT id, parent_id AS parentId, caption FROM ${table} ORDER BY id`, sql`SELECT id, parent_id AS parentId, caption FROM ${table} ORDER BY id`,
); );
const pages =
rows as unknown as import("./catalog-integrity").IntegrityPage[];
return { return {
catalog, catalog,
fingerprint: parentStateFingerprint(catalog, pages), fingerprint: parentStateFingerprint(catalog, rows),
changes: planParentRepairs(pages), changes: planParentRepairs(rows),
}; };
} }
@@ -838,11 +838,11 @@ export async function applyCatalogParentRepair(
const tableName = catalog === "bc" ? "catalog_pages_bc" : "catalog_pages"; const tableName = catalog === "bc" ? "catalog_pages_bc" : "catalog_pages";
const table = sql.raw(tableName); const table = sql.raw(tableName);
return db.transaction(async (tx) => { return db.transaction(async (tx) => {
const [rows] = await tx.execute( const pages = rowsFrom<import("./catalog-integrity").IntegrityPage>(
await tx.execute(
sql`SELECT id, parent_id AS parentId, caption FROM ${table} ORDER BY id FOR UPDATE`, sql`SELECT id, parent_id AS parentId, caption FROM ${table} ORDER BY id FOR UPDATE`,
),
); );
const pages =
rows as unknown as import("./catalog-integrity").IntegrityPage[];
if (parentStateFingerprint(catalog, pages) !== fingerprint) { if (parentStateFingerprint(catalog, pages) !== fingerprint) {
const error = new Error("Catalog changed since preview"); const error = new Error("Catalog changed since preview");
error.name = "CatalogPreviewConflict"; error.name = "CatalogPreviewConflict";
+11 -11
View File
@@ -3,7 +3,7 @@ import {
deletePageCommand, deletePageCommand,
updatePageCommand, updatePageCommand,
} from "@/features/catalog/server/page-commands"; } from "@/features/catalog/server/page-commands";
import { CatalogPages, db } from "@/lib/db"; import { CatalogPages, db, queryRows } from "@/lib/db";
export type { NestedTreeNode, TreeNode } from "@/types/catalog"; export type { NestedTreeNode, TreeNode } from "@/types/catalog";
@@ -29,27 +29,27 @@ export async function getCatalogItemCounts(
if (pageIds) { if (pageIds) {
const idStrs = pageIds.map(String); const idStrs = pageIds.map(String);
const [rows] = (await db.execute(sql` const rows = await queryRows<{
page_id: string | number;
cnt: number | bigint;
}>(sql`
SELECT page_id, COUNT(*) as cnt SELECT page_id, COUNT(*) as cnt
FROM catalog_items FROM catalog_items
WHERE page_id IN (${sql.join(idStrs, sql`, `)}) WHERE page_id IN (${sql.join(idStrs, sql`, `)})
GROUP BY page_id GROUP BY page_id
`)) as unknown as [ `);
{ page_id: string | number; cnt: number | bigint }[],
unknown[],
];
for (const r of rows) { for (const r of rows) {
map.set(toInt(r.page_id), toInt(r.cnt)); map.set(toInt(r.page_id), toInt(r.cnt));
} }
return map; return map;
} }
const [rows] = (await db.execute(sql` const rows = await queryRows<{
page_id: string | number;
cnt: number | bigint;
}>(sql`
SELECT page_id, COUNT(*) as cnt FROM catalog_items GROUP BY page_id 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) { for (const r of rows) {
map.set(toInt(r.page_id), toInt(r.cnt)); map.set(toInt(r.page_id), toInt(r.cnt));
} }
+2 -1
View File
@@ -65,7 +65,8 @@ vi.mock("node:fs", async (orig) => {
}, },
}; };
}); });
vi.mock("@/lib/db", () => ({ vi.mock("@/lib/db", async () => ({
...(await import("@/test/db-helpers")).createDbHelpers(executeFn),
db: { db: {
execute: executeFn, execute: executeFn,
select: vi.fn(() => ({ select: vi.fn(() => ({
+5 -5
View File
@@ -1,6 +1,6 @@
import { promises as fs } from "node:fs"; import { promises as fs } from "node:fs";
import { eq, inArray, sql } from "drizzle-orm"; import { eq, inArray, sql } from "drizzle-orm";
import { db, ItemsBase } from "@/lib/db"; import { db, ItemsBase, queryRows } from "@/lib/db";
import { autoDetectInteraction } from "@/lib/furni/auto-interaction"; import { autoDetectInteraction } from "@/lib/furni/auto-interaction";
import { normalizeClassname } from "@/lib/furni/classname"; import { normalizeClassname } from "@/lib/furni/classname";
import { HABBO_GAMEDATA_HOTEL_SETTING_KEY } from "@/lib/habbo-gamedata-hotel"; import { HABBO_GAMEDATA_HOTEL_SETTING_KEY } from "@/lib/habbo-gamedata-hotel";
@@ -222,9 +222,9 @@ async function allocateItemsBaseId<T>(
itemsBaseNextId === null || itemsBaseNextId === null ||
Date.now() - itemsBaseIdLastUsed > ITEMS_BASE_ID_REFRESH_MS Date.now() - itemsBaseIdLastUsed > ITEMS_BASE_ID_REFRESH_MS
) { ) {
const [idRows] = (await db.execute(sql` const idRows = await queryRows<{ next: number }>(sql`
SELECT COALESCE(MAX(id), 0) + 1 AS next FROM items_base SELECT COALESCE(MAX(id), 0) + 1 AS next FROM items_base
`)) as unknown as [Array<{ next: number }>, unknown]; `);
itemsBaseNextId = Number(idRows[0]?.next ?? 1); itemsBaseNextId = Number(idRows[0]?.next ?? 1);
} }
} finally { } finally {
@@ -450,9 +450,9 @@ export async function cloneSingleFurni(params: {
// Skip when the item already has a catalog row — re-imports must never // Skip when the item already has a catalog row — re-imports must never
// create a second row for the same item_ids. // create a second row for the same item_ids.
try { try {
const [existingCatalog] = (await db.execute(sql` const existingCatalog = await queryRows<{ id: number }>(sql`
SELECT id FROM catalog_items WHERE item_ids = ${String(newId)} LIMIT 1 SELECT id FROM catalog_items WHERE item_ids = ${String(newId)} LIMIT 1
`)) as unknown as [Array<{ id: number }>, unknown]; `);
if (existingCatalog.length > 0) { if (existingCatalog.length > 0) {
warnings.push( warnings.push(
`catalog entry already exists (#${existingCatalog[0].id}) — skipped duplicate`, `catalog entry already exists (#${existingCatalog[0].id}) — skipped duplicate`,
+5 -5
View File
@@ -1,7 +1,7 @@
import { existsSync, promises as fs } from "node:fs"; import { existsSync, promises as fs } from "node:fs";
import path from "node:path"; import path from "node:path";
import { eq, sql } from "drizzle-orm"; import { eq, sql } from "drizzle-orm";
import { db, ItemsBase } from "@/lib/db"; import { db, execResult, ItemsBase, queryRows } from "@/lib/db";
import { import {
getGamedataRoot, getGamedataRoot,
getNitroFilesRoot, getNitroFilesRoot,
@@ -698,14 +698,14 @@ export interface CatalogOfferRebuildResult {
*/ */
export async function rebuildCatalogOfferIds(): Promise<CatalogOfferRebuildResult> { export async function rebuildCatalogOfferIds(): Promise<CatalogOfferRebuildResult> {
return withFurniDataLock(async () => { return withFurniDataLock(async () => {
const [countRows] = (await db.execute( const countRows = await queryRows<{ total: number }>(
sql`SELECT COUNT(*) AS total FROM catalog_items`, sql`SELECT COUNT(*) AS total FROM catalog_items`,
)) as unknown as [Array<{ total: number }>, unknown]; );
const checked = Number(countRows?.[0]?.total ?? 0); const checked = Number(countRows?.[0]?.total ?? 0);
const [result] = (await db.execute(sql` const result = await execResult(sql`
UPDATE catalog_items SET offer_id = id WHERE offer_id <> id UPDATE catalog_items SET offer_id = id WHERE offer_id <> id
`)) as unknown as [Record<string, unknown>, unknown]; `);
return { checked, fixed: Number(result.affectedRows ?? 0) }; return { checked, fixed: Number(result.affectedRows ?? 0) };
}); });
+19 -28
View File
@@ -15,7 +15,7 @@
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import path from "node:path"; import path from "node:path";
import { sql } from "drizzle-orm"; import { sql } from "drizzle-orm";
import { db, ItemsBase } from "@/lib/db"; import { db, ItemsBase, queryRows } from "@/lib/db";
import { normalizeClassname } from "@/lib/furni/classname"; import { normalizeClassname } from "@/lib/furni/classname";
import { indexLocalFurniture } from "@/lib/furni/local-presence"; import { indexLocalFurniture } from "@/lib/furni/local-presence";
import { readFurniData } from "./furni-data"; import { readFurniData } from "./furni-data";
@@ -89,10 +89,10 @@ export async function getCatalogIdSet(): Promise<Set<number>> {
} }
const set = new Set<number>(); const set = new Set<number>();
try { try {
const rows = await db.execute(sql`SELECT item_ids FROM catalog_items`); const rows = await queryRows<{ item_ids: string | null }>(
for (const row of ( sql`SELECT item_ids FROM catalog_items`,
rows as unknown as [Array<{ item_ids: string | null }>, unknown] );
)[0]) { for (const row of rows) {
for (const part of String(row.item_ids ?? "").split(/[;,]/)) { for (const part of String(row.item_ids ?? "").split(/[;,]/)) {
const n = Number(part.trim()); const n = Number(part.trim());
if (Number.isFinite(n) && n > 0) set.add(n); if (Number.isFinite(n) && n > 0) set.add(n);
@@ -138,48 +138,39 @@ export async function getFurniImportStats(
return statsSnapshot.stats; return statsSnapshot.stats;
} }
try { try {
const [totalResult, catalogResult, allIdsResult, referencedResult] = const [totalInDbRaw, inCatalogRaw, allIds, referencedList] =
await Promise.all([ await Promise.all([
db.execute(sql`SELECT COUNT(*) as cnt FROM items_base`), queryRows<{ cnt: bigint }>(sql`SELECT COUNT(*) as cnt FROM items_base`),
db.execute( queryRows<{ cnt: bigint }>(
sql`SELECT COUNT(DISTINCT item_ids) as cnt FROM catalog_items`, sql`SELECT COUNT(DISTINCT item_ids) as cnt FROM catalog_items`,
), ),
db.execute(sql`SELECT id FROM items_base`), queryRows<{ id: number }>(sql`SELECT id FROM items_base`),
db.execute(sql`SELECT item_ids FROM catalog_items`), queryRows<{ item_ids: string | null }>(
sql`SELECT item_ids FROM catalog_items`,
),
]); ]);
const totalInDb = Number( const totalInDb = Number(totalInDbRaw[0]?.cnt ?? 0);
(totalResult as unknown as [[{ cnt: bigint }], unknown])[0][0].cnt, const inCatalog = Number(inCatalogRaw[0]?.cnt ?? 0);
);
const inCatalog = Number(
(catalogResult as unknown as [[{ cnt: bigint }], unknown])[0][0].cnt,
);
const allIds = ( const allIdsNum = allIds.map((r) => Number(r.id));
allIdsResult as unknown as [Array<{ id: number }>, unknown]
)[0].map((r) => Number(r.id));
const referenced = new Set<number>(); const referenced = new Set<number>();
for (const row of ( for (const row of referencedList) {
referencedResult as unknown as [
Array<{ item_ids: string | null }>,
unknown,
]
)[0]) {
for (const part of String(row.item_ids ?? "").split(/[;,]/)) { for (const part of String(row.item_ids ?? "").split(/[;,]/)) {
const n = Number(part.trim()); const n = Number(part.trim());
if (Number.isFinite(n) && n > 0) referenced.add(n); if (Number.isFinite(n) && n > 0) referenced.add(n);
} }
} }
const notInCatalog = allIds.filter((id) => !referenced.has(id)).length; const notInCatalog = allIdsNum.filter((id) => !referenced.has(id)).length;
const [importedItems] = (await db.execute(sql` const importedItems = await queryRows<{ item_name: string }>(sql`
SELECT ib.item_name FROM items_base ib SELECT ib.item_name FROM items_base ib
WHERE EXISTS ( WHERE EXISTS (
SELECT 1 FROM catalog_items ci SELECT 1 FROM catalog_items ci
WHERE FIND_IN_SET(ib.id, REPLACE(ci.item_ids, ';', ',')) > 0 WHERE FIND_IN_SET(ib.id, REPLACE(ci.item_ids, ';', ',')) > 0
) )
LIMIT 10000 LIMIT 10000
`)) as unknown as [Array<{ item_name: string }>, unknown]; `);
let missingNitro = 0; let missingNitro = 0;
for (const item of importedItems) { for (const item of importedItems) {
+2 -1
View File
@@ -15,7 +15,8 @@ const mocks = vi.hoisted(() => ({
build: vi.fn(), build: vi.fn(),
append: vi.fn(), append: vi.fn(),
})); }));
vi.mock("@/lib/db", () => ({ vi.mock("@/lib/db", async () => ({
...(await import("@/test/db-helpers")).createDbHelpers(mocks.execute),
db: { select: mocks.select, execute: mocks.execute }, db: { select: mocks.select, execute: mocks.execute },
ItemsBase: { ItemsBase: {
id: "id", id: "id",
+20 -15
View File
@@ -3,7 +3,7 @@ import { existsSync, promises as fs } from "node:fs";
import path from "node:path"; import path from "node:path";
import { promisify } from "node:util"; import { promisify } from "node:util";
import { and, eq, type SQL, sql } from "drizzle-orm"; import { and, eq, type SQL, sql } from "drizzle-orm";
import { CatalogPages, db, ItemsBase } from "@/lib/db"; import { CatalogPages, db, execResult, ItemsBase, queryRows } from "@/lib/db";
import { officialHabboEnrichmentWarning } from "@/lib/habbo-gamedata-hotel"; import { officialHabboEnrichmentWarning } from "@/lib/habbo-gamedata-hotel";
import { logger } from "@/lib/logger"; import { logger } from "@/lib/logger";
import { logServerError } from "@/lib/server-log"; import { logServerError } from "@/lib/server-log";
@@ -146,10 +146,10 @@ async function dedupeCatalogPage(
parentId: number, parentId: number,
justCreatedId: number, justCreatedId: number,
): Promise<number> { ): Promise<number> {
const [rows] = (await db.execute(sql` const rows = await queryRows<{ id: number }>(sql`
SELECT MIN(id) AS id FROM catalog_pages SELECT MIN(id) AS id FROM catalog_pages
WHERE caption_save = ${captionSave} AND parent_id = ${parentId} WHERE caption_save = ${captionSave} AND parent_id = ${parentId}
`)) as unknown as [Array<{ id: number }>, unknown]; `);
const winner = rows[0]?.id ?? justCreatedId; const winner = rows[0]?.id ?? justCreatedId;
if (winner !== justCreatedId) { if (winner !== justCreatedId) {
// Never delete a page that already received catalog items. // Never delete a page that already received catalog items.
@@ -304,13 +304,13 @@ export async function reconcileImportedOfferIds(): Promise<{
fixed: number; fixed: number;
}> { }> {
const parentId = await getOrCreateImportedParentPage(); const parentId = await getOrCreateImportedParentPage();
const [result] = (await db.execute(sql` const result = await execResult(sql`
UPDATE catalog_items ci UPDATE catalog_items ci
JOIN catalog_pages cp ON cp.id = ci.page_id JOIN catalog_pages cp ON cp.id = ci.page_id
SET ci.offer_id = ci.id SET ci.offer_id = ci.id
WHERE cp.parent_id = ${parentId} WHERE cp.parent_id = ${parentId}
AND ci.offer_id <> ci.id AND ci.offer_id <> ci.id
`)) as unknown as [Record<string, unknown>, unknown]; `);
return { fixed: Number(result.affectedRows ?? 0) }; return { fixed: Number(result.affectedRows ?? 0) };
} }
@@ -486,9 +486,11 @@ export async function allocateCatalogItemId<T>(
catalogNextId === null || catalogNextId === null ||
Date.now() - catalogIdLastUsed > CATALOG_ID_REFRESH_MS Date.now() - catalogIdLastUsed > CATALOG_ID_REFRESH_MS
) { ) {
const [maxIdResult] = (await db.execute(sql` const maxIdResult = await queryRows<{
maxId: number | bigint | null;
}>(sql`
SELECT MAX(id) as maxId FROM catalog_items SELECT MAX(id) as maxId FROM catalog_items
`)) as unknown as [Array<{ maxId: number | bigint | null }>, unknown]; `);
catalogNextId = Number(maxIdResult[0]?.maxId ?? 0) + 1; catalogNextId = Number(maxIdResult[0]?.maxId ?? 0) + 1;
} }
} finally { } finally {
@@ -1067,9 +1069,12 @@ export async function importSingleFurni(params: {
const itemIdStr = String(itemId); const itemIdStr = String(itemId);
// Check if this item already exists anywhere in the catalog // Check if this item already exists anywhere in the catalog
const [existingCatalog] = (await db.execute(sql` const existingCatalog = await queryRows<{
id: number;
page_id: string;
}>(sql`
SELECT id, page_id FROM catalog_items WHERE FIND_IN_SET(${itemIdStr}, REPLACE(item_ids, ';', ',')) > 0 LIMIT 1 SELECT id, page_id FROM catalog_items WHERE FIND_IN_SET(${itemIdStr}, REPLACE(item_ids, ';', ',')) > 0 LIMIT 1
`)) as unknown as [Array<{ id: number; page_id: string }>, unknown]; `);
if (existingCatalog.length > 0) { if (existingCatalog.length > 0) {
catalogItemId = existingCatalog[0].id; catalogItemId = existingCatalog[0].id;
@@ -1177,7 +1182,7 @@ export async function fixDatabaseConsistencyAfterImport(): Promise<{
costCreditsFixed: number; costCreditsFixed: number;
}> { }> {
// 1. Fill empty catalog_name from items_base.public_name // 1. Fill empty catalog_name from items_base.public_name
const [nameResult] = (await db.execute(sql` const nameResult = await execResult(sql`
UPDATE catalog_items c UPDATE catalog_items c
JOIN items_base i ON c.item_ids = i.id JOIN items_base i ON c.item_ids = i.id
SET c.catalog_name = CASE SET c.catalog_name = CASE
@@ -1185,23 +1190,23 @@ export async function fixDatabaseConsistencyAfterImport(): Promise<{
ELSE i.public_name ELSE i.public_name
END END
WHERE c.catalog_name IS NULL OR c.catalog_name = '' WHERE c.catalog_name IS NULL OR c.catalog_name = ''
`)) as unknown as [Record<string, unknown>, unknown]; `);
const catalogNameFixed = Number(nameResult.affectedRows ?? 0); const catalogNameFixed = Number(nameResult.affectedRows ?? 0);
// 2. Set have_offer = '1' where it was '0' // 2. Set have_offer = '1' where it was '0'
const [offerResult] = (await db.execute(sql` const offerResult = await execResult(sql`
UPDATE catalog_items UPDATE catalog_items
SET have_offer = '1' SET have_offer = '1'
WHERE have_offer = '0' WHERE have_offer = '0'
`)) as unknown as [Record<string, unknown>, unknown]; `);
const haveOfferFixed = Number(offerResult.affectedRows ?? 0); const haveOfferFixed = Number(offerResult.affectedRows ?? 0);
// 3. Set cost_credits = 3 where it was <= 0 // 3. Set cost_credits = 3 where it was <= 0
const [creditResult] = (await db.execute(sql` const creditResult = await execResult(sql`
UPDATE catalog_items UPDATE catalog_items
SET cost_credits = 3 SET cost_credits = 3
WHERE cost_credits <= 0 WHERE cost_credits <= 0
`)) as unknown as [Record<string, unknown>, unknown]; `);
const costCreditsFixed = Number(creditResult.affectedRows ?? 0); const costCreditsFixed = Number(creditResult.affectedRows ?? 0);
return { catalogNameFixed, haveOfferFixed, costCreditsFixed }; return { catalogNameFixed, haveOfferFixed, costCreditsFixed };
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff. Show more