From 8638e81444918841c43ee4bd40b01f6576af136d Mon Sep 17 00:00:00 2001 From: openhands Date: Thu, 17 Sep 2026 21:02:57 +0200 Subject: [PATCH] refactor(db): typed query helpers, shared test FormData helper 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. --- .editorconfig | 14 ++ src/actions/admin-ads.test.ts | 11 +- src/actions/admin-ads.ts | 9 +- src/actions/admin-alerts.test.ts | 11 +- src/actions/admin-articles.test.ts | 7 +- src/actions/admin-articles.ts | 9 +- src/actions/admin-guilds.test.ts | 9 +- src/actions/admin-help-tickets.ts | 4 +- src/actions/admin-help.test.ts | 11 +- src/actions/admin-help.ts | 5 +- src/actions/admin-ip.test.ts | 19 +-- src/actions/admin-photos.test.ts | 5 +- src/actions/admin-shop.ts | 5 +- src/actions/admin-table-views.test.ts | 4 +- src/actions/admin-table-views.ts | 16 +-- src/actions/admin-tags.test.ts | 23 ++-- src/actions/admin-tags.ts | 5 +- src/actions/admin-teams.test.ts | 13 +- src/actions/admin-vouchers.ts | 5 +- src/actions/admin-vpn.test.ts | 7 +- src/actions/admin-wordfilter.ts | 5 +- src/actions/admin-writeable-boxes.ts | 5 +- src/actions/applications.test.ts | 5 +- src/actions/banners.ts | 5 +- src/actions/bulk-users.ts | 4 +- src/actions/catalog-items.ts | 8 +- src/actions/catalog.ts | 22 +-- src/actions/commandocentrum.ts | 5 +- src/actions/messenger.ts | 5 +- src/actions/password-reset.test.ts | 32 +++-- src/actions/permissions.ts | 7 +- src/actions/twofactor.test.ts | 34 +++-- src/actions/voucher.test.ts | 7 +- src/app/(site)/radio/leaderboard/page.tsx | 6 +- src/app/(site)/settings/sessions/page.tsx | 7 +- src/app/admin/analytics/activity/page.tsx | 5 +- src/app/admin/analytics/economy/page.tsx | 4 +- src/app/admin/analytics/page.tsx | 5 +- src/app/admin/logs/_lib/load-ignored-logs.ts | 5 +- src/app/admin/navigation/page.tsx | 13 +- src/app/admin/permissions/page.tsx | 6 +- .../admin/permissions/preview/page.test.tsx | 5 +- src/app/admin/permissions/preview/page.tsx | 26 ++-- src/app/admin/subscriptions/page.tsx | 5 +- .../users/_lib/load-user-overview.test.ts | 5 +- .../admin/users/_lib/load-user-overview.ts | 5 +- src/app/admin/users/_lib/load-user.test.ts | 84 ++++++------ src/app/admin/users/_lib/load-user.ts | 4 +- src/app/api/admin/analytics/export/route.ts | 20 +-- .../admin/import/furni/nitro-editor/route.ts | 17 +-- .../api/admin/import/furni/resync/route.ts | 6 +- src/app/api/admin/import/furni/route.ts | 19 ++- src/app/api/admin/import/organize/route.ts | 10 +- src/app/api/admin/permissions/roles/route.ts | 5 +- src/app/api/admin/prefixes/blacklist/route.ts | 6 +- src/app/api/admin/prefixes/route.ts | 23 ++-- src/app/api/admin/prefixes/settings/route.ts | 6 +- .../api/admin/studio/inspect/route.test.ts | 5 +- src/app/api/badges/leaderboard/route.ts | 5 +- src/app/api/me/tokens/route.ts | 5 +- src/app/api/paypal/capture/route.ts | 13 +- src/app/api/tickets/route.ts | 5 +- .../catalog/server/bulk-offers.test.ts | 17 ++- src/features/catalog/server/bulk-offers.ts | 31 +++-- src/features/catalog/server/bulk-undo.test.ts | 8 +- .../catalog/server/duplicate-commands.test.ts | 3 +- .../catalog/server/duplicate-commands.ts | 32 ++--- src/features/catalog/server/import-pages.ts | 18 +-- .../catalog/server/offer-commands.test.ts | 1 + src/features/catalog/server/offer-commands.ts | 61 +++++---- .../catalog/server/package-snapshot.ts | 24 ++-- src/features/catalog/server/packages.test.ts | 3 +- src/features/catalog/server/packages.ts | 25 ++-- .../catalog/server/page-commands.test.ts | 1 + src/features/catalog/server/page-commands.ts | 21 +-- src/features/catalog/server/search.test.ts | 12 +- src/features/catalog/server/search.ts | 24 ++-- src/features/history/catalog-offer.test.ts | 5 +- src/features/history/history.test.ts | 6 +- src/features/history/server.ts | 20 +-- .../operations/delivery-errors.test.ts | 5 +- src/features/operations/server.ts | 49 ++++--- src/lib/admin-search-permissions.test.ts | 2 +- src/lib/admin/installation.ts | 13 +- src/lib/auth/login-core.ts | 14 +- src/lib/bearer-route-abilities.test.ts | 7 +- src/lib/db.ts | 22 +++ src/lib/permissions.ts | 10 +- src/lib/services/catalog-audit.ts | 79 ++++------- .../services/catalog-integrity-audit.test.ts | 5 +- src/lib/services/catalog-items-loader.ts | 25 ++-- .../services/catalog-parent-repair.test.ts | 3 +- src/lib/services/catalog-repair.ts | 64 ++++----- src/lib/services/catalog-tree.ts | 22 +-- src/lib/services/clone-import.test.ts | 3 +- src/lib/services/clone-import.ts | 10 +- src/lib/services/furni-data.ts | 10 +- src/lib/services/furni-import-cache.ts | 47 +++---- src/lib/services/furni-import-repair.test.ts | 3 +- src/lib/services/furni-import.ts | 35 +++-- src/lib/services/furni-maintenance.test.ts | 12 +- src/lib/services/furni-maintenance.ts | 128 +++++++++--------- .../services/furniture-id-reservation.test.ts | 5 +- src/lib/services/furniture-id-reservation.ts | 10 +- src/lib/services/furniture-inspection.ts | 20 ++- src/lib/services/news-scheduler.test.ts | 3 +- src/lib/services/news-scheduler.ts | 18 ++- src/lib/services/nitro-cleanup.test.ts | 5 +- src/lib/services/nitro-cleanup.ts | 12 +- src/lib/services/permission-ranks.ts | 69 ++++------ src/lib/services/repair-icons.ts | 16 +-- src/lib/services/repair-nitros.ts | 16 +-- src/lib/services/upload-import.test.ts | 3 +- src/lib/services/upload-import.ts | 10 +- src/test/db-helpers.ts | 23 ++++ src/test/fake-form.ts | 7 + 116 files changed, 866 insertions(+), 897 deletions(-) create mode 100644 .editorconfig create mode 100644 src/test/db-helpers.ts create mode 100644 src/test/fake-form.ts diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..0a31b81b --- /dev/null +++ b/.editorconfig @@ -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 diff --git a/src/actions/admin-ads.test.ts b/src/actions/admin-ads.test.ts index 0f53a96f..c3138f0e 100644 --- a/src/actions/admin-ads.test.ts +++ b/src/actions/admin-ads.test.ts @@ -6,6 +6,7 @@ import { requirePermission } from "@/lib/admin/guard"; import { logger } from "@/lib/logger"; import { ActionError } from "@/lib/safe-action-shared"; import { logStaffActivity } from "@/lib/services/staff-activity"; +import { fakeForm } from "@/test/fake-form"; import { createAd, deleteAd } from "./admin-ads"; const { insertValues, deleteWhere } = vi.hoisted(() => { @@ -41,10 +42,6 @@ vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); vi.mock("next/navigation", () => ({ redirect: vi.fn() })); const staff = { id: 1, rank: 7, username: "admin" }; -const fakeForm = (data: Record) => ({ - get: (k: string) => data[k] ?? null, -}); - beforeEach(() => { vi.clearAllMocks(); vi.mocked(requirePermission).mockResolvedValue(staff as never); @@ -55,7 +52,7 @@ beforeEach(() => { describe("createAd", () => { it("creates ad and redirects", async () => { 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(logStaffActivity).toHaveBeenCalled(); @@ -63,13 +60,13 @@ describe("createAd", () => { }); 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(); }); it("logs error on db failure", async () => { 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(); }); }); diff --git a/src/actions/admin-ads.ts b/src/actions/admin-ads.ts index f1bc2784..f71201a1 100644 --- a/src/actions/admin-ads.ts +++ b/src/actions/admin-ads.ts @@ -1,7 +1,6 @@ "use server"; import { eq } from "drizzle-orm"; -import type { ResultSetHeader } from "mysql2"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { z } from "zod"; @@ -26,11 +25,11 @@ export async function createAd(formData: FormData): Promise { const now = new Date(); try { - const [result] = (await db.insert(WebsiteAds).values({ + const [result] = await db.insert(WebsiteAds).values({ image, createdAt: now, updatedAt: now, - })) as unknown as [ResultSetHeader]; + }); await logStaffActivity({ staffId: staff.id, action: "ad_create", @@ -95,9 +94,7 @@ export const deleteAd = adminAction( async (ctx) => { const id = ctx.data.id; try { - const [result] = (await db - .delete(WebsiteAds) - .where(eq(WebsiteAds.id, id))) as unknown as [ResultSetHeader]; + const [result] = await db.delete(WebsiteAds).where(eq(WebsiteAds.id, id)); if (!result.affectedRows) { throw new ActionError("Advertisement not found"); } diff --git a/src/actions/admin-alerts.test.ts b/src/actions/admin-alerts.test.ts index 253f3831..4286afb8 100644 --- a/src/actions/admin-alerts.test.ts +++ b/src/actions/admin-alerts.test.ts @@ -3,6 +3,7 @@ import { revalidatePath } from "next/cache"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { requirePermission } from "@/lib/admin/guard"; import { rcon } from "@/lib/services/rcon"; +import { fakeForm } from "@/test/fake-form"; import { sendHotelAlert } from "./admin-alerts"; 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("next/cache", () => ({ revalidatePath: vi.fn() })); -const fakeForm = (data: Record) => ({ - get: (key: string) => data[key] ?? null, -}); - beforeEach(() => { vi.clearAllMocks(); vi.mocked(requirePermission).mockResolvedValue({ @@ -33,15 +30,13 @@ beforeEach(() => { describe("sendHotelAlert", () => { it("sends hotel alert and revalidates", async () => { - await sendHotelAlert( - fakeForm({ message: "Hello!" }) as unknown as FormData, - ); + await sendHotelAlert(fakeForm({ message: "Hello!" }) as FormData); expect(rcon.send).toHaveBeenCalledWith("hotelalert", { message: "Hello!" }); expect(revalidatePath).toHaveBeenCalledWith("/admin/alerts"); }); 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(); }); }); diff --git a/src/actions/admin-articles.test.ts b/src/actions/admin-articles.test.ts index fa9ad3b6..6100600f 100644 --- a/src/actions/admin-articles.test.ts +++ b/src/actions/admin-articles.test.ts @@ -37,6 +37,7 @@ vi.mock("next/navigation", () => ({ }, })); vi.mock("@/lib/db", async () => { + const { createDbHelpers } = await import("@/test/db-helpers"); const { MySqlDialect } = await import("drizzle-orm/mysql-core"); const dialect = new MySqlDialect(); const database = { @@ -85,7 +86,11 @@ vi.mock("@/lib/db", async () => { transaction: async (fn: (tx: unknown) => unknown): Promise => 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"; diff --git a/src/actions/admin-articles.ts b/src/actions/admin-articles.ts index 024deb1b..d8945007 100644 --- a/src/actions/admin-articles.ts +++ b/src/actions/admin-articles.ts @@ -18,6 +18,7 @@ import { } from "@/lib/article-input"; import { db, + rowsFrom, WebsiteArticleComments, WebsiteArticleReactions, WebsiteArticles, @@ -128,11 +129,9 @@ export async function createArticle( updatedAt: now, publishedAt: fields.status === "published" ? now : null, }); - const [createdRows] = await tx.execute( - sql`SELECT CAST(LAST_INSERT_ID() AS CHAR) AS id`, - ); - const articleId = (createdRows as unknown as Array<{ id: string }>)[0] - ?.id; + const articleId = rowsFrom<{ id: string }>( + await tx.execute(sql`SELECT CAST(LAST_INSERT_ID() AS CHAR) AS id`), + )[0]?.id; if (!articleId || !/^[1-9][0-9]*$/.test(articleId)) throw new Error("Article identity unavailable"); await enqueueEffect(tx, operationId, "news.refresh"); diff --git a/src/actions/admin-guilds.test.ts b/src/actions/admin-guilds.test.ts index 07b3b1f4..10aa6d3f 100644 --- a/src/actions/admin-guilds.test.ts +++ b/src/actions/admin-guilds.test.ts @@ -3,6 +3,7 @@ import { revalidatePath } from "next/cache"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { requirePermissionRateLimited } from "@/lib/admin/guard"; import { logStaffActivity } from "@/lib/services/staff-activity"; +import { fakeForm } from "@/test/fake-form"; import { disbandGuild } from "./admin-guilds"; 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() })); const staff = { id: 1, rank: 7, username: "admin" }; -const fakeForm = (data: Record) => ({ - get: (key: string) => data[key] ?? null, -}); - beforeEach(() => { vi.clearAllMocks(); vi.mocked(requirePermissionRateLimited).mockResolvedValue(staff as never); @@ -79,13 +76,13 @@ describe("disbandGuild", () => { await fn(tx); }, ); - await disbandGuild(fakeForm({ id: "1" }) as unknown as FormData); + await disbandGuild(fakeForm({ id: "1" }) as FormData); expect(logStaffActivity).toHaveBeenCalled(); expect(revalidatePath).toHaveBeenCalledWith("/admin/guilds"); }); 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(); }); }); diff --git a/src/actions/admin-help-tickets.ts b/src/actions/admin-help-tickets.ts index e2a15942..79317d97 100644 --- a/src/actions/admin-help-tickets.ts +++ b/src/actions/admin-help-tickets.ts @@ -60,9 +60,7 @@ export const liftBanFromHelpTicket = adminAction( } const result = await db.delete(Ban).where(eq(Ban.userId, ticket.userId)); - const removed = Number( - (result as unknown as [{ affectedRows: number }])[0]?.affectedRows ?? 0, - ); + const removed = Number(result[0]?.affectedRows ?? 0); const now = new Date(); if (ticket.open) { diff --git a/src/actions/admin-help.test.ts b/src/actions/admin-help.test.ts index 2009298b..3952a963 100644 --- a/src/actions/admin-help.test.ts +++ b/src/actions/admin-help.test.ts @@ -1,6 +1,7 @@ import { redirect } from "next/navigation"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { requirePermission } from "@/lib/admin/guard"; +import { fakeForm } from "@/test/fake-form"; import { createHelpQuestion, deleteHelpQuestion, @@ -29,10 +30,6 @@ vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); vi.mock("next/navigation", () => ({ redirect: vi.fn() })); const staff = { id: 1, rank: 7, username: "admin" }; -const fakeForm = (data: Record) => ({ - get: (key: string) => (key in data ? data[key] : null), -}); - beforeEach(() => { vi.clearAllMocks(); vi.mocked(requirePermission).mockResolvedValue(staff as never); @@ -47,7 +44,7 @@ describe("createHelpQuestion", () => { fakeForm({ name: "FAQ", content: "

Answer

", - }) as unknown as FormData, + }) as FormData, ); expect(insertValues).toHaveBeenCalled(); expect(redirect).toHaveBeenCalledWith("/admin/help-questions"); @@ -61,7 +58,7 @@ describe("updateHelpQuestion", () => { id: "42", name: "Updated", content: "New", - }) as unknown as FormData, + }) as FormData, ); expect(updateWhere).toHaveBeenCalled(); expect(redirect).toHaveBeenCalledWith("/admin/help-questions"); @@ -70,7 +67,7 @@ describe("updateHelpQuestion", () => { describe("deleteHelpQuestion", () => { 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(redirect).toHaveBeenCalledWith("/admin/help-questions"); }); diff --git a/src/actions/admin-help.ts b/src/actions/admin-help.ts index a59258ff..bf04eaad 100644 --- a/src/actions/admin-help.ts +++ b/src/actions/admin-help.ts @@ -1,7 +1,6 @@ "use server"; import { eq } from "drizzle-orm"; -import type { ResultSetHeader } from "mysql2"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { requirePermission } from "@/lib/admin/guard"; @@ -34,7 +33,7 @@ export async function createHelpQuestion(formData: FormData): Promise { sanitizeField(formData.get("buttonBorderColor"), 16) || "#facc15"; try { - const [result] = (await db.insert(WebsiteHelpCenterCategories).values({ + const [result] = await db.insert(WebsiteHelpCenterCategories).values({ name, content, position: parsePosition(formData.get("position")), @@ -44,7 +43,7 @@ export async function createHelpQuestion(formData: FormData): Promise { buttonColor, buttonBorderColor, smallBox: formData.get("smallBox") != null, - })) as unknown as [ResultSetHeader]; + }); await logStaffActivity({ staffId: staff.id, action: "help_create", diff --git a/src/actions/admin-ip.test.ts b/src/actions/admin-ip.test.ts index 13354e38..338f7cd4 100644 --- a/src/actions/admin-ip.test.ts +++ b/src/actions/admin-ip.test.ts @@ -2,6 +2,7 @@ import { revalidatePath } from "next/cache"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { requirePermission } from "@/lib/admin/guard"; +import { fakeForm } from "@/test/fake-form"; import { addBlacklist, addWhitelist, @@ -30,10 +31,6 @@ vi.mock("@/lib/db", () => ({ vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); const staff = { id: 1, rank: 7, username: "admin" }; -const fakeForm = (data: Record) => ({ - get: (key: string) => data[key] ?? null, -}); - beforeEach(() => { vi.clearAllMocks(); vi.mocked(requirePermission).mockResolvedValue(staff as never); @@ -43,9 +40,7 @@ beforeEach(() => { describe("addWhitelist", () => { it("creates whitelist entry", async () => { - await addWhitelist( - fakeForm({ ipAddress: "192.168.1.1" }) as unknown as FormData, - ); + await addWhitelist(fakeForm({ ipAddress: "192.168.1.1" }) as FormData); expect(insertValues).toHaveBeenCalledWith({ ipAddress: "192.168.1.1", asn: null, @@ -55,23 +50,21 @@ describe("addWhitelist", () => { }); 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(); }); }); describe("deleteWhitelist", () => { it("deletes whitelist entry", async () => { - await deleteWhitelist(fakeForm({ id: "42" }) as unknown as FormData); + await deleteWhitelist(fakeForm({ id: "42" }) as FormData); expect(deleteWhere).toHaveBeenCalled(); }); }); describe("addBlacklist", () => { it("creates blacklist entry", async () => { - await addBlacklist( - fakeForm({ ipAddress: "203.0.113.1" }) as unknown as FormData, - ); + await addBlacklist(fakeForm({ ipAddress: "203.0.113.1" }) as FormData); expect(insertValues).toHaveBeenCalledWith({ ipAddress: "203.0.113.1", asn: null, @@ -82,7 +75,7 @@ describe("addBlacklist", () => { describe("deleteBlacklist", () => { it("deletes blacklist entry", async () => { - await deleteBlacklist(fakeForm({ id: "99" }) as unknown as FormData); + await deleteBlacklist(fakeForm({ id: "99" }) as FormData); expect(deleteWhere).toHaveBeenCalled(); }); }); diff --git a/src/actions/admin-photos.test.ts b/src/actions/admin-photos.test.ts index d90175dc..bb72fe0c 100644 --- a/src/actions/admin-photos.test.ts +++ b/src/actions/admin-photos.test.ts @@ -4,6 +4,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { requirePermission } from "@/lib/admin/guard"; import { tryRemoveLocalPhotoFile } from "@/lib/admin/photo-files"; import { logStaffActivity } from "@/lib/services/staff-activity"; +import { fakeForm } from "@/test/fake-form"; import { deletePhoto } from "./admin-photos"; const { select, deleteFn, limit, whereDelete } = vi.hoisted(() => { @@ -33,10 +34,6 @@ vi.mock("@/lib/db", () => ({ CameraWeb: { id: "id", url: "url" }, })); -const fakeForm = (data) => ({ - get: (key) => data[key] ?? null, -}); - beforeEach(() => { vi.clearAllMocks(); limit.mockResolvedValue([{ id: 42, url: "/uploads/cam/42.png" }]); diff --git a/src/actions/admin-shop.ts b/src/actions/admin-shop.ts index aa8feb91..4125c2ac 100644 --- a/src/actions/admin-shop.ts +++ b/src/actions/admin-shop.ts @@ -1,7 +1,6 @@ "use server"; import { eq } from "drizzle-orm"; -import type { ResultSetHeader } from "mysql2"; import { revalidatePath } from "next/cache"; import { redirect } from "next/navigation"; import { requirePermission } from "@/lib/admin/guard"; @@ -44,7 +43,7 @@ export async function createShopArticle(formData: FormData): Promise { const now = new Date(); const costs = reqUInt(formData, "costs"); try { - const [result] = (await db.insert(WebsiteShopArticles).values({ + const [result] = await db.insert(WebsiteShopArticles).values({ name, info: String(formData.get("info") ?? "") .normalize("NFC") @@ -71,7 +70,7 @@ export async function createShopArticle(formData: FormData): Promise { position: reqUInt(formData, "position"), createdAt: now, updatedAt: now, - })) as unknown as [ResultSetHeader]; + }); await logStaffActivity({ staffId: staff.id, action: "shop_create", diff --git a/src/actions/admin-table-views.test.ts b/src/actions/admin-table-views.test.ts index b5d05739..e82fd61b 100644 --- a/src/actions/admin-table-views.test.ts +++ b/src/actions/admin-table-views.test.ts @@ -23,13 +23,15 @@ vi.mock("@/lib/report-error", () => ({ reportError: vi.fn() })); vi.mock("@/lib/foundation/security", () => ({ 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 query = new MySqlDialect().sqlToQuery(sql); state.queries.push(query); return [state.rows]; }; return { + ...createDbHelpers(execute), db: { execute, transaction: async (fn: (tx: { execute: typeof execute }) => unknown) => diff --git a/src/actions/admin-table-views.ts b/src/actions/admin-table-views.ts index 2f4af2a2..8e26cd2b 100644 --- a/src/actions/admin-table-views.ts +++ b/src/actions/admin-table-views.ts @@ -2,7 +2,7 @@ import { sql } from "drizzle-orm"; import { z } from "zod"; 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 { adminAction } from "@/lib/safe-action"; @@ -25,11 +25,10 @@ export const listTableViews = adminAction( schema: z.object({ path: tableViewPath }), }, 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 = ( - 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) => { try { const parsed = namedTableView.safeParse({ @@ -58,10 +57,11 @@ export const saveTableView = adminAction( await tx.execute( sql`SELECT id FROM users WHERE id = ${userId} FOR UPDATE`, ); - const [rows] = await tx.execute( - sql`SELECT name FROM website_admin_table_views WHERE user_id = ${userId} AND path = ${data.path}`, + const names = rowsFrom<{ name: string }>( + await tx.execute( + 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 ( names.length >= 20 && !names.some( diff --git a/src/actions/admin-tags.test.ts b/src/actions/admin-tags.test.ts index 733c9853..1915c1c5 100644 --- a/src/actions/admin-tags.test.ts +++ b/src/actions/admin-tags.test.ts @@ -3,6 +3,7 @@ import { revalidatePath } from "next/cache"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { requirePermission } from "@/lib/admin/guard"; import { logStaffActivity } from "@/lib/services/staff-activity"; +import { fakeForm } from "@/test/fake-form"; import { createTag, deleteTag, updateTag } from "./admin-tags"; 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() })); const staff = { id: 1, rank: 7, username: "admin" }; -const fakeForm = (data: Record) => ({ - get: (key: string) => data[key] ?? null, -}); - beforeEach(() => { vi.clearAllMocks(); vi.mocked(requirePermission).mockResolvedValue(staff as never); @@ -58,7 +55,7 @@ describe("createTag", () => { fakeForm({ name: "News", backgroundColor: "#ff0000", - }) as unknown as FormData, + }) as FormData, ); expect(insertValues).toHaveBeenCalledWith( @@ -69,12 +66,12 @@ describe("createTag", () => { }); 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(); }); 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.objectContaining({ backgroundColor: "#888888" }), @@ -85,7 +82,7 @@ describe("createTag", () => { insertValues.mockRejectedValue(new Error("DB error")); await expect( - createTag(fakeForm({ name: "News" }) as unknown as FormData), + createTag(fakeForm({ name: "News" }) as FormData), ).resolves.toBeUndefined(); expect(revalidatePath).toHaveBeenCalledWith("/admin/tags"); }); @@ -98,7 +95,7 @@ describe("updateTag", () => { id: "42", name: "Updated", backgroundColor: "#00ff00", - }) as unknown as FormData, + }) as FormData, ); expect(updateWhere).toHaveBeenCalled(); @@ -107,19 +104,19 @@ describe("updateTag", () => { }); 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(); }); 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(); }); }); describe("deleteTag", () => { 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(deleteWhere).toHaveBeenCalled(); @@ -128,7 +125,7 @@ describe("deleteTag", () => { }); 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(); }); }); diff --git a/src/actions/admin-tags.ts b/src/actions/admin-tags.ts index f372d642..ec267a3d 100644 --- a/src/actions/admin-tags.ts +++ b/src/actions/admin-tags.ts @@ -1,7 +1,6 @@ "use server"; import { eq } from "drizzle-orm"; -import type { ResultSetHeader } from "mysql2"; import { revalidatePath } from "next/cache"; import { requirePermission } from "@/lib/admin/guard"; import { db, Taggables, Tags } from "@/lib/db"; @@ -42,12 +41,12 @@ export async function createTag(formData: FormData): Promise { const now = new Date(); try { - const [result] = (await db.insert(Tags).values({ + const [result] = await db.insert(Tags).values({ name, backgroundColor, createdAt: now, updatedAt: now, - })) as unknown as [ResultSetHeader]; + }); await logStaffActivity({ staffId: staff.id, action: "tag_create", diff --git a/src/actions/admin-teams.test.ts b/src/actions/admin-teams.test.ts index d30a24fb..20453235 100644 --- a/src/actions/admin-teams.test.ts +++ b/src/actions/admin-teams.test.ts @@ -2,6 +2,7 @@ import { revalidatePath } from "next/cache"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { requirePermission } from "@/lib/admin/guard"; +import { fakeForm } from "@/test/fake-form"; import { createTeam, deleteTeam } from "./admin-teams"; const { insertValues, deleteWhere } = vi.hoisted(() => { @@ -22,10 +23,6 @@ vi.mock("@/lib/db", () => ({ vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); const staff = { id: 1, rank: 7, username: "admin" }; -const fakeForm = (data: Record) => ({ - get: (key: string) => (key in data ? data[key] : null), -}); - beforeEach(() => { vi.clearAllMocks(); vi.mocked(requirePermission).mockResolvedValue(staff as never); @@ -35,9 +32,7 @@ beforeEach(() => { describe("createTeam", () => { it("creates a team entry", async () => { - await createTeam( - fakeForm({ rankName: "Moderator" }) as unknown as FormData, - ); + await createTeam(fakeForm({ rankName: "Moderator" }) as FormData); expect(insertValues).toHaveBeenCalledWith( expect.objectContaining({ rankName: "Moderator" }), ); @@ -45,14 +40,14 @@ describe("createTeam", () => { }); 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(); }); }); describe("deleteTeam", () => { 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(revalidatePath).toHaveBeenCalledWith("/admin/teams"); }); diff --git a/src/actions/admin-vouchers.ts b/src/actions/admin-vouchers.ts index 64021bf3..093f0b13 100644 --- a/src/actions/admin-vouchers.ts +++ b/src/actions/admin-vouchers.ts @@ -1,7 +1,6 @@ "use server"; import { eq } from "drizzle-orm"; -import type { ResultSetHeader } from "mysql2"; import { revalidatePath } from "next/cache"; import { requirePermission } from "@/lib/admin/guard"; import { positiveBigInt } from "@/lib/api"; @@ -47,7 +46,7 @@ export async function createVoucher(input: { const now = new Date(); try { - const [result] = (await db.insert(WebsiteShopVouchers).values({ + const [result] = await db.insert(WebsiteShopVouchers).values({ code, amount: Math.floor(amount), maxUses, @@ -55,7 +54,7 @@ export async function createVoucher(input: { expiresAt, createdAt: now, updatedAt: now, - })) as unknown as [ResultSetHeader]; + }); revalidatePath("/admin/vouchers"); return actionOk({ id: String(result.insertId) }); } catch (error) { diff --git a/src/actions/admin-vpn.test.ts b/src/actions/admin-vpn.test.ts index 90eb3970..f6e36c1a 100644 --- a/src/actions/admin-vpn.test.ts +++ b/src/actions/admin-vpn.test.ts @@ -2,6 +2,7 @@ import { redirect } from "next/navigation"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { requirePermission } from "@/lib/admin/guard"; import { siteSettings } from "@/lib/services/site-settings"; +import { fakeForm } from "@/test/fake-form"; import { saveVpn } from "./admin-vpn"; const { mockValues, mockOnDuplicateKeyUpdate } = vi.hoisted(() => { @@ -30,10 +31,6 @@ vi.mock("next/cache", () => ({ revalidatePath: vi.fn() })); vi.mock("next/navigation", () => ({ redirect: vi.fn() })); const staff = { id: 1, rank: 7, username: "admin" }; -const fakeForm = (data: Record) => ({ - get: (key: string) => (key in data ? data[key] : null), -}); - beforeEach(() => { vi.clearAllMocks(); vi.mocked(requirePermission).mockResolvedValue(staff as never); @@ -50,7 +47,7 @@ describe("saveVpn", () => { vpn_block_enabled: "1", vpn_provider: "proxycheck", vpn_api_key: "abc123", - }) as unknown as FormData, + }) as FormData, ); expect(mockValues).toHaveBeenCalledTimes(4); expect(mockOnDuplicateKeyUpdate).toHaveBeenCalledTimes(4); diff --git a/src/actions/admin-wordfilter.ts b/src/actions/admin-wordfilter.ts index 66fa3896..5ac17707 100644 --- a/src/actions/admin-wordfilter.ts +++ b/src/actions/admin-wordfilter.ts @@ -1,7 +1,6 @@ "use server"; import { eq } from "drizzle-orm"; -import type { ResultSetHeader } from "mysql2"; import { revalidatePath } from "next/cache"; import { requirePermission } from "@/lib/admin/guard"; import { db, WebsiteWordfilter } from "@/lib/db"; @@ -25,9 +24,7 @@ export async function addWord(input: { if (!word) return actionError("Word is required"); try { - const [result] = (await db - .insert(WebsiteWordfilter) - .values({ word })) as unknown as [ResultSetHeader]; + const [result] = await db.insert(WebsiteWordfilter).values({ word }); reloadWordFilter(); await rcon.updateWordFilter(); revalidatePath("/admin/wordfilter"); diff --git a/src/actions/admin-writeable-boxes.ts b/src/actions/admin-writeable-boxes.ts index d628221e..4f58e38f 100644 --- a/src/actions/admin-writeable-boxes.ts +++ b/src/actions/admin-writeable-boxes.ts @@ -1,7 +1,6 @@ "use server"; import { eq } from "drizzle-orm"; -import type { ResultSetHeader } from "mysql2"; import { revalidatePath } from "next/cache"; import { requirePermission } from "@/lib/admin/guard"; import { db, WebsiteWriteableBoxes } from "@/lib/db"; @@ -53,7 +52,7 @@ export async function createBox(formData: FormData): Promise { const now = new Date(); try { - const [result] = (await db.insert(WebsiteWriteableBoxes).values({ + const [result] = await db.insert(WebsiteWriteableBoxes).values({ title, icon: String(formData.get("icon") ?? "") @@ -65,7 +64,7 @@ export async function createBox(formData: FormData): Promise { isActive: String(formData.get("isActive") ?? "").normalize("NFC") === "1", createdAt: now, updatedAt: now, - })) as unknown as [ResultSetHeader]; + }); await logStaffActivity({ staffId: staff.id, action: "writeable_box_create", diff --git a/src/actions/applications.test.ts b/src/actions/applications.test.ts index a6b16854..e3fa1326 100644 --- a/src/actions/applications.test.ts +++ b/src/actions/applications.test.ts @@ -3,6 +3,7 @@ import { redirect } from "next/navigation"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { auth } from "@/lib/auth"; import { clientIp, rateLimit } from "@/lib/rate-limit"; +import { fakeForm } from "@/test/fake-form"; import { applyStaff, applyTeam } from "./applications"; 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/navigation", () => ({ redirect: vi.fn() })); -const fakeForm = (data) => ({ - get: (key) => data[key] ?? null, -}); - beforeEach(() => { vi.clearAllMocks(); vi.mocked(auth).mockResolvedValue({ user: { id: "42" } }); diff --git a/src/actions/banners.ts b/src/actions/banners.ts index e73fcbf4..1387180c 100644 --- a/src/actions/banners.ts +++ b/src/actions/banners.ts @@ -1,7 +1,6 @@ "use server"; import { eq } from "drizzle-orm"; -import type { ResultSetHeader } from "mysql2"; import { z } from "zod"; import { db, WebsiteBanner } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; @@ -24,9 +23,7 @@ const bannerSchema = z.object({ export const createBanner = adminAction( { permission: PERMS.BANNERS_EDIT, schema: bannerSchema }, async (ctx) => { - const [result] = (await db - .insert(WebsiteBanner) - .values(ctx.data)) as unknown as [ResultSetHeader]; + const [result] = await db.insert(WebsiteBanner).values(ctx.data); const id = Number(result.insertId); logAudit({ userId: ctx.session.user.id, diff --git a/src/actions/bulk-users.ts b/src/actions/bulk-users.ts index a6a3d057..1bd037fa 100644 --- a/src/actions/bulk-users.ts +++ b/src/actions/bulk-users.ts @@ -23,9 +23,7 @@ export async function bulkUnban({ }): Promise> { const staff = await requirePermission(PERMS.USERS_EDIT); const result = await db.delete(Ban).where(inArray(Ban.userId, userIds)); - const unbanned = Number( - (result as unknown as [{ affectedRows: number }])[0]?.affectedRows ?? 0, - ); + const unbanned = Number(result[0]?.affectedRows ?? 0); await logStaffActivity({ staffId: staff.id, action: "bulk_unban", diff --git a/src/actions/catalog-items.ts b/src/actions/catalog-items.ts index 138ee254..23c47c4f 100644 --- a/src/actions/catalog-items.ts +++ b/src/actions/catalog-items.ts @@ -11,7 +11,7 @@ import { } from "@/features/catalog/server/offer-commands"; import { sendCatalogUpdate } from "@/features/catalog/server/sync-status"; 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 { logAudit } from "@/lib/services/audit"; import { withCatalogExport } from "@/lib/services/catalog-git-queue"; @@ -145,10 +145,10 @@ export async function bulkCreateCatalogItems({ let failed = 0; // 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)} - `)) 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) { const base = baseMap.get(row.baseId); diff --git a/src/actions/catalog.ts b/src/actions/catalog.ts index 7eb28758..721434ff 100644 --- a/src/actions/catalog.ts +++ b/src/actions/catalog.ts @@ -13,7 +13,7 @@ import { } from "@/features/catalog/server/page-commands"; import { sendCatalogUpdate } from "@/features/catalog/server/sync-status"; 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 type { ActionResult } from "@/lib/safe-action-shared"; import { withCatalogExport } from "@/lib/services/catalog-git-queue"; @@ -290,10 +290,10 @@ export async function createAutoCategory(input: { .where(inArray(ItemsBase.id, baseIds)); 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)} - `)) as unknown as [{ maxOrder: number }, unknown]; - let nextOrder = Number(maxOrder?.maxOrder ?? 0) + 1; + `); + let nextOrder = Number(maxOrder[0]?.maxOrder ?? 0) + 1; let created = 0; for (const row of rows) { @@ -455,9 +455,9 @@ export async function organizeImportFurni(input: { ); let destinationCaption: string | null = null; if (destinationPageId) { - const [captionRows] = (await db.execute(sql` + const captionRows = await queryRows<{ caption: string }>(sql` SELECT caption FROM catalog_pages WHERE id = ${destinationPageId} - `)) as unknown as [Array<{ caption: string }>, unknown]; + `); destinationCaption = captionRows[0]?.caption ?? null; } const caption = @@ -465,11 +465,11 @@ export async function organizeImportFurni(input: { // Existing destinations append after their current offers; freshly // 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 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 bases = await db @@ -493,12 +493,12 @@ export async function organizeImportFurni(input: { ]; const validMoveIds = new Set(); 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( moverIds.map((id) => sql`${id}`), sql`, `, )}) - `)) as unknown as [Array<{ id: number; page_id: number }>, unknown]; + `); for (const row of hostRows) { if (importPageIds.has(Number(row.page_id))) { validMoveIds.add(Number(row.id)); diff --git a/src/actions/commandocentrum.ts b/src/actions/commandocentrum.ts index 025c497a..56b9680a 100644 --- a/src/actions/commandocentrum.ts +++ b/src/actions/commandocentrum.ts @@ -3,7 +3,7 @@ import { eq, sql } from "drizzle-orm"; import { revalidatePath } from "next/cache"; import { z } from "zod"; -import { db, User } from "@/lib/db"; +import { db, queryRows, User } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; import { adminAction } from "@/lib/safe-action"; import { ActionError, actionOk } from "@/lib/safe-action-shared"; @@ -209,10 +209,9 @@ export const setRank = adminAction( let rankExists: { id: number }[] = []; try { - const [rows] = await db.execute( + rankExists = await queryRows<{ id: number }>( sql`SELECT id FROM permission_ranks WHERE id = ${ctx.data.rank} LIMIT 1`, ); - rankExists = rows as unknown as { id: number }[]; } catch { rankExists = []; } diff --git a/src/actions/messenger.ts b/src/actions/messenger.ts index ef0bcbbb..a3c55009 100644 --- a/src/actions/messenger.ts +++ b/src/actions/messenger.ts @@ -297,10 +297,7 @@ export async function removeFriendship(formData: FormData): Promise { ), ); - return Number( - (result as unknown as [{ affectedRows: number }])[0] - ?.affectedRows ?? 0, - ); + return Number(result[0]?.affectedRows ?? 0); }); outcome = deleted > 0 ? "removed" : "not_found"; diff --git a/src/actions/password-reset.test.ts b/src/actions/password-reset.test.ts index 4062fff8..38ff699d 100644 --- a/src/actions/password-reset.test.ts +++ b/src/actions/password-reset.test.ts @@ -16,24 +16,26 @@ vi.mock("next/navigation", () => ({ }, })); -vi.mock("@/lib/db", () => ({ - db: { - select: vi.fn(() => ({ - from: vi.fn(() => ({ - where: vi.fn(() => ({ - limit: selectLimit, +vi.mock("@/lib/db", () => { + return { + db: { + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + limit: selectLimit, + })), })), })), - })), - insert: vi.fn(() => ({ - values: vi.fn(() => ({ - onDuplicateKeyUpdate: insertOnDup, + insert: vi.fn(() => ({ + values: vi.fn(() => ({ + onDuplicateKeyUpdate: insertOnDup, + })), })), - })), - }, - User: { id: "id", mail: "mail" }, - PasswordReset: { email: "email", token: "token", createdAt: "createdAt" }, -})); + }, + User: { id: "id", mail: "mail" }, + PasswordReset: { email: "email", token: "token", createdAt: "createdAt" }, + }; +}); vi.mock("@/lib/services/email", () => ({ sendMail: mockSendMail, diff --git a/src/actions/permissions.ts b/src/actions/permissions.ts index ac11d18c..d75fe7e4 100644 --- a/src/actions/permissions.ts +++ b/src/actions/permissions.ts @@ -1,7 +1,6 @@ "use server"; import { and, count, eq, inArray, sql } from "drizzle-orm"; -import type { ResultSetHeader } from "mysql2"; import { revalidateTag } from "next/cache"; import { z } from "zod"; import { @@ -254,9 +253,9 @@ export const repairAdminNavAclGrants = adminAction( `); const inserted = - Number((dashboardFillResult as ResultSetHeader).affectedRows) + - Number((midRankViewsResult as ResultSetHeader).affectedRows) + - Number((highRankToolsResult as ResultSetHeader).affectedRows); + Number(dashboardFillResult.affectedRows) + + Number(midRankViewsResult.affectedRows) + + Number(highRankToolsResult.affectedRows); await logStaffActivity({ staffId: ctx.session.user.id, diff --git a/src/actions/twofactor.test.ts b/src/actions/twofactor.test.ts index 2bb96399..9dc8a4ec 100644 --- a/src/actions/twofactor.test.ts +++ b/src/actions/twofactor.test.ts @@ -79,32 +79,30 @@ const hoistedUpdateSet = vi.hoisted(() => vi.fn(() => ({ where: hoistedUpdateWhere })), ); -vi.mock("@/lib/db", () => ({ - db: { - select: vi.fn(() => ({ from: hoistedSelectFrom })), - insert: vi.fn(() => ({ values: hoistedInsertValues })), - update: vi.fn(() => ({ set: hoistedUpdateSet })), - }, - User: { - id: "user.id", - twoFactorSecret: "user.twoFactorSecret", - twoFactorConfirmedAt: "user.twoFactorConfirmedAt", - twoFactorRecoveryCodes: "user.twoFactorRecoveryCodes", - }, -})); +vi.mock("@/lib/db", () => { + return { + db: { + select: vi.fn(() => ({ from: hoistedSelectFrom })), + insert: vi.fn(() => ({ values: hoistedInsertValues })), + update: vi.fn(() => ({ set: hoistedUpdateSet })), + }, + User: { + id: "user.id", + twoFactorSecret: "user.twoFactorSecret", + twoFactorConfirmedAt: "user.twoFactorConfirmedAt", + twoFactorRecoveryCodes: "user.twoFactorRecoveryCodes", + }, + }; +}); import { db, User } from "@/lib/db"; +import { fakeForm } from "@/test/fake-form"; import { beginTwoFactor, confirmTwoFactor, disableTwoFactor, } from "./twofactor"; -const fakeForm = (data: Record) => - ({ - get: (key: string) => data[key] ?? null, - }) as unknown as FormData; - beforeEach(() => { vi.clearAllMocks(); mockAuth.mockResolvedValue({ user: { id: "42" } }); diff --git a/src/actions/voucher.test.ts b/src/actions/voucher.test.ts index dcdd5ca7..003d6eed 100644 --- a/src/actions/voucher.test.ts +++ b/src/actions/voucher.test.ts @@ -6,6 +6,8 @@ vi.mock("next/server", () => ({ }, })); +import { fakeForm } from "@/test/fake-form"; + vi.mock("next-auth", () => ({ default: vi.fn(() => ({ handlers: {}, @@ -82,11 +84,6 @@ vi.mock("next/cache", () => ({ import { db } from "@/lib/db"; import { redeem } from "./voucher"; -const fakeForm = (data: Record) => - ({ - get: (key: string) => data[key] ?? null, - }) as unknown as FormData; - beforeEach(() => { vi.clearAllMocks(); mockAuth.mockResolvedValue({ user: { id: "1" } }); diff --git a/src/app/(site)/radio/leaderboard/page.tsx b/src/app/(site)/radio/leaderboard/page.tsx index 803a09cf..2ecc2444 100644 --- a/src/app/(site)/radio/leaderboard/page.tsx +++ b/src/app/(site)/radio/leaderboard/page.tsx @@ -3,7 +3,7 @@ import { getTranslations } from "next-intl/server"; import Link from "@/components/link"; import { ContentCard, EmptyState, RankBadge } from "@/components/public/ui"; import { UserAvatarThumbnail } from "@/components/shared/user-avatar-thumbnail"; -import { db } from "@/lib/db"; +import { queryRows } from "@/lib/db"; type LeaderRow = { username: string; @@ -15,13 +15,13 @@ async function loadRows(): Promise< { username: string; look: string; points: number }[] > { try { - const [rows] = (await db.execute(sql` + const rows = await queryRows(sql` SELECT u.username AS username, u.look AS look, p.points AS points FROM radio_listener_points p INNER JOIN users u ON u.id = p.user_id ORDER BY p.points DESC LIMIT 50 - `)) as unknown as [LeaderRow[], unknown]; + `); return (rows ?? []).map((r) => ({ username: r.username, look: r.look, diff --git a/src/app/(site)/settings/sessions/page.tsx b/src/app/(site)/settings/sessions/page.tsx index 712b4e70..4d77c294 100644 --- a/src/app/(site)/settings/sessions/page.tsx +++ b/src/app/(site)/settings/sessions/page.tsx @@ -4,7 +4,7 @@ import { getTranslations } from "next-intl/server"; import { signOutEverywhere } from "@/actions/sessions"; import { ContentCard, EmptyState } from "@/components/public/ui"; import { auth } from "@/lib/auth"; -import { db, WebsiteLoginLogs } from "@/lib/db"; +import { db, queryRows, WebsiteLoginLogs } from "@/lib/db"; import { formatDate } from "@/lib/format-date"; 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 }; let failed: Failed[] = []; try { - const [rows] = (await db.execute(sql` + failed = await queryRows(sql` SELECT id, ip_address, attempted_at FROM failed_logins WHERE username = ${session.user.name} ORDER BY id DESC LIMIT 15 - `)) as unknown as [Failed[], unknown]; - failed = rows ?? []; + `); } catch { failed = []; } diff --git a/src/app/admin/analytics/activity/page.tsx b/src/app/admin/analytics/activity/page.tsx index 40d740ef..95aea27b 100644 --- a/src/app/admin/analytics/activity/page.tsx +++ b/src/app/admin/analytics/activity/page.tsx @@ -4,7 +4,7 @@ import { redirect } from "next/navigation"; import { getLocale, getTranslations } from "next-intl/server"; import { StatsCard } from "@/components/admin/stats-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 { redisCache } from "@/lib/redis-cache"; @@ -36,8 +36,7 @@ async function safeCount( } async function rawRows(query: ReturnType): Promise { - const [rows] = (await db.execute(query)) as unknown as [T[], unknown]; - return rows ?? []; + return queryRows(query); } async function safeRawCount( diff --git a/src/app/admin/analytics/economy/page.tsx b/src/app/admin/analytics/economy/page.tsx index 6ad56c3d..8f913668 100644 --- a/src/app/admin/analytics/economy/page.tsx +++ b/src/app/admin/analytics/economy/page.tsx @@ -10,6 +10,7 @@ import { db, LogsShopPurchases, MarketplaceItems, + queryRows, RoomTradeLog, User, } from "@/lib/db"; @@ -63,8 +64,7 @@ async function safeCount(fn: () => Promise): Promise { } async function rawRows(query: ReturnType): Promise { - const [rows] = (await db.execute(query)) as unknown as [T[], unknown]; - return rows ?? []; + return queryRows(query); } async function getEconomyData( diff --git a/src/app/admin/analytics/page.tsx b/src/app/admin/analytics/page.tsx index 5d838112..1974edfe 100644 --- a/src/app/admin/analytics/page.tsx +++ b/src/app/admin/analytics/page.tsx @@ -12,7 +12,7 @@ import { redirect } from "next/navigation"; import { getLocale, getTranslations } from "next-intl/server"; import { StatsCard } from "@/components/admin/stats-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 { redisCache } from "@/lib/redis-cache"; @@ -82,8 +82,7 @@ async function safeCount( } async function rawRows(query: ReturnType): Promise { - const [rows] = (await db.execute(query)) as unknown as [T[], unknown]; - return rows ?? []; + return queryRows(query); } async function getAnalyticsData( diff --git a/src/app/admin/logs/_lib/load-ignored-logs.ts b/src/app/admin/logs/_lib/load-ignored-logs.ts index daf7ae76..5476b236 100644 --- a/src/app/admin/logs/_lib/load-ignored-logs.ts +++ b/src/app/admin/logs/_lib/load-ignored-logs.ts @@ -1,7 +1,7 @@ import { sql } from "drizzle-orm"; import { redirect } from "next/navigation"; import { calcPagination, parseListParams } from "@/lib/admin-helpers"; -import { db } from "@/lib/db"; +import { queryRows } from "@/lib/db"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; type ListParamsInput = { @@ -37,8 +37,7 @@ function parsePageParams( } async function rawRows(query: SqlFragment): Promise { - const [rows] = (await db.execute(query)) as unknown as [T[], unknown]; - return rows ?? []; + return queryRows(query); } /** diff --git a/src/app/admin/navigation/page.tsx b/src/app/admin/navigation/page.tsx index 3bd651a9..687d3858 100644 --- a/src/app/admin/navigation/page.tsx +++ b/src/app/admin/navigation/page.tsx @@ -2,7 +2,13 @@ import { asc, inArray, sql } from "drizzle-orm"; import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; 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"; // navigator_flatcats — emulator-owned flat (private-room) categories shown in the @@ -65,12 +71,11 @@ export default async function AdminNavigation() { let publics: PublicRow[] = []; try { - const [rows] = (await db.execute(sql` + publics = await queryRows(sql` SELECT public_cat_id, room_id, visible FROM navigator_publics ORDER BY public_cat_id ASC, room_id ASC - `)) as unknown as [PublicRow[], unknown]; - publics = rows ?? []; + `); } catch { publics = []; } diff --git a/src/app/admin/permissions/page.tsx b/src/app/admin/permissions/page.tsx index e1106c9a..2e78e0df 100644 --- a/src/app/admin/permissions/page.tsx +++ b/src/app/admin/permissions/page.tsx @@ -2,7 +2,7 @@ import { sql } from "drizzle-orm"; import { redirect } from "next/navigation"; import { getTranslations } from "next-intl/server"; import Link from "@/components/link"; -import { db } from "@/lib/db"; +import { db, queryRows } from "@/lib/db"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; import { fetchEmulatorRankSummaries } from "@/lib/services/permission-ranks"; import { PermissionsList } from "./permissions-list"; @@ -25,9 +25,9 @@ export default async function PermissionsPage({ const ranks = await fetchEmulatorRankSummaries(db); // 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\``, - )) as unknown as [{ rank: number; cnt: number }[], unknown[]]; + ); const countMap: Record = {}; for (const row of rawCounts) { countMap[Number(row.rank)] = Number(row.cnt); diff --git a/src/app/admin/permissions/preview/page.test.tsx b/src/app/admin/permissions/preview/page.test.tsx index 96afe93a..764e5113 100644 --- a/src/app/admin/permissions/preview/page.test.tsx +++ b/src/app/admin/permissions/preview/page.test.tsx @@ -2,7 +2,10 @@ import { renderToStaticMarkup } from "react-dom/server"; import { beforeEach, expect, it, vi } from "vitest"; 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", () => ({ PERMS: { PERMISSIONS_MANAGE: "admin.permissions.manage" }, getAdminContext: async () => ({ diff --git a/src/app/admin/permissions/preview/page.tsx b/src/app/admin/permissions/preview/page.tsx index 5f9c83d2..91c04482 100644 --- a/src/app/admin/permissions/preview/page.tsx +++ b/src/app/admin/permissions/preview/page.tsx @@ -4,7 +4,7 @@ import { getTranslations } from "next-intl/server"; import Link from "@/components/link"; import { isDynamicSuperAdmin } from "@/lib/admin/authorization-policy"; import { previewRoleAccess } from "@/lib/admin/permission-preview"; -import { db } from "@/lib/db"; +import { queryRows } from "@/lib/db"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; export default async function PermissionPreview({ @@ -17,14 +17,11 @@ export default async function PermissionPreview({ redirect("/admin"); const t = await getTranslations("pages.admin.permissionPreview"); const nav = await getTranslations("pages.admin.nav"); - const [roleRows] = await db.execute( - sql`SELECT id, slug, title FROM acl_roles ORDER BY title, id`, - ); - const roles = roleRows as unknown as { + const roles = await queryRows<{ id: number; slug: string; title: string; - }[]; + }>(sql`SELECT id, slug, title FROM acl_roles ORDER BY title, id`); const params = await searchParams; const selected = roles.find((r) => String(r.id) === params.role) ?? @@ -33,15 +30,13 @@ export default async function PermissionPreview({ let superAdmin = false; let permissionTitles: Record = {}; 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}`, ); - 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 = ( - 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; const rankMatch = /^rank_([1-9]\d*)$/.exec(selected.slug); superAdmin = @@ -54,14 +49,11 @@ export default async function PermissionPreview({ grants.map((g) => g.slug), superAdmin, ); - const [allRows] = await db.execute( + const allRows = await queryRows<{ slug: string; title: string }>( sql`SELECT slug, title FROM acl_permissions ORDER BY slug`, ); permissionTitles = Object.fromEntries( - (allRows as unknown as { slug: string; title: string }[]).map((p) => [ - p.slug, - p.title, - ]), + allRows.map((p) => [p.slug, p.title]), ); } return ( diff --git a/src/app/admin/subscriptions/page.tsx b/src/app/admin/subscriptions/page.tsx index ac56bc47..d965b154 100644 --- a/src/app/admin/subscriptions/page.tsx +++ b/src/app/admin/subscriptions/page.tsx @@ -4,7 +4,7 @@ import { getTranslations } from "next-intl/server"; import { AdminSimplePager } from "@/components/admin/admin-simple-pager"; import { StatusCard } from "@/components/admin/dashboard"; import { calcPagination, parseListParams } from "@/lib/admin-helpers"; -import { db } from "@/lib/db"; +import { queryRows } from "@/lib/db"; import { formatDate } from "@/lib/format-date"; import { canAccess, getAdminContext, PERMS } from "@/lib/permissions"; @@ -24,8 +24,7 @@ type SubSqlRow = { }; async function rawRows(query: ReturnType): Promise { - const [rows] = (await db.execute(query)) as unknown as [T[], unknown]; - return rows ?? []; + return queryRows(query); } export default async function AdminSubscriptions({ diff --git a/src/app/admin/users/_lib/load-user-overview.test.ts b/src/app/admin/users/_lib/load-user-overview.test.ts index 231470ad..9bf1bcde 100644 --- a/src/app/admin/users/_lib/load-user-overview.test.ts +++ b/src/app/admin/users/_lib/load-user-overview.test.ts @@ -7,7 +7,10 @@ const state = vi.hoisted(() => ({ allowed: new Set(), 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 () => ({ ...(await import("@/lib/permission-slugs")), canAccess: (_: unknown, permission: string) => state.allowed.has(permission), diff --git a/src/app/admin/users/_lib/load-user-overview.ts b/src/app/admin/users/_lib/load-user-overview.ts index 09d18a58..a3ae9c69 100644 --- a/src/app/admin/users/_lib/load-user-overview.ts +++ b/src/app/admin/users/_lib/load-user-overview.ts @@ -1,5 +1,5 @@ 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"; export type OverviewRow = { @@ -27,8 +27,7 @@ export async function loadUserOverview( rank: number, ): Promise { const read = async (query: SQL): Promise => { - const [rows] = await db.execute(query); - return rows as unknown as OverviewRow[]; + return queryRows(query); }; const sources: { key: string; diff --git a/src/app/admin/users/_lib/load-user.test.ts b/src/app/admin/users/_lib/load-user.test.ts index 2383208f..e0141c78 100644 --- a/src/app/admin/users/_lib/load-user.test.ts +++ b/src/app/admin/users/_lib/load-user.test.ts @@ -11,54 +11,56 @@ vi.mock("next/navigation", () => ({ throw new Error("not found"); }, })); -vi.mock("@/lib/db", () => { +vi.mock("@/lib/db", async () => { const tables = Object.fromEntries( ["User", "Rooms", "RoomTradeLog", "UsersBadges", "UsersCurrency"].map( (name) => [name, { name }], ), ); + const db = { + select() { + let name = ""; + const rows = () => + name === "User" + ? [{ id: 42, username: "User", rank: 2, online: "0" }] + : []; + const query = { + from(table: { name: string }) { + name = table.name; + state.tables.push(name); + return query; + }, + where() { + return query; + }, + orderBy() { + return query; + }, + limit() { + return query; + }, + // biome-ignore lint/suspicious/noThenProperty: Drizzle queries are intentionally awaitable. + then(resolve: (value: unknown[]) => unknown) { + return Promise.resolve(rows()).then(resolve); + }, + catch() { + return Promise.resolve(rows()); + }, + }; + return query; + }, + async execute(query: Parameters[0]) { + const compiled = new MySqlDialect().sqlToQuery(query).sql; + state.sql.push(compiled); + if (state.failLogs && compiled.includes("chatlogs_room")) + throw new Error("unavailable"); + return [[], []]; + }, + }; return { ...tables, - db: { - select() { - let name = ""; - const rows = () => - name === "User" - ? [{ id: 42, username: "User", rank: 2, online: "0" }] - : []; - const query = { - from(table: { name: string }) { - name = table.name; - state.tables.push(name); - return query; - }, - where() { - return query; - }, - orderBy() { - return query; - }, - limit() { - return query; - }, - // biome-ignore lint/suspicious/noThenProperty: Drizzle queries are intentionally awaitable. - then(resolve: (value: unknown[]) => unknown) { - return Promise.resolve(rows()).then(resolve); - }, - catch() { - return Promise.resolve(rows()); - }, - }; - return query; - }, - async execute(query: Parameters[0]) { - const compiled = new MySqlDialect().sqlToQuery(query).sql; - state.sql.push(compiled); - if (state.failLogs && compiled.includes("chatlogs_room")) - throw new Error("unavailable"); - return [[], []]; - }, - }, + db, + ...(await import("@/test/db-helpers")).createDbHelpers(db.execute), }; }); diff --git a/src/app/admin/users/_lib/load-user.ts b/src/app/admin/users/_lib/load-user.ts index c85a7dfa..d359647e 100644 --- a/src/app/admin/users/_lib/load-user.ts +++ b/src/app/admin/users/_lib/load-user.ts @@ -2,6 +2,7 @@ import { asc, count, desc, eq, or, sql } from "drizzle-orm"; import { notFound } from "next/navigation"; import { db, + queryRows, Rooms, RoomTradeLog, User, @@ -10,8 +11,7 @@ import { } from "@/lib/db"; async function rawRows(query: ReturnType): Promise { - const [rows] = (await db.execute(query)) as unknown as [T[], unknown]; - return rows ?? []; + return queryRows(query); } export async function loadUserById( diff --git a/src/app/api/admin/analytics/export/route.ts b/src/app/api/admin/analytics/export/route.ts index 61656d34..b50a1b4d 100644 --- a/src/app/api/admin/analytics/export/route.ts +++ b/src/app/api/admin/analytics/export/route.ts @@ -1,7 +1,7 @@ import { sql } from "drizzle-orm"; import { apiError } from "@/lib/api"; import { withAdmin } from "@/lib/api-handler"; -import { db } from "@/lib/db"; +import { queryRows } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; const VALID_REPORTS = new Set(["registrations", "online-by-hour", "economy"]); @@ -20,13 +20,13 @@ function csvRow(values: unknown[]): string { } async function streamRegistrations(): Promise> { - 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 FROM users WHERE account_created > UNIX_TIMESTAMP(NOW() - INTERVAL 90 DAY) GROUP BY day ORDER BY day ASC - `)) as unknown as [{ day: string; cnt: bigint }[], unknown]; + `); const encoder = new TextEncoder(); return new ReadableStream({ start(controller) { @@ -40,13 +40,13 @@ async function streamRegistrations(): Promise> { } async function streamOnlineByHour(): Promise> { - 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 FROM users WHERE last_online > UNIX_TIMESTAMP(NOW() - INTERVAL 30 DAY) GROUP BY hour ORDER BY hour ASC - `)) as unknown as [{ hour: number; cnt: bigint }[], unknown]; + `); const encoder = new TextEncoder(); return new ReadableStream({ start(controller) { @@ -62,12 +62,12 @@ async function streamOnlineByHour(): Promise> { } async function streamEconomy(): Promise> { - 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 - `)) as unknown as [ - { total_credits: bigint; total_pixels: bigint }[], - unknown, - ]; + `); const totals = rows[0]; const encoder = new TextEncoder(); return new ReadableStream({ diff --git a/src/app/api/admin/import/furni/nitro-editor/route.ts b/src/app/api/admin/import/furni/nitro-editor/route.ts index 0c6af927..ec503328 100644 --- a/src/app/api/admin/import/furni/nitro-editor/route.ts +++ b/src/app/api/admin/import/furni/nitro-editor/route.ts @@ -4,7 +4,7 @@ import { eq, sql } from "drizzle-orm"; import type { NextRequest } from "next/server"; import { apiError, apiOk } from "@/lib/api"; 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 { logAudit } from "@/lib/services/audit"; import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs"; @@ -48,17 +48,14 @@ export const GET = withAdmin( const { json } = parseNitroBundle(buffer); // Fetch interaction flags from DB - const [items] = (await db.execute(sql` + const items = await queryRows<{ + allow_sit: string; + allow_lay: string; + allow_walk: string; + }>(sql` SELECT allow_sit, allow_lay, allow_walk FROM items_base WHERE item_name = ${rawClassname} LIMIT 1 - `)) as unknown as [ - Array<{ - allow_sit: string; - allow_lay: string; - allow_walk: string; - }>, - unknown, - ]; + `); const dbRow = items[0]; const flags = { diff --git a/src/app/api/admin/import/furni/resync/route.ts b/src/app/api/admin/import/furni/resync/route.ts index a03b79e0..e014a24a 100644 --- a/src/app/api/admin/import/furni/resync/route.ts +++ b/src/app/api/admin/import/furni/resync/route.ts @@ -2,7 +2,7 @@ import { existsSync } from "node:fs"; import { asc, inArray, sql } from "drizzle-orm"; import { apiOk } from "@/lib/api"; 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 { appendFurniEntriesBatch, @@ -221,7 +221,7 @@ async function resolveTargetIds( Date.now() - days * 24 * 60 * 60 * 1000, ).toISOString(); 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 FROM admin_audit_log WHERE action = 'furni_import' @@ -229,6 +229,6 @@ async function resolveTargetIds( AND target_id IS NOT NULL AND created_at >= ${sinceMysql} ORDER BY target_id ASC - `)) as unknown as [Array<{ target_id: number }>, unknown]; + `); return auditRows.map((r) => r.target_id).filter(Boolean); } diff --git a/src/app/api/admin/import/furni/route.ts b/src/app/api/admin/import/furni/route.ts index bea7a874..e8446253 100644 --- a/src/app/api/admin/import/furni/route.ts +++ b/src/app/api/admin/import/furni/route.ts @@ -3,7 +3,7 @@ import path from "node:path"; import { eq, sql } from "drizzle-orm"; import { apiError, apiOk } from "@/lib/api"; 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 { localFurnitureStatus } from "@/lib/furni/local-presence"; import { PERMS } from "@/lib/permissions"; @@ -184,21 +184,18 @@ export const GET = withAdmin( ); 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 WHERE EXISTS ( SELECT 1 FROM catalog_items ci 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) const missing = importedItems.filter((item) => { diff --git a/src/app/api/admin/import/organize/route.ts b/src/app/api/admin/import/organize/route.ts index 05252034..c153da86 100644 --- a/src/app/api/admin/import/organize/route.ts +++ b/src/app/api/admin/import/organize/route.ts @@ -9,7 +9,7 @@ import { } from "@/features/catalog/server/import-pages"; import { apiOk } from "@/lib/api"; import { withAdmin } from "@/lib/api-handler"; -import { db, ItemsBase } from "@/lib/db"; +import { db, ItemsBase, queryRows } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; interface OrganizeItem { @@ -59,7 +59,7 @@ export const GET = withAdmin( const placedById = new Map(); const itemIdsToResolve = new Set(); if (pageIds.length > 0) { - const [rows] = (await db.execute(sql` + const rows = await queryRows>(sql` SELECT ci.id AS catalogItemId, ci.page_id AS sourcePageId, ci.item_ids AS itemIds, @@ -70,7 +70,7 @@ export const GET = withAdmin( WHERE ci.page_id IN (${sql.join(pageIds, sql`, `)}) ORDER BY ci.id LIMIT ${limit} - `)) as unknown as [Record[], unknown]; + `); for (const r of rows) { const parsed = parseItemIds(String(r.itemIds ?? "")); @@ -96,7 +96,7 @@ export const GET = withAdmin( // 2) Recently imported furniture. Bounded candidate list first, matched // 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 FROM admin_audit_log alog WHERE alog.action = 'furni_import' @@ -105,7 +105,7 @@ export const GET = withAdmin( AND alog.created_at >= ${since} ORDER BY itemId DESC LIMIT 2000 - `)) as unknown as [Array<{ itemId: number }>, unknown]; + `); for (const r of candidateRows) { const itemId = Number(r.itemId); diff --git a/src/app/api/admin/permissions/roles/route.ts b/src/app/api/admin/permissions/roles/route.ts index aea4cad3..5d05a0e4 100644 --- a/src/app/api/admin/permissions/roles/route.ts +++ b/src/app/api/admin/permissions/roles/route.ts @@ -1,5 +1,4 @@ import { and, eq } from "drizzle-orm"; -import type { ResultSetHeader } from "mysql2"; import { revalidateTag } from "next/cache"; import { NextResponse } from "next/server"; import { apiError } from "@/lib/api"; @@ -17,9 +16,7 @@ export const POST = withAdmin( const title = String(body.title ?? "").trim(); if (!/^[a-z0-9._-]{2,64}$/.test(slug) || !title) return apiError("Valid slug and title required", 400); - const [result] = (await db - .insert(AclRole) - .values({ slug, title })) as unknown as [ResultSetHeader]; + const [result] = await db.insert(AclRole).values({ slug, title }); const role = { id: Number(result.insertId), slug, title }; revalidateTag("permissions", { expire: 0 }); return NextResponse.json({ role }); diff --git a/src/app/api/admin/prefixes/blacklist/route.ts b/src/app/api/admin/prefixes/blacklist/route.ts index 3f16beb4..f2938994 100644 --- a/src/app/api/admin/prefixes/blacklist/route.ts +++ b/src/app/api/admin/prefixes/blacklist/route.ts @@ -1,7 +1,7 @@ import { sql } from "drizzle-orm"; import { apiError, apiOk } from "@/lib/api"; import { withAdmin } from "@/lib/api-handler"; -import { db } from "@/lib/db"; +import { db, queryRows } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; interface BlacklistWord { @@ -10,9 +10,9 @@ interface BlacklistWord { } export const GET = withAdmin({ permission: PERMS.PREFIXES_VIEW }, async () => { - const [words] = (await db.execute( + const words = await queryRows( sql`SELECT id, word FROM custom_prefix_blacklist ORDER BY word ASC`, - )) as unknown as [BlacklistWord[], unknown]; + ); return apiOk({ words }); }); diff --git a/src/app/api/admin/prefixes/route.ts b/src/app/api/admin/prefixes/route.ts index e54e1e68..12cf11fc 100644 --- a/src/app/api/admin/prefixes/route.ts +++ b/src/app/api/admin/prefixes/route.ts @@ -1,7 +1,7 @@ import { sql } from "drizzle-orm"; import { apiError, apiOk } from "@/lib/api"; import { withAdmin } from "@/lib/api-handler"; -import { db } from "@/lib/db"; +import { db, queryRows } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; interface UserPrefix { @@ -28,21 +28,20 @@ export const GET = withAdmin( ? sql`WHERE up.text LIKE ${`%${q}%`} OR u.username LIKE ${`%${q}%`}` : sql``; - const [prefixes] = - (await db.execute(sql`SELECT up.id, up.user_id, up.text, up.color, up.icon, up.effect, up.active, u.username + const prefixes = await queryRows( + sql`SELECT up.id, up.user_id, up.text, up.color, up.icon, up.effect, up.active, u.username FROM custom_prefixes up LEFT JOIN users u ON u.id = up.user_id ${whereFragment} ORDER BY up.id DESC - LIMIT ${limit} OFFSET ${offset}`)) as unknown as [ - UserPrefix[], - unknown, - ]; + LIMIT ${limit} OFFSET ${offset}`, + ); - const [countResult] = - (await db.execute(sql`SELECT COUNT(*) as total FROM custom_prefixes up + const countResult = await queryRows<{ total: bigint }>( + sql`SELECT COUNT(*) as total FROM custom_prefixes up 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); @@ -70,9 +69,9 @@ export const POST = withAdmin( 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`, - )) as unknown as [{ id: number }[], unknown]; + ); if (!users || users.length === 0) { return apiError("User not found"); diff --git a/src/app/api/admin/prefixes/settings/route.ts b/src/app/api/admin/prefixes/settings/route.ts index f081e804..72c5bcba 100644 --- a/src/app/api/admin/prefixes/settings/route.ts +++ b/src/app/api/admin/prefixes/settings/route.ts @@ -1,7 +1,7 @@ import { sql } from "drizzle-orm"; import { apiError, apiOk } from "@/lib/api"; import { withAdmin } from "@/lib/api-handler"; -import { db } from "@/lib/db"; +import { db, queryRows } from "@/lib/db"; import { PERMS } from "@/lib/permissions"; interface PrefixSetting { @@ -32,9 +32,9 @@ const DEFAULT_SETTINGS: Record = { }; export const GET = withAdmin({ permission: PERMS.PREFIXES_VIEW }, async () => { - const [settings] = (await db.execute( + const settings = await queryRows( sql`SELECT \`key\`, \`value\` FROM custom_prefix_settings`, - )) as unknown as [PrefixSetting[], unknown]; + ); const result: Record = { ...DEFAULT_SETTINGS }; for (const s of settings) { diff --git a/src/app/api/admin/studio/inspect/route.test.ts b/src/app/api/admin/studio/inspect/route.test.ts index a26f9488..b3aa6e55 100644 --- a/src/app/api/admin/studio/inspect/route.test.ts +++ b/src/app/api/admin/studio/inspect/route.test.ts @@ -13,7 +13,10 @@ vi.mock("@/lib/api-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-asset-dirs", () => ({ getFurniAssetDirs: async () => ({ diff --git a/src/app/api/badges/leaderboard/route.ts b/src/app/api/badges/leaderboard/route.ts index 503b9665..d787c71d 100644 --- a/src/app/api/badges/leaderboard/route.ts +++ b/src/app/api/badges/leaderboard/route.ts @@ -3,7 +3,7 @@ import { connection } from "next/server"; import { apiJson } from "@/lib/api"; import { bearerUserId } from "@/lib/api-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 { apiCacheKey, cacheSafe, redisCache } from "@/lib/redis-cache"; @@ -68,8 +68,7 @@ function rankEntries( } async function rawRows(query: ReturnType): Promise { - const [rows] = (await db.execute(query)) as unknown as [T[], unknown]; - return rows; + return queryRows(query); } async function loadTotalBadgesBoard(): Promise { diff --git a/src/app/api/me/tokens/route.ts b/src/app/api/me/tokens/route.ts index 0aaf4198..ede1dadd 100644 --- a/src/app/api/me/tokens/route.ts +++ b/src/app/api/me/tokens/route.ts @@ -9,7 +9,6 @@ // table has no expires_at column, so it is never read or written here. import { and, desc, eq } from "drizzle-orm"; -import type { ResultSetHeader } from "mysql2"; import { apiError, apiJson, apiUnavailable, positiveBigInt } from "@/lib/api"; import { auth } from "@/lib/auth"; import { personalTokenScope } from "@/lib/auth/personal-token-scope"; @@ -61,7 +60,7 @@ export async function DELETE(req: Request) { try { // Scope the delete to the owner so users cannot revoke others' tokens. const scope = personalTokenScope(id); - const [result] = (await db + const [result] = await db .delete(PersonalAccessTokens) .where( and( @@ -69,7 +68,7 @@ export async function DELETE(req: Request) { eq(PersonalAccessTokens.tokenableId, scope.tokenableId), eq(PersonalAccessTokens.tokenableType, scope.tokenableType), ), - )) as unknown as [ResultSetHeader]; + ); if (!result.affectedRows) { return apiError("Token not found", 404); } diff --git a/src/app/api/paypal/capture/route.ts b/src/app/api/paypal/capture/route.ts index b2b4ff19..33ead608 100644 --- a/src/app/api/paypal/capture/route.ts +++ b/src/app/api/paypal/capture/route.ts @@ -1,5 +1,4 @@ import { and, eq } from "drizzle-orm"; -import type { ResultSetHeader } from "mysql2"; import { NextResponse } from "next/server"; import { auth } from "@/lib/auth"; import { sessionUserId } from "@/lib/auth/session-user"; @@ -119,7 +118,7 @@ export async function POST(req: Request): Promise { const credits = Math.floor(amount * creditsPerUnit()); try { await claimTopupDelivery(async () => { - const [result] = (await db + const [result] = await db .update(WebsitePaypalTransactions) .set({ status: "CREDIT_DELIVERING", updatedAt: new Date() }) .where( @@ -128,7 +127,7 @@ export async function POST(req: Request): Promise { eq(WebsitePaypalTransactions.transactionId, orderId), eq(WebsitePaypalTransactions.status, "CAPTURED_PENDING_CREDIT"), ), - )) as unknown as [ResultSetHeader]; + ); return { count: result.affectedRows }; }); await sendCurrency({ rcon, db: currencyDb }, userId, "credits", credits); @@ -217,7 +216,7 @@ export async function POST(req: Request): Promise { // 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). try { - const [claimed] = (await db + const [claimed] = await db .update(WebsitePaypalTransactions) .set({ status: "CAPTURED_PENDING_CREDIT", @@ -233,7 +232,7 @@ export async function POST(req: Request): Promise { eq(WebsitePaypalTransactions.transactionId, orderId), eq(WebsitePaypalTransactions.status, "CREATED"), ), - )) as unknown as [ResultSetHeader]; + ); if (claimed.affectedRows !== 1) throw new Error("Top-up order was already claimed"); } catch (e) { @@ -255,7 +254,7 @@ export async function POST(req: Request): Promise { // for staff reconciliation instead of automatically risking a second grant. try { await claimTopupDelivery(async () => { - const [claimResult] = (await db + const [claimResult] = await db .update(WebsitePaypalTransactions) .set({ status: "CREDIT_DELIVERING", updatedAt: new Date() }) .where( @@ -264,7 +263,7 @@ export async function POST(req: Request): Promise { eq(WebsitePaypalTransactions.transactionId, orderId), eq(WebsitePaypalTransactions.status, "CAPTURED_PENDING_CREDIT"), ), - )) as unknown as [ResultSetHeader]; + ); return { count: claimResult.affectedRows }; }); await sendCurrency({ rcon, db: currencyDb }, userId, "credits", credits); diff --git a/src/app/api/tickets/route.ts b/src/app/api/tickets/route.ts index 11449a82..1ff3e1d1 100644 --- a/src/app/api/tickets/route.ts +++ b/src/app/api/tickets/route.ts @@ -5,7 +5,6 @@ // DB errors return an apiError envelope, never a 500. import { desc, eq } from "drizzle-orm"; -import type { ResultSetHeader } from "mysql2"; import { apiError, apiJson, positiveBigInt } from "@/lib/api"; import { bearerUserId } from "@/lib/api-auth"; import { db, WebsiteHelpCenterTickets } from "@/lib/db"; @@ -79,7 +78,7 @@ export async function POST(req: Request) { try { const now = new Date(); - const [result] = (await db.insert(WebsiteHelpCenterTickets).values({ + const [result] = await db.insert(WebsiteHelpCenterTickets).values({ userId: uid, categoryId, title, @@ -87,7 +86,7 @@ export async function POST(req: Request) { open: true, createdAt: now, updatedAt: now, - })) as unknown as [ResultSetHeader]; + }); return apiJson( { diff --git a/src/features/catalog/server/bulk-offers.test.ts b/src/features/catalog/server/bulk-offers.test.ts index f044722c..3f268bb6 100644 --- a/src/features/catalog/server/bulk-offers.test.ts +++ b/src/features/catalog/server/bulk-offers.test.ts @@ -32,8 +32,8 @@ const state = vi.hoisted(() => ({ commits: 0, rollbacks: 0, })); -vi.mock("@/lib/db", () => ({ - db: { +vi.mock("@/lib/db", async () => { + const db = { execute: async () => [state.pages, []], transaction: async (fn: (tx: unknown) => Promise) => { const before = state.writes; @@ -112,11 +112,16 @@ vi.mock("@/lib/db", () => ({ throw error; } }, - }, -})); + }; + return { + db, + ...(await import("@/test/db-helpers")).createDbHelpers(db.execute), + }; +}); -vi.mock("@/features/operations/server", async () => { - const { db } = await import("@/lib/db"); +import { db } from "@/lib/db"; + +vi.mock("@/features/operations/server", () => { return { runOperation: ( _input: unknown, diff --git a/src/features/catalog/server/bulk-offers.ts b/src/features/catalog/server/bulk-offers.ts index d60d9a4b..5dcd3e35 100644 --- a/src/features/catalog/server/bulk-offers.ts +++ b/src/features/catalog/server/bulk-offers.ts @@ -8,7 +8,7 @@ import { recordHistory, } from "@/features/history/server"; import { enqueueEffect, runOperation } from "@/features/operations/server"; -import { db } from "@/lib/db"; +import { db, queryRows, rowsFrom } from "@/lib/db"; import { type BulkOfferInput, type BulkOfferPreview, @@ -29,10 +29,11 @@ async function readOffers( ids: number[], lock = false, ): Promise { - const [rows] = 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``}`, - ); - const offers = (rows as unknown as OfferRow[]).map((row) => ({ + const offers = rowsFrom( + 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``}`, + ), + ).map((row) => ({ ...row, id: Number(row.id), pageId: Number(row.pageId), @@ -63,10 +64,11 @@ async function readPages( ].sort((a, b) => a - b); if (ids.some((id) => !Number.isInteger(id) || id <= 0)) throw new CatalogInputError("An offer has an invalid category reference"); - const [rows] = 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``}`, - ); - const pages = (rows as unknown as { id: number; caption: string }[]) + 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``}`, + ), + ) .filter((row) => ids.includes(Number(row.id))) .map((row) => ({ id: Number(row.id), caption: row.caption })) .sort((a, b) => a.id - b.id); @@ -193,12 +195,11 @@ export async function applyBulkOffersCommand( } export async function listBulkOfferDestinationsCommand() { - const [rows] = await db.execute( - 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( - (row) => ({ id: Number(row.id), caption: row.caption }), - ); + const pages = ( + await queryRows<{ id: number; caption: string }>( + sql`SELECT id, caption FROM catalog_pages WHERE id > 0 ORDER BY caption, id LIMIT 5001`, + ) + ).map((row) => ({ id: Number(row.id), caption: row.caption })); if (pages.length > 5000) throw new CatalogInputError( "The catalog has more than 5000 categories. Use bulk editing from a category instead.", diff --git a/src/features/catalog/server/bulk-undo.test.ts b/src/features/catalog/server/bulk-undo.test.ts index f4e7dda1..76e3729c 100644 --- a/src/features/catalog/server/bulk-undo.test.ts +++ b/src/features/catalog/server/bulk-undo.test.ts @@ -10,7 +10,8 @@ const state = vi.hoisted(() => ({ audit: [] as unknown[], queries: [] as string[], })); -vi.mock("@/lib/db", () => ({ +vi.mock("@/lib/db", async () => ({ + rowsFrom: (await import("@/test/db-helpers")).rowsFrom, db: { transaction: async (fn: (tx: unknown) => Promise) => { const saved = structuredClone(state.current); @@ -79,8 +80,9 @@ vi.mock("@/lib/db", () => ({ }, })); -vi.mock("@/features/operations/server", async () => { - const { db } = await import("@/lib/db"); +import { db } from "@/lib/db"; + +vi.mock("@/features/operations/server", () => { return { runOperation: ( _input: unknown, diff --git a/src/features/catalog/server/duplicate-commands.test.ts b/src/features/catalog/server/duplicate-commands.test.ts index deb186c5..d1d83de4 100644 --- a/src/features/catalog/server/duplicate-commands.test.ts +++ b/src/features/catalog/server/duplicate-commands.test.ts @@ -27,7 +27,8 @@ const state = vi.hoisted(() => ({ vi.mock("@/lib/services/furni-import", () => ({ 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: { transaction: async (fn: (tx: unknown) => Promise) => { const saved = state.writes; diff --git a/src/features/catalog/server/duplicate-commands.ts b/src/features/catalog/server/duplicate-commands.ts index 8b68be5f..abc1a60d 100644 --- a/src/features/catalog/server/duplicate-commands.ts +++ b/src/features/catalog/server/duplicate-commands.ts @@ -2,7 +2,7 @@ import "server-only"; import { createHash } from "node:crypto"; import { sql } from "drizzle-orm"; import { z } from "zod"; -import { db } from "@/lib/db"; +import { db, queryRows, rowsFrom } from "@/lib/db"; import { allocateCatalogItemId } from "@/lib/services/furni-import"; import { type DuplicateInput, @@ -28,10 +28,11 @@ function table(kind: DuplicateInput["kind"], offers = false) { } async function snapshot(tx: Transaction, input: DuplicateInput, lock: boolean) { const suffix = lock ? sql` FOR UPDATE` : sql``; - const [rawPages] = await tx.execute( - sql`SELECT * FROM ${table(input.kind)} ORDER BY id LIMIT 20001${suffix}`, + const pages = rowsFrom( + await tx.execute( + sql`SELECT * FROM ${table(input.kind)} ORDER BY id LIMIT 20001${suffix}`, + ), ); - const pages = rawPages as unknown as DuplicateRow[]; if (pages.length > 20000) throw new CatalogInputError( "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) if (input.kind === "normal") remapIncludes(page.includes, new Map(), existing); - const [rawOffers] = 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}`, + const offers = rowsFrom( + 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}`, + ), ); - const offers = rawOffers as unknown as DuplicateRow[]; if (offers.length > 5000) throw new CatalogInputError("Duplicate at most 5000 offers at a time"); const furniture = [ ...new Set(offers.flatMap((o) => parseFurnitureIds(String(o.item_ids)))), ].sort((a, b) => a - b); 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( - (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))) throw new CatalogInputError("Furniture reference not found"); @@ -134,9 +137,7 @@ export async function duplicateCategoryCommand( sql`, `, )}`, ); - const id = Number( - row.id ?? (result as unknown as { insertId: number }).insertId, - ); + const id = Number(row.id ?? result.insertId); if (!id) throw Error("Could not allocate duplicate category ID"); return id; }; @@ -195,10 +196,9 @@ export async function duplicateDestinationsCommand( kind: DuplicateInput["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`, ); - const pages = rows as unknown as { id: number; caption: string }[]; if (pages.length > 20000) throw new CatalogInputError( "Catalog exceeds duplication safety limit (20000 categories)", diff --git a/src/features/catalog/server/import-pages.ts b/src/features/catalog/server/import-pages.ts index b84baca0..c18b8424 100644 --- a/src/features/catalog/server/import-pages.ts +++ b/src/features/catalog/server/import-pages.ts @@ -1,7 +1,7 @@ import "server-only"; import { sql } from "drizzle-orm"; -import { db } from "@/lib/db"; +import { queryRows } from "@/lib/db"; /** * 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. */ export async function getImportedCategoryPageIds(): Promise { - const [rows] = (await db.execute(sql` + const rows = await queryRows<{ id: number }>(sql` SELECT id FROM catalog_pages WHERE caption_save = 'imported_furni' AND parent_id IN (-1, 0) LIMIT 1 - `)) as unknown as [Array<{ id: number }>, unknown]; + `); const rootId = Number(rows[0]?.id ?? 0); 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} - `)) as unknown as [Array<{ id: number }>, unknown]; + `); const children = childRows.map((r) => Number(r.id)); const pageIds = [rootId, ...children]; if (children.length > 0) { - const [grandRows] = (await db.execute(sql` + const grandRows = await queryRows<{ id: number }>(sql` SELECT id FROM catalog_pages WHERE parent_id IN (${sql.join(children, sql`, `)}) - `)) as unknown as [Array<{ id: number }>, unknown]; + `); pageIds.push(...grandRows.map((r) => Number(r.id))); } return pageIds; @@ -37,11 +37,11 @@ export async function getImportedCategoryPageIds(): Promise { /** Caption of the imported-furniture root page, when present. */ export async function getImportedRootCaption(): Promise { - const [rows] = (await db.execute(sql` + const rows = await queryRows<{ caption: string }>(sql` SELECT caption FROM catalog_pages WHERE caption_save = 'imported_furni' AND parent_id IN (-1, 0) LIMIT 1 - `)) as unknown as [Array<{ caption: string }>, unknown]; + `); return rows[0]?.caption ?? null; } diff --git a/src/features/catalog/server/offer-commands.test.ts b/src/features/catalog/server/offer-commands.test.ts index a6bc3ddf..34a2dd81 100644 --- a/src/features/catalog/server/offer-commands.test.ts +++ b/src/features/catalog/server/offer-commands.test.ts @@ -16,6 +16,7 @@ const state = vi.hoisted(() => ({ rollbacks: 0, })); vi.mock("@/lib/db", async () => ({ + rowsFrom: (await import("@/test/db-helpers")).rowsFrom, ...(await import("@/db/schema")), db: { transaction: async (fn: (tx: unknown) => Promise) => { diff --git a/src/features/catalog/server/offer-commands.ts b/src/features/catalog/server/offer-commands.ts index d67c7f10..48883b9b 100644 --- a/src/features/catalog/server/offer-commands.ts +++ b/src/features/catalog/server/offer-commands.ts @@ -2,7 +2,13 @@ import "server-only"; import { getTableColumns, type SQL, sql } from "drizzle-orm"; import { z } from "zod"; 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 { distinctOfferIds, @@ -31,13 +37,14 @@ async function lockedOffers( ids: number[], kind: OfferKind = "normal", ): Promise { - const [rows] = await tx.execute( - sql`SELECT id, page_id AS pageId, item_ids AS itemIds FROM ${offerTable(kind)} WHERE id IN (${sql.join( - [...ids].sort((a, b) => a - b), - sql`, `, - )}) ORDER BY id FOR UPDATE`, + const offers = rowsFrom( + await tx.execute( + sql`SELECT id, page_id AS pageId, item_ids AS itemIds FROM ${offerTable(kind)} WHERE id IN (${sql.join( + [...ids].sort((a, b) => a - b), + sql`, `, + )}) ORDER BY id FOR UPDATE`, + ), ); - const offers = rows as unknown as OfferRow[]; if ( offers.length !== ids.length || ids.some((id) => !offers.some((offer) => Number(offer.id) === id)) @@ -51,21 +58,23 @@ async function lockPage( kind: OfferKind = "normal", ) { offerTable(kind); - const [rows] = await tx.execute( - sql`SELECT id FROM ${sql.identifier(kind === "bc" ? "catalog_pages_bc" : "catalog_pages")} WHERE id=${id} FOR UPDATE`, - ); if ( - !(rows as unknown as { id: number }[]).some((row) => Number(row.id) === id) + !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"); } async function lockFurniture(tx: Transaction, ids: number[]) { const unique = [...new Set(ids)].sort((a, b) => a - b); - const [rows] = await tx.execute( - sql`SELECT id FROM items_base WHERE id IN (${sql.join(unique, sql`, `)}) ORDER BY id FOR UPDATE`, - ); const found = new Set( - (rows as unknown as { id: number }[]).map((row) => Number(row.id)), + 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))) throw Error("Furniture reference not found"); @@ -98,10 +107,13 @@ async function assertOrderUnique( orderNumber: number, excludeId: number, ) { - const [dups] = await tx.execute( - 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) + if ( + rowsFrom( + await tx.execute( + sql`SELECT id FROM catalog_items WHERE page_id = ${String(pageId)} AND order_number = ${orderNumber} AND id != ${excludeId} LIMIT 1`, + ), + ).length > 0 + ) throw new CatalogInputError( `Order number ${orderNumber} is already used by another offer on this page`, ); @@ -156,11 +168,10 @@ export async function updateOfferCommand( fields.limitedStack !== undefined || fields.limitedSells !== undefined ) { - const [current] = await tx.execute( - 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 }[] + 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}`, + ), )[0]; if (row) { const stack = fields.limitedStack ?? Number(row.limitedStack); @@ -267,7 +278,7 @@ export async function createBcOfferCommand( const [result] = await tx.execute( sql`INSERT INTO ${CatalogItemsBc} SET ${sql.join(assignments(CatalogItemsBc, fields), sql`, `)}`, ); - const id = Number((result as unknown as { insertId: number }).insertId); + const id = Number(result.insertId); offerIdSchema.parse(id); return id; }); diff --git a/src/features/catalog/server/package-snapshot.ts b/src/features/catalog/server/package-snapshot.ts index be590042..397a0635 100644 --- a/src/features/catalog/server/package-snapshot.ts +++ b/src/features/catalog/server/package-snapshot.ts @@ -2,6 +2,7 @@ import "server-only"; import { createHash } from "node:crypto"; import { sql } from "drizzle-orm"; import type { db } from "@/lib/db"; +import { rowsFrom } from "@/lib/db"; import { assertParent, CatalogInputError, @@ -84,10 +85,11 @@ export async function readPackagePages( tx: PackageTx, lock: boolean, ): Promise { - const [rows] = await tx.execute( - sql`SELECT * FROM catalog_pages ORDER BY id LIMIT 20001${lock ? sql` FOR UPDATE` : sql``}`, + const pages = rowsFrom( + await tx.execute( + 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) throw new CatalogInputError( "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 ids = selectedPages.map((p) => String(p.id)); - const [rows] = 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``}`, + const offers = rowsFrom( + 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``}`, + ), ); - const offers = rows as unknown as SourceRow[]; if (offers.length > 500) throw new CatalogInputError("A package supports at most 500 offers"); const furnitureIds = [ @@ -142,11 +145,12 @@ export async function readPackageSource( "Package furniture references exceed safety limit", ); 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( - (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))) throw new CatalogNotFound("Package furniture reference no longer exists"); diff --git a/src/features/catalog/server/packages.test.ts b/src/features/catalog/server/packages.test.ts index 57c40087..a07c1793 100644 --- a/src/features/catalog/server/packages.test.ts +++ b/src/features/catalog/server/packages.test.ts @@ -18,7 +18,8 @@ vi.mock("@/lib/services/furni-import", () => ({ allocateCatalogItemId: async (fn: (id: number) => unknown) => fn(state.nextOffer++), })); -vi.mock("@/lib/db", () => ({ +vi.mock("@/lib/db", async () => ({ + rowsFrom: (await import("@/test/db-helpers")).rowsFrom, db: { transaction: async (fn: (tx: unknown) => Promise) => { const before = structuredClone({ diff --git a/src/features/catalog/server/packages.ts b/src/features/catalog/server/packages.ts index a9142a3f..340d5890 100644 --- a/src/features/catalog/server/packages.ts +++ b/src/features/catalog/server/packages.ts @@ -2,7 +2,7 @@ import "server-only"; import { randomUUID } from "node:crypto"; import { sql } from "drizzle-orm"; 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 { remapIncludes } from "../domain/duplicate"; import { @@ -55,10 +55,11 @@ async function read( id: string, lock = false, ): Promise { - const [rows] = await tx.execute( - 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]; + 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``}`, + ), + )[0]; if (!row) throw new CatalogNotFound("Catalog package not found"); 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 { - const [rows] = await db.execute( + const rows = await queryRows< + Omit & { 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`, ); - return ( - rows as unknown as Array< - Omit & { updatedAt: Date | string } - > - ).map((row) => ({ + return rows.map((row) => ({ ...row, version: Number(row.version), updatedAt: new Date(row.updatedAt).toISOString(), @@ -238,9 +237,7 @@ async function insertRow( sql`, `, )}`, ); - const id = Number( - row.id ?? (result as unknown as { insertId: number }).insertId, - ); + const id = Number(row.id ?? result.insertId); if (!Number.isSafeInteger(id) || id <= 0) throw Error("Could not allocate package row ID"); return id; diff --git a/src/features/catalog/server/page-commands.test.ts b/src/features/catalog/server/page-commands.test.ts index a179c7e1..6ccf5704 100644 --- a/src/features/catalog/server/page-commands.test.ts +++ b/src/features/catalog/server/page-commands.test.ts @@ -15,6 +15,7 @@ const state = vi.hoisted(() => ({ rollbacks: 0, })); vi.mock("@/lib/db", async () => ({ + rowsFrom: (await import("@/test/db-helpers")).rowsFrom, ...(await import("@/db/schema")), db: { transaction: async (fn: (tx: unknown) => Promise) => { diff --git a/src/features/catalog/server/page-commands.ts b/src/features/catalog/server/page-commands.ts index cc24f02f..1b757915 100644 --- a/src/features/catalog/server/page-commands.ts +++ b/src/features/catalog/server/page-commands.ts @@ -1,7 +1,7 @@ import "server-only"; import { getTableColumns, type SQL, sql } from "drizzle-orm"; import { historySnapshot, recordHistory } from "@/features/history/server"; -import { CatalogPages, CatalogPagesBc, db } from "@/lib/db"; +import { CatalogPages, CatalogPagesBc, db, rowsFrom } from "@/lib/db"; import { assertParent, CatalogConflict, @@ -29,10 +29,12 @@ async function lockedPages( ): Promise { const { pages } = tables(kind); // Structural commands serialize against a stable lock order, including parent validation. - const [rows] = await tx.execute( - sql`SELECT id, parent_id AS parentId, order_num AS orderNum FROM ${pages} ORDER BY id FOR UPDATE`, + const rows = rowsFrom( + await tx.execute( + sql`SELECT id, parent_id AS parentId, order_num AS orderNum FROM ${pages} ORDER BY id FOR UPDATE`, + ), ); - return (rows as unknown as HierarchyPage[]).map((row) => ({ + return rows.map((row) => ({ id: Number(row.id), parentId: Number(row.parentId), orderNum: Number(row.orderNum), @@ -64,10 +66,11 @@ export async function updatePageCommand( throw new CatalogNotFound("Catalog page not found"); if (expected) { const columns = getTableColumns(tables(kind).pages); - const [existingRows] = await tx.execute( - sql`SELECT * FROM ${tables(kind).pages} WHERE id=${id} FOR UPDATE`, - ); - const current = (existingRows as unknown as Record[])[0]; + const current = rowsFrom>( + await tx.execute( + sql`SELECT * FROM ${tables(kind).pages} WHERE id=${id} FOR UPDATE`, + ), + )[0]; if (!current) throw new CatalogNotFound("Catalog page not found"); for (const [key, value] of Object.entries(expected)) { if (!Object.hasOwn(data, key) || !Object.hasOwn(columns, key)) continue; @@ -107,7 +110,7 @@ export async function createPageCommand( const [result] = await tx.execute( sql`INSERT INTO ${tables(kind).pages} SET ${sql.join(assignments, sql`, `)}`, ); - return Number((result as unknown as { insertId: number }).insertId); + return Number(result.insertId); }); } export async function reorderPagesCommand( diff --git a/src/features/catalog/server/search.test.ts b/src/features/catalog/server/search.test.ts index c10438e6..f313b539 100644 --- a/src/features/catalog/server/search.test.ts +++ b/src/features/catalog/server/search.test.ts @@ -9,8 +9,8 @@ const state = vi.hoisted(() => ({ offers: [] as unknown[], parents: [] as unknown[], })); -vi.mock("@/lib/db", () => ({ - db: { +vi.mock("@/lib/db", async () => { + const db = { execute: vi.fn(async (query: SQL) => { const result = new MySqlDialect().sqlToQuery(query); state.queries.push(result); @@ -19,8 +19,12 @@ vi.mock("@/lib/db", () => ({ if (result.sql.includes("WHERE id IN")) return [state.parents]; return [state.categories]; }), - }, -})); + }; + return { + db, + ...(await import("@/test/db-helpers")).createDbHelpers(db.execute), + }; +}); import { searchCatalog } from "./search"; diff --git a/src/features/catalog/server/search.ts b/src/features/catalog/server/search.ts index f3febe11..397f394e 100644 --- a/src/features/catalog/server/search.ts +++ b/src/features/catalog/server/search.ts @@ -1,6 +1,6 @@ import "server-only"; import { sql } from "drizzle-orm"; -import { db } from "@/lib/db"; +import { queryRows } from "@/lib/db"; import { type CatalogSearchResult, parseCatalogSearch } from "../domain/search"; const LIMIT = 25; @@ -29,18 +29,15 @@ export async function searchCatalog( ); const number = input.numericId; const [categoryData, furnitureData] = await Promise.all([ - db.execute( + queryRows( 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( sql`SELECT id, item_name AS classname, sprite_id AS spriteId FROM items_base WHERE item_name LIKE ${input.pattern} ESCAPE '=' OR public_name LIKE ${input.pattern} ESCAPE '=' ${number === null ? sql`` : sql`OR id=${number} OR sprite_id=${number}`} ORDER BY ${number === null ? sql`id` : sql`CASE WHEN id=${number} THEN 0 ELSE 1 END, id`} LIMIT 25`, ), ]); - const categories = (categoryData[0] as unknown as PageRow[]).slice(0, 12); - const furniture = (furnitureData[0] as unknown as FurnitureRow[]).slice( - 0, - LIMIT, - ); + const categories = categoryData.slice(0, 12); + const furniture = furnitureData.slice(0, LIMIT); const predicates = [sql`i.catalog_name LIKE ${input.pattern} ESCAPE '='`]; if (number !== null) { predicates.push(sql`i.id=${number}`, sql`i.page_id=${String(number)}`); @@ -51,13 +48,10 @@ export async function searchCatalog( predicates.push( sql`FIND_IN_SET(${String(base.id)}, REPLACE(i.item_ids, ';', ',')) > 0`, ); - const [offerData] = await db.execute( + const offerData = await queryRows( sql`SELECT i.id, i.catalog_name AS caption, i.page_id AS pageId, i.item_ids AS itemIds, p.caption AS pageCaption, p.parent_id AS parentId FROM ${offers} i INNER JOIN ${pages} p ON p.id=i.page_id WHERE ${sql.join(predicates, sql` OR `)} ORDER BY ${number === null ? sql`i.id` : sql`CASE WHEN i.id=${number} THEN 0 ELSE 1 END, i.id`} LIMIT ${LIMIT - categories.length}`, ); - const matches = (offerData as unknown as OfferRow[]).slice( - 0, - LIMIT - categories.length, - ); + const matches = offerData.slice(0, LIMIT - categories.length); const pageMap = new Map(); for (const page of categories) pageMap.set(Number(page.id), { @@ -79,11 +73,11 @@ export async function searchCatalog( .slice(0, LIMIT); if (!ids.length) break; for (const id of ids) attempted.add(id); - const [parents] = await db.execute( + const parents = await queryRows( sql`SELECT id, caption, parent_id AS parentId FROM ${pages} WHERE id IN (${sql.join(ids, sql`, `)}) LIMIT 25`, ); frontier = []; - for (const raw of parents as unknown as PageRow[]) { + for (const raw of parents) { const page = { ...raw, id: Number(raw.id), diff --git a/src/features/history/catalog-offer.test.ts b/src/features/history/catalog-offer.test.ts index 2bd93426..889c1cca 100644 --- a/src/features/history/catalog-offer.test.ts +++ b/src/features/history/catalog-offer.test.ts @@ -2,7 +2,10 @@ import type { SQL } from "drizzle-orm"; import { MySqlDialect } from "drizzle-orm/mysql-core"; 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"; diff --git a/src/features/history/history.test.ts b/src/features/history/history.test.ts index f935d62c..2f44a8cb 100644 --- a/src/features/history/history.test.ts +++ b/src/features/history/history.test.ts @@ -2,7 +2,11 @@ import type { SQL } from "drizzle-orm"; import { MySqlDialect } from "drizzle-orm/mysql-core"; 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 { diff --git a/src/features/history/server.ts b/src/features/history/server.ts index ad8f4aee..57b3cc4e 100644 --- a/src/features/history/server.ts +++ b/src/features/history/server.ts @@ -7,7 +7,7 @@ import { CatalogPagesBc, WebsiteArticles, } from "@/db/schema"; -import type { db } from "@/lib/db"; +import { type db, rowsFrom } from "@/lib/db"; import { getOperationContext } from "@/lib/foundation/request-context"; import { type HistorySnapshot, sameSnapshot } from "./model"; export type HistoryKind = @@ -84,10 +84,11 @@ export async function historySnapshot( (key) => sql`${sql.identifier(columns[key as keyof typeof columns].name)} AS ${sql.identifier(key)}`, ); - const [rows] = await tx.execute( - sql`SELECT ${sql.join(selected, sql`, `)} FROM ${table} WHERE id=${id} FOR UPDATE`, - ); - const row = (rows as unknown as Record[])[0]; + const row = rowsFrom>( + await tx.execute( + sql`SELECT ${sql.join(selected, sql`, `)} FROM ${table} WHERE id=${id} FOR UPDATE`, + ), + )[0]; if (!row) throw Error("unavailable"); return Object.fromEntries( fields.map((key) => { @@ -223,11 +224,12 @@ export async function lockOfferHistoryPages( ].sort((a, b) => a - b); if (ids.some((id) => !Number.isSafeInteger(id) || id < 1)) 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( - (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"); } diff --git a/src/features/operations/delivery-errors.test.ts b/src/features/operations/delivery-errors.test.ts index ed77225c..f47c0cda 100644 --- a/src/features/operations/delivery-errors.test.ts +++ b/src/features/operations/delivery-errors.test.ts @@ -2,7 +2,10 @@ import { MySqlDialect } from "drizzle-orm/mysql-core"; import { beforeEach, expect, it, vi } from "vitest"; 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 } })); import { effectRepository, listEffects } from "./server"; diff --git a/src/features/operations/server.ts b/src/features/operations/server.ts index 5d2ae0fb..510ab81d 100644 --- a/src/features/operations/server.ts +++ b/src/features/operations/server.ts @@ -1,7 +1,7 @@ import "server-only"; import { randomUUID } from "node:crypto"; import { sql } from "drizzle-orm"; -import { db } from "@/lib/db"; +import { db, queryRows, rowsFrom } from "@/lib/db"; import { logger } from "@/lib/logger"; import { isDeliveryReference } from "./delivery-diagnostics"; import { @@ -26,15 +26,14 @@ export async function runOperation( 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`, ); - const [rows] = 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`, - ); - const row = ( - rows as unknown as Array<{ - id: string; - requestHash: string; - resultJson: string | null; - }> + const row = rowsFrom<{ + id: string; + requestHash: string; + 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]; if (!row) throw new Error("Operation unavailable"); if (row.requestHash !== hash) throw new OperationConflict(); @@ -61,16 +60,15 @@ export async function enqueueEffect( export const effectRepository = { async claim(): Promise { return db.transaction(async (tx) => { - const [rows] = 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 + const row = rowsFrom<{ + id: string; + operationId: string; + topic: EffectTopic; + 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; + }>( + 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) { if ( @@ -130,6 +127,6 @@ export async function retryEffect(id: string) { 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'`, ); - if ((result as unknown as { affectedRows: number }).affectedRows !== 1) + if (result.affectedRows !== 1) throw new Error("Delivery is no longer available for retry"); } diff --git a/src/lib/admin-search-permissions.test.ts b/src/lib/admin-search-permissions.test.ts index 48b95f01..e614af6a 100644 --- a/src/lib/admin-search-permissions.test.ts +++ b/src/lib/admin-search-permissions.test.ts @@ -18,7 +18,7 @@ vi.mock("@/lib/permissions", async () => ({ ...(await import("@/lib/permission-slugs")), canAccess: (_: unknown, slug: string) => state.allowed.has(slug), })); -vi.mock("@/lib/db", () => { +vi.mock("@/lib/db", async () => { const tables = Object.fromEntries( [ "User", diff --git a/src/lib/admin/installation.ts b/src/lib/admin/installation.ts index a4a0611d..2b362b08 100644 --- a/src/lib/admin/installation.ts +++ b/src/lib/admin/installation.ts @@ -9,7 +9,7 @@ import { workerState, } from "@/lib/admin/installation-state"; import { fetchOpsHealth } from "@/lib/admin/ops-health"; -import { db } from "@/lib/db"; +import { queryRows } from "@/lib/db"; import { redis } from "@/lib/redis"; import { siteSettings } from "@/lib/services/site-settings"; @@ -36,13 +36,10 @@ export async function inspectInstallation() { .map((name) => name.slice(0, -4)), ) .catch(() => []), - db - .execute(sql`SELECT migration FROM cms_migrations`) - .then(([rows]) => - (rows as unknown as { migration: string }[]).map( - (row) => row.migration, - ), - ) + queryRows<{ migration: string }>( + sql`SELECT migration FROM cms_migrations`, + ) + .then((rows) => rows.map((row) => row.migration)) .catch(() => null), redis ? redis.get("cms:jobs-worker:heartbeat").catch(() => null) diff --git a/src/lib/auth/login-core.ts b/src/lib/auth/login-core.ts index 760d54e3..d3d4e0d2 100644 --- a/src/lib/auth/login-core.ts +++ b/src/lib/auth/login-core.ts @@ -1,7 +1,7 @@ import { sql } from "drizzle-orm"; import { checkLogin } from "@/lib/auth/password"; import { cachedQuery, invalidateKey } from "@/lib/cached-db"; -import { db } from "@/lib/db"; +import { queryRows } from "@/lib/db"; import { siteSettings } from "@/lib/services/site-settings"; export interface LoginUser { @@ -45,7 +45,7 @@ export async function getLoginUser( return cachedQuery( `login:user:${username}`, async () => { - const [result] = await db.execute<{ + const rows = await queryRows<{ id: number; username: string; password: string | null; @@ -63,16 +63,6 @@ export async function getLoginUser( WHERE username = ${username} 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 ? { id: rows[0].id, diff --git a/src/lib/bearer-route-abilities.test.ts b/src/lib/bearer-route-abilities.test.ts index f70d47ab..3ebd3645 100644 --- a/src/lib/bearer-route-abilities.test.ts +++ b/src/lib/bearer-route-abilities.test.ts @@ -5,6 +5,7 @@ const state = vi.hoisted(() => ({ queries: [] as string[], })); vi.mock("@/lib/db", async () => { + const { createDbHelpers } = await import("@/test/db-helpers"); const schema = await import("@/db/schema"); const { drizzle } = await import("drizzle-orm/mysql-proxy"); const db = drizzle(async (sql) => { @@ -27,9 +28,13 @@ vi.mock("@/lib/db", async () => { } return { rows: [] }; }); + const mockDb = Object.assign(db, { + execute: async () => [[{ cnt: 0n }], []], + }); return { ...schema, - db: Object.assign(db, { execute: async () => [[{ cnt: 0n }], []] }), + ...createDbHelpers(mockDb.execute), + db: mockDb, }; }); vi.mock("@/lib/auth", () => ({ auth: async () => ({ user: { id: "77" } }) })); diff --git a/src/lib/db.ts b/src/lib/db.ts index 98e6077e..9712f109 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -1,4 +1,6 @@ +import type { SQL } from "drizzle-orm"; import { drizzle } from "drizzle-orm/mysql2"; +import type { ResultSetHeader } from "mysql2"; import mysql from "mysql2/promise"; import * as relations from "@/db/relations"; import * as schema from "@/db/schema"; @@ -62,6 +64,26 @@ export const db = globalForDb.db ?? createDb(); if (env.NODE_ENV !== "production") globalForDb.db = db; +export function rowsFrom(result: unknown): T[] { + const [first] = result as [unknown, unknown]; + return (Array.isArray(first) ? first : []) as T[]; +} + +export async function queryRows(query: SQL | string): Promise { + return rowsFrom(await db.execute(query)); +} + +export async function execResult( + query: SQL | string, +): Promise { + const [first] = (await db.execute(query)) as unknown as [unknown, unknown]; + return first as ResultSetHeader; +} + +export async function affectedRows(query: SQL | string): Promise { + return (await execResult(query)).affectedRows; +} + export type Db = ReturnType; export * from "@/db/schema"; diff --git a/src/lib/permissions.ts b/src/lib/permissions.ts index de985740..ca496671 100644 --- a/src/lib/permissions.ts +++ b/src/lib/permissions.ts @@ -6,7 +6,7 @@ import { isDynamicSuperAdmin } from "./admin/authorization-policy"; import { resolveAuthorizationState } from "./admin/rank-authority"; import { auth } from "./auth"; import { sessionUserId } from "./auth/session-user"; -import { db, User } from "./db"; +import { db, queryRows, User } from "./db"; import { redirectSafe } from "./foundation/security"; // Re-export PERMS from the standalone file (safe for client components) @@ -33,7 +33,7 @@ function createEmptySet(): PermissionSet { */ const getCachedPermissionSlugs = unstable_cache( async (userId: number, rank: number): Promise => { - const [result] = await db.execute<{ slug: string }>(sql` + const rows = await queryRows<{ slug: string }>(sql` SELECT DISTINCT p.slug FROM acl_model_permissions mp JOIN acl_permissions p ON p.id = mp.permission_id @@ -48,7 +48,6 @@ const getCachedPermissionSlugs = unstable_cache( WHERE ar.slug = ${`rank_${rank}`} ) `); - const rows = result as unknown as { slug: string }[]; return rows.map((r) => r.slug); }, ["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 // permission_ranks (common on Habbo DBs) would otherwise lock the real // owner out of super-admin / permissions management. - const [result] = await db.execute<{ + const rows = await queryRows<{ highest_rank: number | bigint | null; }>(sql` SELECT COALESCE( @@ -132,9 +131,6 @@ const getCurrentAuthorizationState = cache(async (userId: number) => (SELECT MAX(id) FROM permission_ranks) ) AS highest_rank `); - const rows = result as unknown as { - highest_rank: number | bigint | null; - }[]; return rows[0]?.highest_rank == null ? null : Number(rows[0].highest_rank); diff --git a/src/lib/services/catalog-audit.ts b/src/lib/services/catalog-audit.ts index e693aeb9..7da3e8cf 100644 --- a/src/lib/services/catalog-audit.ts +++ b/src/lib/services/catalog-audit.ts @@ -1,6 +1,6 @@ import { readdir } from "node:fs/promises"; import { sql } from "drizzle-orm"; -import { db } from "@/lib/db"; +import { queryRows } from "@/lib/db"; import { fetchSourceFurnidata } from "@/lib/services/clone-import"; import { listSources } from "@/lib/services/clone-sources"; import { @@ -215,20 +215,10 @@ export async function runCatalogAudit( interaction_type: string; }>; try { - const [rows] = (await db.execute(sql` + items = await queryRows<(typeof items)[number]>(sql` SELECT id, item_name, public_name, type, interaction_type 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) { onEvent?.({ type: "error", @@ -262,13 +252,9 @@ export async function runCatalogAudit( catalog_name: string; }>; 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 - `)) as unknown as [ - Array<{ id: number; item_ids: string; catalog_name: string }>, - unknown, - ]; - catalogItems = rows; + `); } catch (err) { onEvent?.({ type: "error", @@ -625,12 +611,13 @@ export async function runCatalogAudit( orphanedCleaned = orphanResult.cleaned; // 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 - `)) as unknown as [ - Array<{ id: number; item_ids: string; catalog_name: string }>, - unknown, - ]; + `); const orphanedCatalogIds = new Set(); for (const ci of afterCatalog) { const ids = ci.item_ids.split(";").map(Number).filter(Boolean); @@ -650,9 +637,9 @@ export async function runCatalogAudit( remappedReferences = duplicateResult.remapped; // 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 - `)) as unknown as [Array<{ item_name: string }>, unknown]; + `); const afterCount = new Map(); for (const row of afterItems) { 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 // catalog_items rows (item_ids pointing at deleted sprite ids). 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 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; - 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 - `)) as unknown as [ - Array<{ id: number; item_ids: string; catalog_name: string }>, - unknown, - ]; + `); const postCatalogIds = new Set(); for (const ci of postCatalog) { 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", ); const [pageResult, itemResult, offerResult] = await Promise.all([ - db.execute( + queryRows( sql`SELECT id, parent_id AS parentId, caption FROM ${pagesTable} ORDER BY id`, ), - db.execute( + queryRows( sql`SELECT id, item_name, public_name, type, interaction_type FROM items_base ORDER BY id`, ), - db.execute( + queryRows( 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({ catalog, - pages: - pageResult[0] as unknown as import("./catalog-integrity").IntegrityPage[], - items: - itemResult[0] as unknown as import("./catalog-integrity").IntegrityItem[], - offers: ( - offerResult[0] as unknown as import("./catalog-integrity").IntegrityOffer[] - ).map((row) => ({ + pages: pageResult, + items: itemResult, + offers: offerResult.map((row) => ({ ...row, pageId: Number(row.pageId), offerId: row.offerId === null ? null : Number(row.offerId), diff --git a/src/lib/services/catalog-integrity-audit.test.ts b/src/lib/services/catalog-integrity-audit.test.ts index 4dced525..878b9681 100644 --- a/src/lib/services/catalog-integrity-audit.test.ts +++ b/src/lib/services/catalog-integrity-audit.test.ts @@ -8,7 +8,10 @@ const mocks = vi.hoisted(() => ({ repair: vi.fn(), })); 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-sources", () => ({})); vi.mock("@/lib/services/furni-asset-dirs", () => ({ diff --git a/src/lib/services/catalog-items-loader.ts b/src/lib/services/catalog-items-loader.ts index 065e7ceb..364a5763 100644 --- a/src/lib/services/catalog-items-loader.ts +++ b/src/lib/services/catalog-items-loader.ts @@ -1,7 +1,7 @@ import { promises as fs } from "node:fs"; import { asc, sql } from "drizzle-orm"; 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 { 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 const pageIdStr = String(pageId); // CAST: live Habbo DBs often store page_id as VARCHAR while schema maps Int. - const [rawItems] = (await db.execute(sql` + const rawItems = await queryRows>(sql` SELECT * FROM catalog_items WHERE CAST(page_id AS CHAR) = ${pageIdStr} ORDER BY order_number ASC, id ASC - `)) as unknown as [Record[], unknown[]]; - const items: RawItem[] = rawItems.map((r: Record) => ({ + `); + const items: RawItem[] = rawItems.map((r) => ({ id: Number(r.id), itemIds: String(r.item_ids ?? ""), pageId: numericValue(r.page_id, 0), @@ -169,14 +169,9 @@ export async function loadCatalogItemsData( .select({ id: CatalogPages.id, caption: CatalogPages.caption }) .from(CatalogPages) .orderBy(asc(CatalogPages.caption)), - db - .execute(sql` + queryRows<{ interaction_type: string }>(sql` SELECT DISTINCT interaction_type FROM items_base ORDER BY interaction_type ASC - `) - .then( - (result) => - (result as unknown as [{ interaction_type: string }[], unknown[]])[0], - ), + `), getHabboGamedataHotel(), ]); @@ -199,9 +194,9 @@ export async function loadCatalogItemsData( // 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. - const [baseItemRows] = + const baseItemRows = baseItemIds.length > 0 - ? ((await db.execute(sql` + ? await queryRows>(sql` SELECT id, sprite_id, public_name, item_name, type, width, length, stack_height, allow_stack, allow_sit, allow_lay, allow_walk, 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, clothing_on_walk FROM items_base WHERE id IN (${sql.join(baseItemIds, sql`, `)}) - `)) as unknown as [Record[], unknown[]]) - : [[], []]; + `) + : []; const baseItems: BaseItem[] = baseItemRows.map((r) => ({ id: Number(r.id), diff --git a/src/lib/services/catalog-parent-repair.test.ts b/src/lib/services/catalog-parent-repair.test.ts index d37d202e..650dd05c 100644 --- a/src/lib/services/catalog-parent-repair.test.ts +++ b/src/lib/services/catalog-parent-repair.test.ts @@ -7,7 +7,8 @@ const mocks = vi.hoisted(() => ({ insert: vi.fn(), values: vi.fn(), })); -vi.mock("@/lib/db", () => ({ +vi.mock("@/lib/db", async () => ({ + ...(await import("@/test/db-helpers")).createDbHelpers(mocks.execute), AdminAuditLog: "audit", db: { execute: mocks.execute, transaction: mocks.transaction }, })); diff --git a/src/lib/services/catalog-repair.ts b/src/lib/services/catalog-repair.ts index f041c38a..14d4a0eb 100644 --- a/src/lib/services/catalog-repair.ts +++ b/src/lib/services/catalog-repair.ts @@ -1,6 +1,6 @@ import { createHash } from "node:crypto"; import { sql } from "drizzle-orm"; -import { AdminAuditLog, db } from "@/lib/db"; +import { AdminAuditLog, db, execResult, queryRows, rowsFrom } from "@/lib/db"; import { readFurniData, withFurniDataLock, @@ -329,26 +329,28 @@ export async function generateCatalogSql( let nextPageId = 0; if (organizeSql) { 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 - `)) as unknown as [Array<{ maxId: number | bigint | null }>, unknown]; + `); nextPageId = Number(maxPageResult[0]?.maxId ?? 0) + 1; } // The generation pass does not INSERT, so MAX(id) would never advance and // per-entry allocateCatalogItemId would hand out the same id every time. // 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 - `)) as unknown as [Array<{ maxId: number | bigint | null }>, unknown]; + `); let nextId = Number(maxIdResult[0]?.maxId ?? 0) + 1; // 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 // 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 - `)) as unknown as [Array<{ id: number }>, unknown]; + `); const validItemIds = new Set(validIds.map((r) => Number(r.id))); let skipped = 0; @@ -365,9 +367,9 @@ export async function generateCatalogSql( // 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 // 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 - `)) as unknown as [Array<{ id: number }>, unknown]; + `); if (existing.length > 0) { skipped++; continue; @@ -383,9 +385,9 @@ export async function generateCatalogSql( ); let existing = pageMap.get(captionSave); 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} - `)) as unknown as [Array<{ id: number }>, unknown]; + `); if (found?.[0]?.id) { pageMap.set(captionSave, found[0].id); } else { @@ -466,15 +468,15 @@ export interface DuplicateClassnameResult { * new orphans are left behind. */ export async function repairOrphanedCatalog(): Promise { - const [itemRows] = (await db.execute(sql` + const itemRows = await queryRows<{ id: number }>(sql` SELECT id FROM items_base - `)) as unknown as [Array<{ id: number }>, unknown]; + `); const valid = new Set(); 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 - `)) as unknown as [Array<{ id: number; item_ids: string }>, unknown]; + `); let removed = 0; let cleaned = 0; @@ -513,9 +515,9 @@ export async function repairOrphanedCatalog(): Promise { * catalog_items when present, otherwise the lowest id. */ export async function repairDuplicateClassnames(): Promise { - const [itemRows] = (await db.execute(sql` + const itemRows = await queryRows<{ id: number; item_name: string }>(sql` SELECT id, item_name FROM items_base - `)) as unknown as [Array<{ id: number; item_name: string }>, unknown]; + `); const byName = new Map(); for (const row of itemRows) { @@ -524,9 +526,9 @@ export async function repairDuplicateClassnames(): Promise(sql` SELECT item_ids FROM catalog_items - `)) as unknown as [Array<{ item_ids: string }>, unknown]; + `); const referenced = new Set(); for (const row of catalogRows) { for (const part of (row.item_ids ?? "").split(";")) { @@ -558,9 +560,9 @@ export async function repairDuplicateClassnames(): Promise( 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) { if (!row.v) continue; const parts = row.v @@ -625,11 +627,11 @@ export async function repairDuplicateClassnames(): Promise dup); try { - const [result] = (await db.execute(sql` + const result = await execResult(sql` UPDATE ${sql.raw(quoteIdentifier(table))} SET item_id = CASE item_id ${sql.join(cases, sql` `)} ELSE item_id END WHERE item_id IN (${sql.join(dupList, sql`, `)}) - `)) as unknown as [Record, unknown]; + `); remapped += Number(result.affectedRows ?? 0); } catch { // Table may not exist on some hotel schemas — skip it. @@ -811,15 +813,13 @@ export async function previewCatalogParentRepair( const table = sql.raw( catalog === "bc" ? "catalog_pages_bc" : "catalog_pages", ); - const [rows] = await db.execute( + const rows = await queryRows( sql`SELECT id, parent_id AS parentId, caption FROM ${table} ORDER BY id`, ); - const pages = - rows as unknown as import("./catalog-integrity").IntegrityPage[]; return { catalog, - fingerprint: parentStateFingerprint(catalog, pages), - changes: planParentRepairs(pages), + fingerprint: parentStateFingerprint(catalog, rows), + changes: planParentRepairs(rows), }; } @@ -838,11 +838,11 @@ export async function applyCatalogParentRepair( const tableName = catalog === "bc" ? "catalog_pages_bc" : "catalog_pages"; const table = sql.raw(tableName); return db.transaction(async (tx) => { - const [rows] = await tx.execute( - sql`SELECT id, parent_id AS parentId, caption FROM ${table} ORDER BY id FOR UPDATE`, + const pages = rowsFrom( + await tx.execute( + 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) { const error = new Error("Catalog changed since preview"); error.name = "CatalogPreviewConflict"; diff --git a/src/lib/services/catalog-tree.ts b/src/lib/services/catalog-tree.ts index d5b8f7ab..1e136eda 100644 --- a/src/lib/services/catalog-tree.ts +++ b/src/lib/services/catalog-tree.ts @@ -3,7 +3,7 @@ import { deletePageCommand, updatePageCommand, } 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"; @@ -29,27 +29,27 @@ export async function getCatalogItemCounts( if (pageIds) { 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 FROM catalog_items WHERE page_id IN (${sql.join(idStrs, sql`, `)}) GROUP BY page_id - `)) as unknown as [ - { page_id: string | number; cnt: number | bigint }[], - unknown[], - ]; + `); for (const r of rows) { map.set(toInt(r.page_id), toInt(r.cnt)); } return map; } - const [rows] = (await db.execute(sql` + 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 - `)) as unknown as [ - { page_id: string | number; cnt: number | bigint }[], - unknown[], - ]; + `); for (const r of rows) { map.set(toInt(r.page_id), toInt(r.cnt)); } diff --git a/src/lib/services/clone-import.test.ts b/src/lib/services/clone-import.test.ts index edf1c46e..e05ccf5e 100644 --- a/src/lib/services/clone-import.test.ts +++ b/src/lib/services/clone-import.test.ts @@ -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: { execute: executeFn, select: vi.fn(() => ({ diff --git a/src/lib/services/clone-import.ts b/src/lib/services/clone-import.ts index e163b533..b064d7e9 100644 --- a/src/lib/services/clone-import.ts +++ b/src/lib/services/clone-import.ts @@ -1,6 +1,6 @@ import { promises as fs } from "node:fs"; 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 { normalizeClassname } from "@/lib/furni/classname"; import { HABBO_GAMEDATA_HOTEL_SETTING_KEY } from "@/lib/habbo-gamedata-hotel"; @@ -222,9 +222,9 @@ async function allocateItemsBaseId( itemsBaseNextId === null || 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 - `)) as unknown as [Array<{ next: number }>, unknown]; + `); itemsBaseNextId = Number(idRows[0]?.next ?? 1); } } finally { @@ -450,9 +450,9 @@ export async function cloneSingleFurni(params: { // Skip when the item already has a catalog row — re-imports must never // create a second row for the same item_ids. 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 - `)) as unknown as [Array<{ id: number }>, unknown]; + `); if (existingCatalog.length > 0) { warnings.push( `catalog entry already exists (#${existingCatalog[0].id}) — skipped duplicate`, diff --git a/src/lib/services/furni-data.ts b/src/lib/services/furni-data.ts index 6f5499f8..ae9b8ef5 100644 --- a/src/lib/services/furni-data.ts +++ b/src/lib/services/furni-data.ts @@ -1,7 +1,7 @@ import { existsSync, promises as fs } from "node:fs"; import path from "node:path"; import { eq, sql } from "drizzle-orm"; -import { db, ItemsBase } from "@/lib/db"; +import { db, execResult, ItemsBase, queryRows } from "@/lib/db"; import { getGamedataRoot, getNitroFilesRoot, @@ -698,14 +698,14 @@ export interface CatalogOfferRebuildResult { */ export async function rebuildCatalogOfferIds(): Promise { return withFurniDataLock(async () => { - const [countRows] = (await db.execute( + const countRows = await queryRows<{ total: number }>( sql`SELECT COUNT(*) AS total FROM catalog_items`, - )) as unknown as [Array<{ total: number }>, unknown]; + ); 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 - `)) as unknown as [Record, unknown]; + `); return { checked, fixed: Number(result.affectedRows ?? 0) }; }); diff --git a/src/lib/services/furni-import-cache.ts b/src/lib/services/furni-import-cache.ts index b09b8071..1ce59549 100644 --- a/src/lib/services/furni-import-cache.ts +++ b/src/lib/services/furni-import-cache.ts @@ -15,7 +15,7 @@ import { existsSync } from "node:fs"; import path from "node:path"; 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 { indexLocalFurniture } from "@/lib/furni/local-presence"; import { readFurniData } from "./furni-data"; @@ -89,10 +89,10 @@ export async function getCatalogIdSet(): Promise> { } const set = new Set(); try { - const rows = await db.execute(sql`SELECT item_ids FROM catalog_items`); - for (const row of ( - rows as unknown as [Array<{ item_ids: string | null }>, unknown] - )[0]) { + const rows = await queryRows<{ item_ids: string | null }>( + sql`SELECT item_ids FROM catalog_items`, + ); + for (const row of rows) { for (const part of String(row.item_ids ?? "").split(/[;,]/)) { const n = Number(part.trim()); if (Number.isFinite(n) && n > 0) set.add(n); @@ -138,48 +138,39 @@ export async function getFurniImportStats( return statsSnapshot.stats; } try { - const [totalResult, catalogResult, allIdsResult, referencedResult] = + const [totalInDbRaw, inCatalogRaw, allIds, referencedList] = await Promise.all([ - db.execute(sql`SELECT COUNT(*) as cnt FROM items_base`), - db.execute( + queryRows<{ cnt: bigint }>(sql`SELECT COUNT(*) as cnt FROM items_base`), + queryRows<{ cnt: bigint }>( sql`SELECT COUNT(DISTINCT item_ids) as cnt FROM catalog_items`, ), - db.execute(sql`SELECT id FROM items_base`), - db.execute(sql`SELECT item_ids FROM catalog_items`), + queryRows<{ id: number }>(sql`SELECT id FROM items_base`), + queryRows<{ item_ids: string | null }>( + sql`SELECT item_ids FROM catalog_items`, + ), ]); - const totalInDb = Number( - (totalResult as unknown as [[{ cnt: bigint }], unknown])[0][0].cnt, - ); - const inCatalog = Number( - (catalogResult as unknown as [[{ cnt: bigint }], unknown])[0][0].cnt, - ); + const totalInDb = Number(totalInDbRaw[0]?.cnt ?? 0); + const inCatalog = Number(inCatalogRaw[0]?.cnt ?? 0); - const allIds = ( - allIdsResult as unknown as [Array<{ id: number }>, unknown] - )[0].map((r) => Number(r.id)); + const allIdsNum = allIds.map((r) => Number(r.id)); const referenced = new Set(); - for (const row of ( - referencedResult as unknown as [ - Array<{ item_ids: string | null }>, - unknown, - ] - )[0]) { + for (const row of referencedList) { for (const part of String(row.item_ids ?? "").split(/[;,]/)) { const n = Number(part.trim()); 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 WHERE EXISTS ( SELECT 1 FROM catalog_items ci WHERE FIND_IN_SET(ib.id, REPLACE(ci.item_ids, ';', ',')) > 0 ) LIMIT 10000 - `)) as unknown as [Array<{ item_name: string }>, unknown]; + `); let missingNitro = 0; for (const item of importedItems) { diff --git a/src/lib/services/furni-import-repair.test.ts b/src/lib/services/furni-import-repair.test.ts index e11112c9..cbe1ca1f 100644 --- a/src/lib/services/furni-import-repair.test.ts +++ b/src/lib/services/furni-import-repair.test.ts @@ -15,7 +15,8 @@ const mocks = vi.hoisted(() => ({ build: 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 }, ItemsBase: { id: "id", diff --git a/src/lib/services/furni-import.ts b/src/lib/services/furni-import.ts index 247afeb8..7c0c7a8c 100644 --- a/src/lib/services/furni-import.ts +++ b/src/lib/services/furni-import.ts @@ -3,7 +3,7 @@ import { existsSync, promises as fs } from "node:fs"; import path from "node:path"; import { promisify } from "node:util"; 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 { logger } from "@/lib/logger"; import { logServerError } from "@/lib/server-log"; @@ -146,10 +146,10 @@ async function dedupeCatalogPage( parentId: number, justCreatedId: number, ): Promise { - const [rows] = (await db.execute(sql` + const rows = await queryRows<{ id: number }>(sql` SELECT MIN(id) AS id FROM catalog_pages WHERE caption_save = ${captionSave} AND parent_id = ${parentId} - `)) as unknown as [Array<{ id: number }>, unknown]; + `); const winner = rows[0]?.id ?? justCreatedId; if (winner !== justCreatedId) { // Never delete a page that already received catalog items. @@ -304,13 +304,13 @@ export async function reconcileImportedOfferIds(): Promise<{ fixed: number; }> { const parentId = await getOrCreateImportedParentPage(); - const [result] = (await db.execute(sql` + const result = await execResult(sql` UPDATE catalog_items ci JOIN catalog_pages cp ON cp.id = ci.page_id SET ci.offer_id = ci.id WHERE cp.parent_id = ${parentId} AND ci.offer_id <> ci.id - `)) as unknown as [Record, unknown]; + `); return { fixed: Number(result.affectedRows ?? 0) }; } @@ -486,9 +486,11 @@ export async function allocateCatalogItemId( catalogNextId === null || 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 - `)) as unknown as [Array<{ maxId: number | bigint | null }>, unknown]; + `); catalogNextId = Number(maxIdResult[0]?.maxId ?? 0) + 1; } } finally { @@ -1067,9 +1069,12 @@ export async function importSingleFurni(params: { const itemIdStr = String(itemId); // 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 - `)) as unknown as [Array<{ id: number; page_id: string }>, unknown]; + `); if (existingCatalog.length > 0) { catalogItemId = existingCatalog[0].id; @@ -1177,7 +1182,7 @@ export async function fixDatabaseConsistencyAfterImport(): Promise<{ costCreditsFixed: number; }> { // 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 JOIN items_base i ON c.item_ids = i.id SET c.catalog_name = CASE @@ -1185,23 +1190,23 @@ export async function fixDatabaseConsistencyAfterImport(): Promise<{ ELSE i.public_name END WHERE c.catalog_name IS NULL OR c.catalog_name = '' - `)) as unknown as [Record, unknown]; + `); const catalogNameFixed = Number(nameResult.affectedRows ?? 0); // 2. Set have_offer = '1' where it was '0' - const [offerResult] = (await db.execute(sql` + const offerResult = await execResult(sql` UPDATE catalog_items SET have_offer = '1' WHERE have_offer = '0' - `)) as unknown as [Record, unknown]; + `); const haveOfferFixed = Number(offerResult.affectedRows ?? 0); // 3. Set cost_credits = 3 where it was <= 0 - const [creditResult] = (await db.execute(sql` + const creditResult = await execResult(sql` UPDATE catalog_items SET cost_credits = 3 WHERE cost_credits <= 0 - `)) as unknown as [Record, unknown]; + `); const costCreditsFixed = Number(creditResult.affectedRows ?? 0); return { catalogNameFixed, haveOfferFixed, costCreditsFixed }; diff --git a/src/lib/services/furni-maintenance.test.ts b/src/lib/services/furni-maintenance.test.ts index 43619588..5f9b9fe1 100644 --- a/src/lib/services/furni-maintenance.test.ts +++ b/src/lib/services/furni-maintenance.test.ts @@ -12,8 +12,8 @@ const state = vi.hoisted(() => ({ queries: [] as { sql: string; params: unknown[] }[], writes: 0, })); -vi.mock("@/lib/db", () => ({ - db: { +vi.mock("@/lib/db", async () => { + const db = { execute: async (query: SQL) => { const compiled = new MySqlDialect().sqlToQuery(query); if (compiled.sql.includes("FROM catalog_items ci")) @@ -50,8 +50,12 @@ vi.mock("@/lib/db", () => ({ }, }); }, - }, -})); + }; + return { + db, + ...(await import("@/test/db-helpers")).createDbHelpers(db.execute), + }; +}); import { fixEmulatorIssues, listEmulatorIssues } from "./furni-maintenance"; diff --git a/src/lib/services/furni-maintenance.ts b/src/lib/services/furni-maintenance.ts index 09135d3b..43dc2464 100644 --- a/src/lib/services/furni-maintenance.ts +++ b/src/lib/services/furni-maintenance.ts @@ -1,5 +1,5 @@ import { sql } from "drizzle-orm"; -import { db } from "@/lib/db"; +import { db, queryRows, rowsFrom } from "@/lib/db"; import { type CatalogOfferRebuildResult, type FurniDataReconcileResult, @@ -56,11 +56,8 @@ const STR_COLS: Array<[string, string]> = [ ["catalog_version_offers", "item_ids"], ]; -type Rows = [T[], unknown]; - async function query(q: unknown): Promise { - const [rows] = (await db.execute(q as never)) as unknown as Rows; - return rows ?? []; + return queryRows(q as never); } async function exists(table: string): Promise { @@ -228,37 +225,33 @@ export async function countEmulatorIssues(): Promise { } export async function listEmulatorIssues(): Promise { - const [items] = (await db.execute(sql` + const items = await queryRows<{ + id: number; + pageId: number | string; + itemIds: string; + costCredits: number; + costPoints: number; + pointsType: number; + amount: number; + limitedStack: number; + limitedSells: number; + orderNumber: number; + }>(sql` SELECT ci.id, ci.page_id AS pageId, ci.item_ids AS itemIds, ci.cost_credits AS costCredits, ci.cost_points AS costPoints, ci.points_type AS pointsType, ci.amount, ci.limited_stack AS limitedStack, ci.limited_sells AS limitedSells, ci.order_number AS orderNumber FROM catalog_items ci - `)) as unknown as [ - Array<{ - id: number; - pageId: number | string; - itemIds: string; - costCredits: number; - costPoints: number; - pointsType: number; - amount: number; - limitedStack: number; - limitedSells: number; - orderNumber: number; - }>, - unknown, - ]; - const [pages] = (await db.execute( + `); + const pages = await queryRows<{ + id: number; + parentId: number | string; + orderNum: number; + }>( sql`SELECT id, parent_id AS parentId, order_num AS orderNum FROM catalog_pages`, - )) as unknown as [ - Array<{ id: number; parentId: number | string; orderNum: number }>, - unknown, - ]; - const [bases] = (await db.execute( - sql`SELECT id FROM items_base`, - )) as unknown as [Array<{ id: number }>, unknown]; + ); + const bases = await queryRows<{ id: number }>(sql`SELECT id FROM items_base`); const pageIds = new Set(pages.map((r) => Number(r.id))); const baseIds = new Set(bases.map((r) => Number(r.id))); @@ -504,13 +497,16 @@ export async function fixEmulatorIssues(): Promise { // duplicate sibling order -> re-number sequentially per page const dupOrders = issues.filter((i) => i.type === "duplicate_order"); if (dupOrders.length > 0) { - const [allRows] = (await tx.execute(sql` + const allRows = rowsFrom<{ + id: number; + pageId: number | string; + orderNumber: number; + }>( + await tx.execute(sql` SELECT id, page_id AS pageId, order_number AS orderNumber FROM catalog_items ORDER BY page_id, order_number, id - `)) as unknown as [ - Array<{ id: number; pageId: number | string; orderNumber: number }>, - unknown, - ]; + `), + ); let lastPage: number | null = null; let next = 1; for (const row of allRows) { @@ -535,12 +531,14 @@ export async function fixEmulatorIssues(): Promise { const cyclePages = issues.filter((i) => i.type === "page_cycle"); if (cyclePages.length > 0) { const cycleIds = cyclePages.map((i) => i.id); - const [cycleRows] = (await tx.execute(sql` + const cycleRows = rowsFrom<{ + id: number | string; + parentId: number | string; + }>( + await tx.execute(sql` SELECT id, parent_id AS parentId FROM catalog_pages WHERE id IN (${sql.join(cycleIds, sql`, `)}) - `)) as unknown as [ - Array<{ id: number | string; parentId: number | string }>, - unknown, - ]; + `), + ); const parentOf = new Map( cycleRows.map((r) => [Number(r.id), Number(r.parentId)]), ); @@ -574,13 +572,15 @@ export async function fixEmulatorIssues(): Promise { if (pageOrderIssues.length > 0) { const parents = [...new Set(pageOrderIssues.map((i) => i.pageId))]; for (const parent of parents) { - const [children] = (await tx.execute(sql` + const children = rowsFrom<{ + id: number | string; + orderNum: number; + }>( + await tx.execute(sql` SELECT id, order_num AS orderNum FROM catalog_pages WHERE parent_id = ${String(parent)} ORDER BY order_num, id - `)) as unknown as [ - Array<{ id: number | string; orderNum: number }>, - unknown, - ]; + `), + ); let next = 1; for (const child of children) { const newOrder = next++; @@ -659,19 +659,19 @@ export async function dedupeCatalogPages(): Promise { if (pages.length < 2) continue; const [winner, ...losers] = pages.map((p) => p.id); for (const loser of losers) { - const ci = (await tx.execute(sql` - UPDATE catalog_items SET page_id = ${winner} WHERE page_id = ${loser}`)) as unknown as [ - Record, - unknown, - ]; - remapped += Number(ci[0]?.affectedRows ?? 0); + const affected = + rowsFrom<{ affectedRows: number }>( + await tx.execute(sql` + UPDATE catalog_items SET page_id = ${winner} WHERE page_id = ${loser}`), + )[0]?.affectedRows ?? 0; + remapped += Number(affected); if (await exists("catalog_items_bc")) { - const bci = (await tx.execute(sql` - UPDATE catalog_items_bc SET page_id = ${winner} WHERE page_id = ${loser}`)) as unknown as [ - Record, - unknown, - ]; - remapped += Number(bci[0]?.affectedRows ?? 0); + const bciAffected = + rowsFrom<{ affectedRows: number }>( + await tx.execute(sql` + UPDATE catalog_items_bc SET page_id = ${winner} WHERE page_id = ${loser}`), + )[0]?.affectedRows ?? 0; + remapped += Number(bciAffected); } // Keep any child pages by reparenting them onto the survivor. await tx.execute(sql` @@ -738,10 +738,12 @@ export async function removeDuplicateItemsBase(): Promise { const ids = g.ids.filter((x) => x !== g.keeper); for (const dupId of ids) { for (const [table, col] of activeInt) { - const r = (await tx.execute( - sql`UPDATE ${sql.raw(table)} t SET ${sql.raw(col)} = ${g.keeper} WHERE ${sql.raw(col)} = ${dupId}`, - )) as unknown as [Record, unknown]; - remapped += Number(r[0]?.affectedRows ?? 0); + const r = rowsFrom<{ affectedRows: number }>( + await tx.execute( + sql`UPDATE ${sql.raw(table)} t SET ${sql.raw(col)} = ${g.keeper} WHERE ${sql.raw(col)} = ${dupId}`, + ), + )[0]; + remapped += Number(r?.affectedRows ?? 0); } for (const [table, col] of activeStr) { const from = String(dupId); @@ -783,12 +785,12 @@ async function dedupCatalogTable(table: string): Promise { ? ["page_id", "item_ids", "catalog_name", "extradata"] : ["page_id", "item_ids"]; const on = grp.map((c) => `t.${c} <=> d.${c}`).join(" AND "); - const r = (await db.execute( + const r = await queryRows<{ affectedRows: number }>( sql.raw( `DELETE t FROM ${table} t JOIN (SELECT ${grp.join(",")}, MIN(id) AS _keep FROM ${table} GROUP BY ${grp.join(",")} HAVING COUNT(*) > 1) d ON ${on} WHERE t.id <> d._keep`, ), - )) as unknown as [Record, unknown]; - return Number(r[0]?.affectedRows ?? 0); + ); + return r[0]?.affectedRows ?? 0; } /** diff --git a/src/lib/services/furniture-id-reservation.test.ts b/src/lib/services/furniture-id-reservation.test.ts index 61c9e35f..297d8bad 100644 --- a/src/lib/services/furniture-id-reservation.test.ts +++ b/src/lib/services/furniture-id-reservation.test.ts @@ -3,7 +3,10 @@ import { MySqlDialect } from "drizzle-orm/mysql-core"; import { beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ execute: vi.fn(), conflict: 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("./furni-data", () => ({ findFurniDataIdConflict: mocks.conflict })); import { reserveFurnitureId } from "./furniture-id-reservation"; diff --git a/src/lib/services/furniture-id-reservation.ts b/src/lib/services/furniture-id-reservation.ts index 824d4b49..edbabc03 100644 --- a/src/lib/services/furniture-id-reservation.ts +++ b/src/lib/services/furniture-id-reservation.ts @@ -1,5 +1,5 @@ import { sql } from "drizzle-orm"; -import { db } from "@/lib/db"; +import { queryRows } from "@/lib/db"; import { findFurniDataIdConflict } from "./furni-data"; let reservation: Promise = Promise.resolve(); @@ -26,9 +26,9 @@ export async function reserveFurnitureId( candidate > 2147483647 ) throw new Error("No valid furniture ID is available"); - const [owners] = (await db.execute( + const owners = await queryRows<{ item_name: string }>( sql`SELECT item_name FROM items_base WHERE id = ${candidate} OR sprite_id = ${candidate} LIMIT 1`, - )) as unknown as [Array<{ item_name: string }>, unknown]; + ); const occupied = owners.length > 0 || !!(await findFurniDataIdConflict(candidate, classname)); @@ -44,9 +44,9 @@ export async function reserveFurnitureId( throw error; } } - const [rows] = (await db.execute( + const rows = await queryRows<{ maximum: number | string }>( sql`SELECT GREATEST(COALESCE(MAX(id), 0), COALESCE(MAX(sprite_id), 0)) AS maximum FROM items_base`, - )) as unknown as [Array<{ maximum: number | string }>, unknown]; + ); candidate = Math.max(candidate + 1, Number(rows[0]?.maximum ?? 0) + 1); } throw new Error("Cannot reserve a free furniture ID; retry the import"); diff --git a/src/lib/services/furniture-inspection.ts b/src/lib/services/furniture-inspection.ts index 07146846..faafaae1 100644 --- a/src/lib/services/furniture-inspection.ts +++ b/src/lib/services/furniture-inspection.ts @@ -1,7 +1,7 @@ import { promises as fs } from "node:fs"; import path from "node:path"; import { sql } from "drizzle-orm"; -import { db } from "@/lib/db"; +import { queryRows } from "@/lib/db"; import { normalizeClassname } from "@/lib/furni/classname"; import type { FurnitureInspection } from "@/lib/furni/studio-inspection"; import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs"; @@ -42,18 +42,16 @@ export async function inspectFurniture( names.map((name) => sql`${normalizeClassname(name)}`), sql`, `, ); - const [rows] = (await db.execute( + const rows = await queryRows< + FurnitureInspection["sql"][number] & { classname: string } + >( sql`SELECT id, sprite_id AS spriteId, item_name AS classname, public_name AS name, type FROM items_base WHERE LOWER(TRIM(item_name)) IN (${namesSql})`, - )) as unknown as [ - Array, - unknown, - ]; - const [offers] = (await db.execute( + ); + const offers = await queryRows< + FurnitureInspection["catalog"][number] & { classname: string } + >( sql`SELECT ib.item_name AS classname, ci.id, ci.page_id AS pageId, ci.cost_credits AS credits, ci.cost_points AS points FROM items_base ib JOIN catalog_items ci ON FIND_IN_SET(ib.id, REPLACE(ci.item_ids, ';', ',')) > 0 WHERE LOWER(TRIM(ib.item_name)) IN (${namesSql})`, - )) as unknown as [ - Array, - unknown, - ]; + ); let readable = true; let data: Record = {}; try { diff --git a/src/lib/services/news-scheduler.test.ts b/src/lib/services/news-scheduler.test.ts index b4854efa..50d701c7 100644 --- a/src/lib/services/news-scheduler.test.ts +++ b/src/lib/services/news-scheduler.test.ts @@ -22,7 +22,7 @@ const state = vi.hoisted(() => ({ })); vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } })); vi.mock("./news-cache", () => ({ invalidateNewsCache: state.refresh })); -vi.mock("@/lib/db", () => { +vi.mock("@/lib/db", async () => { const dialect = new MySqlDialect(); const timestamp = (value: unknown) => value instanceof Date @@ -68,6 +68,7 @@ vi.mock("@/lib/db", () => { }, }; return { + rowsFrom: (await import("@/test/db-helpers")).rowsFrom, db: { transaction: async (work: (tx: unknown) => Promise) => { state.transactionCalls++; diff --git a/src/lib/services/news-scheduler.ts b/src/lib/services/news-scheduler.ts index 74bb74fd..dd02673d 100644 --- a/src/lib/services/news-scheduler.ts +++ b/src/lib/services/news-scheduler.ts @@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto"; import { sql } from "drizzle-orm"; import { operationHash } from "@/features/operations/model"; import { enqueueEffect } from "@/features/operations/server"; -import { db } from "@/lib/db"; +import { db, rowsFrom } from "@/lib/db"; import { logger } from "@/lib/logger"; import { invalidateNewsCache } from "./news-cache"; @@ -35,23 +35,21 @@ export async function publishDueArticles(now = new Date()): Promise { for (let attempt = 0; ; attempt++) { try { published = await db.transaction(async (tx) => { - const [rows] = await tx.execute( - sql`SELECT CAST(id AS CHAR) AS id,user_id AS userId,title,publish_at AS publishAt FROM website_articles WHERE status='scheduled' AND publish_at<=${utcNow} ORDER BY publish_at,id LIMIT 100 FOR UPDATE`, - ); let published = 0; - for (const article of rows as unknown as Array<{ + for (const article of rowsFrom<{ id: string; userId: number | null; title: string | null; publishAt: Date | string; - }>) { + }>( + await tx.execute( + sql`SELECT CAST(id AS CHAR) AS id,user_id AS userId,title,publish_at AS publishAt FROM website_articles WHERE status='scheduled' AND publish_at<=${utcNow} ORDER BY publish_at,id LIMIT 100 FOR UPDATE`, + ), + )) { const [result] = await tx.execute( sql`UPDATE website_articles SET status='published',published_at=${utcNow},updated_at=${utcNow} WHERE id=${article.id} AND status='scheduled' AND publish_at<=${utcNow}`, ); - if ( - (result as unknown as { affectedRows: number }).affectedRows !== 1 - ) - continue; + if (result.affectedRows !== 1) continue; const operationId = randomUUID(); const hash = operationHash({ articleId: article.id, diff --git a/src/lib/services/nitro-cleanup.test.ts b/src/lib/services/nitro-cleanup.test.ts index aedfbdfe..dba657b5 100644 --- a/src/lib/services/nitro-cleanup.test.ts +++ b/src/lib/services/nitro-cleanup.test.ts @@ -52,7 +52,10 @@ const { downloadFileFn: vi.fn(async () => ({ ok: true, size: 100 })), })); -vi.mock("@/lib/db", () => ({ db: { execute: executeFn } })); +vi.mock("@/lib/db", async () => ({ + ...(await import("@/test/db-helpers")).createDbHelpers(executeFn), + db: { execute: executeFn }, +})); vi.mock("@/lib/services/furni-asset-dirs", () => ({ getFurniAssetWriteTargets: getTargetsFn, })); diff --git a/src/lib/services/nitro-cleanup.ts b/src/lib/services/nitro-cleanup.ts index 19f900bc..5fda1c97 100644 --- a/src/lib/services/nitro-cleanup.ts +++ b/src/lib/services/nitro-cleanup.ts @@ -1,7 +1,7 @@ import { existsSync, promises as fs } from "node:fs"; import os from "node:os"; import { sql } from "drizzle-orm"; -import { db } from "@/lib/db"; +import { queryRows } from "@/lib/db"; import { getBaseClassname, getSafeClassnameStem } from "@/lib/furni/classname"; import { DEFAULT_NITRO_SOURCES, @@ -272,12 +272,12 @@ async function loadValidStems(): Promise> { // fallback), mirroring repair-nitros.ts classname resolution. const validStems = new Set(); try { - const [result] = (await db.execute(sql` + const result = await queryRows<{ + item_name: string; + public_name: string; + }>(sql` SELECT item_name, public_name FROM items_base - `)) as unknown as [ - Array<{ item_name: string; public_name: string }>, - unknown, - ]; + `); for (const row of result) { addStems(validStems, row.item_name); if ( diff --git a/src/lib/services/permission-ranks.ts b/src/lib/services/permission-ranks.ts index 95f46433..e02c7efd 100644 --- a/src/lib/services/permission-ranks.ts +++ b/src/lib/services/permission-ranks.ts @@ -1,6 +1,5 @@ import { type SQL, sql } from "drizzle-orm"; -import type { ResultSetHeader } from "mysql2"; -import type { Db } from "@/lib/db"; +import { type Db, rowsFrom } from "@/lib/db"; const VALID_KEY = /^[a-z][a-z0-9_]{0,63}$/; @@ -46,14 +45,9 @@ export interface EmulatorRankForEdit extends EmulatorRankSummary { /** Surface of `Db` used by these helpers (plain raw-SQL exec + transactions). */ type RawDb = Pick; -async function rawRows(db: RawDb, query: SQL | string): Promise { - const [rows] = await db.execute(query); - return rows as T; -} - async function rawExec(db: RawDb, query: SQL | string): Promise { const [result] = await db.execute(query); - return (result as ResultSetHeader).affectedRows; + return result.affectedRows; } export function getRankPermissionColumn(rankId: number) { @@ -88,13 +82,12 @@ export function splitRankUpdateFields(fields: Record) { export async function fetchEmulatorRankSummaries( db: RawDb, ): Promise { - const rows = await rawRows[]>( - db, - sql` + const rows = rowsFrom>( + await db.execute(sql` SELECT id, rank_name, badge, level, prefix, prefix_color, hidden_rank FROM permission_ranks ORDER BY id ASC - `, + `), ); return rows.map((row) => ({ @@ -112,15 +105,14 @@ export async function fetchEmulatorRankForEdit( db: RawDb, rankId: number, ): Promise { - const rankRows = await rawRows[]>( - db, - sql` + const rankRows = rowsFrom>( + await db.execute(sql` SELECT id, rank_name, badge, level, prefix, prefix_color, hidden_rank, log_commands, room_effect, auto_credits_amount, auto_pixels_amount, auto_gotw_amount, auto_points_amount FROM permission_ranks WHERE id = ${rankId} - `, + `), ); if (rankRows.length === 0) return null; @@ -129,19 +121,16 @@ export async function fetchEmulatorRankForEdit( const permissionMaxValues: Record = {}; const definitionColumns = await getTableColumns(db, "permission_definitions"); if (definitionColumns.has(rankColumn)) { - const permissionRows = await rawRows< - { - permission_key: string; - max_value: number; - value: number | string | null; - }[] - >( - db, - sql` + const permissionRows = rowsFrom<{ + permission_key: string; + max_value: number; + value: number | string | null; + }>( + await db.execute(sql` SELECT permission_key, max_value, ${sql.raw(quoteIdentifier(rankColumn))} AS value FROM permission_definitions ORDER BY permission_key ASC - `, + `), ); for (const permission of permissionRows) { @@ -151,9 +140,10 @@ export async function fetchEmulatorRankForEdit( ); } } else { - const legacyRows = await rawRows[]>( - db, - sql`SELECT * FROM permissions WHERE id = ${rankId} LIMIT 1`, + const legacyRows = rowsFrom>( + await db.execute( + sql`SELECT * FROM permissions WHERE id = ${rankId} LIMIT 1`, + ), ); for (const [key, value] of Object.entries(legacyRows[0] ?? {})) { if ( @@ -196,9 +186,10 @@ export async function createEmulatorRank( db: RawDb, data: { rank_name: string; level: number }, ): Promise { - const rows = await rawRows<{ next_id: number }[]>( - db, - sql`SELECT COALESCE(MAX(id), 0) + 1 AS next_id FROM permission_ranks`, + const rows = rowsFrom<{ next_id: number }>( + await db.execute( + sql`SELECT COALESCE(MAX(id), 0) + 1 AS next_id FROM permission_ranks`, + ), ); const id = Number(rows[0]?.next_id ?? 1); await ensurePermissionRankColumn(db, id); @@ -311,15 +302,14 @@ async function updateLegacyPermissionFields( async function ensurePermissionRankColumn(db: RawDb, rankId: number) { const rankColumn = getRankPermissionColumn(rankId); - const exists = await rawRows<{ c: number }[]>( - db, - sql` + const exists = rowsFrom<{ c: number }>( + await db.execute(sql` SELECT COUNT(*) AS c FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = 'permission_definitions' AND column_name = ${rankColumn} - `, + `), ); if (Number(exists[0]?.c ?? 0) > 0) return; @@ -344,14 +334,13 @@ async function resetPermissionRankColumn(db: RawDb, rankId: number) { } async function getTableColumns(db: RawDb, table: string) { - const rows = await rawRows<{ column_name: string }[]>( - db, - sql` + const rows = rowsFrom<{ column_name: string }>( + await db.execute(sql` SELECT column_name FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = ${table} - `, + `), ); return new Set(rows.map((row) => row.column_name)); } diff --git a/src/lib/services/repair-icons.ts b/src/lib/services/repair-icons.ts index ae393848..e46953ef 100644 --- a/src/lib/services/repair-icons.ts +++ b/src/lib/services/repair-icons.ts @@ -1,7 +1,7 @@ import { existsSync, promises as fs } from "node:fs"; import os from "node:os"; import { sql } from "drizzle-orm"; -import { db } from "@/lib/db"; +import { queryRows } from "@/lib/db"; import { logServerError } from "@/lib/server-log"; import { extractFurniIconPng } from "@/lib/services/clone-icon"; import { @@ -174,19 +174,9 @@ export async function repairMissingIcons( interaction_type: string; }>; try { - const [rows] = (await db.execute(sql` + items = await queryRows<(typeof items)[number]>(sql` SELECT id, item_name, public_name, type, interaction_type 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) { onEvent?.({ type: "error", diff --git a/src/lib/services/repair-nitros.ts b/src/lib/services/repair-nitros.ts index b9050a59..bb808a26 100644 --- a/src/lib/services/repair-nitros.ts +++ b/src/lib/services/repair-nitros.ts @@ -1,7 +1,7 @@ import { existsSync, promises as fs } from "node:fs"; import os from "node:os"; import { sql } from "drizzle-orm"; -import { db } from "@/lib/db"; +import { queryRows } from "@/lib/db"; import { type CloneSource, DEFAULT_NITRO_SOURCES, @@ -167,19 +167,9 @@ export async function repairMissingNitros( interaction_type: string; }>; try { - const [rows] = (await db.execute(sql` + items = await queryRows<(typeof items)[number]>(sql` SELECT id, item_name, public_name, type, interaction_type 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) { onEvent?.({ type: "error", diff --git a/src/lib/services/upload-import.test.ts b/src/lib/services/upload-import.test.ts index 720730ac..1ee20b69 100644 --- a/src/lib/services/upload-import.test.ts +++ b/src/lib/services/upload-import.test.ts @@ -33,7 +33,8 @@ vi.mock("drizzle-orm", () => ({ }), })); -vi.mock("@/lib/db", () => ({ +vi.mock("@/lib/db", async () => ({ + ...(await import("@/test/db-helpers")).createDbHelpers(dbExecuteMock), ItemsBase: { id: "id", itemName: "item_name" }, db: { execute: dbExecuteMock, diff --git a/src/lib/services/upload-import.ts b/src/lib/services/upload-import.ts index 1b767bfa..d3763f3d 100644 --- a/src/lib/services/upload-import.ts +++ b/src/lib/services/upload-import.ts @@ -1,7 +1,7 @@ import { existsSync, promises as fs } from "node:fs"; import path from "node:path"; import { eq, 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 { logServerError } from "@/lib/server-log"; import { getFurniAssetWriteTargets } from "@/lib/services/furni-asset-dirs"; @@ -105,9 +105,9 @@ async function allocateItemsBaseId( itemsBaseNextId === null || 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 - `)) as unknown as [Array<{ next: number }>, unknown]; + `); itemsBaseNextId = Number(idRows[0]?.next ?? 1); } } finally { @@ -410,9 +410,9 @@ export async function uploadSingleFurni(params: { try { // Skip when the item already has a catalog row — re-uploads must never // create a second row for the same item_ids. - 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 - `)) as unknown as [Array<{ id: number }>, unknown]; + `); if (existingCatalog.length > 0) { catalogItemId = existingCatalog[0].id; warnings.push( diff --git a/src/test/db-helpers.ts b/src/test/db-helpers.ts new file mode 100644 index 00000000..6a0a2b02 --- /dev/null +++ b/src/test/db-helpers.ts @@ -0,0 +1,23 @@ +import type { ResultSetHeader } from "mysql2"; + +export function rowsFrom(result: unknown): T[] { + const [first] = result as [unknown, unknown]; + return (Array.isArray(first) ? first : []) as T[]; +} + +export function createDbHelpers(execute: (query: Query) => unknown) { + async function queryRows(query: Query): Promise { + return rowsFrom(await execute(query)); + } + + async function execResult(query: Query): Promise { + const [first] = (await execute(query)) as [unknown, unknown]; + return first as ResultSetHeader; + } + + async function affectedRows(query: Query): Promise { + return (await execResult(query)).affectedRows; + } + + return { rowsFrom, queryRows, execResult, affectedRows }; +} diff --git a/src/test/fake-form.ts b/src/test/fake-form.ts new file mode 100644 index 00000000..0849e64e --- /dev/null +++ b/src/test/fake-form.ts @@ -0,0 +1,7 @@ +export function fakeForm( + data: Record, +): FormData { + return { + get: (key: string) => data[key] ?? null, + } as unknown as FormData; +}