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

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

No files matched your search

+14
View File
@@ -0,0 +1,14 @@
# http://editorconfig.org
root = true
[*]
indent_style = tab
indent_size = 4
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.{js,ts,tsx,jsx,json,md}]
indent_style = space
indent_size = 2
+4 -7
View File
@@ -6,6 +6,7 @@ import { requirePermission } from "@/lib/admin/guard";
import { logger } from "@/lib/logger";
import { 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<string, string>) => ({
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();
});
});
+3 -6
View File
@@ -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<void> {
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");
}
+3 -8
View File
@@ -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<string, string>) => ({
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();
});
});
+6 -1
View File
@@ -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<unknown> =>
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";
+4 -5
View File
@@ -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");
+3 -6
View File
@@ -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<string, string>) => ({
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();
});
});
+1 -3
View File
@@ -60,9 +60,7 @@ export const liftBanFromHelpTicket = adminAction(
}
const result = await db.delete(Ban).where(eq(Ban.userId, ticket.userId));
const 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) {
+4 -7
View File
@@ -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<string, string | null>) => ({
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: "<p>Answer</p>",
}) 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");
});
+2 -3
View File
@@ -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<void> {
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<void> {
buttonColor,
buttonBorderColor,
smallBox: formData.get("smallBox") != null,
})) as unknown as [ResultSetHeader];
});
await logStaffActivity({
staffId: staff.id,
action: "help_create",
+6 -13
View File
@@ -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<string, string>) => ({
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();
});
});
+1 -4
View File
@@ -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" }]);
+2 -3
View File
@@ -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<void> {
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<void> {
position: reqUInt(formData, "position"),
createdAt: now,
updatedAt: now,
})) as unknown as [ResultSetHeader];
});
await logStaffActivity({
staffId: staff.id,
action: "shop_create",
+3 -1
View File
@@ -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) =>
+8 -8
View File
@@ -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(
+10 -13
View File
@@ -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<string, string>) => ({
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();
});
});
+2 -3
View File
@@ -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<void> {
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",
+4 -9
View File
@@ -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<string, string | null>) => ({
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");
});
+2 -3
View File
@@ -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) {
+2 -5
View File
@@ -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<string, string | null>) => ({
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);
+1 -4
View File
@@ -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");
+2 -3
View File
@@ -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<void> {
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<void> {
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",
+1 -4
View File
@@ -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" } });
+1 -4
View File
@@ -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,
+1 -3
View File
@@ -23,9 +23,7 @@ export async function bulkUnban({
}): Promise<ActionResult<{ unbanned: number; total: number }>> {
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",
+4 -4
View File
@@ -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);
+11 -11
View File
@@ -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<number>();
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));
+2 -3
View File
@@ -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 = [];
}
+1 -4
View File
@@ -297,10 +297,7 @@ export async function removeFriendship(formData: FormData): Promise<void> {
),
);
return Number(
(result as unknown as [{ affectedRows: number }])[0]
?.affectedRows ?? 0,
);
return Number(result[0]?.affectedRows ?? 0);
});
outcome = deleted > 0 ? "removed" : "not_found";
+17 -15
View File
@@ -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,
+3 -4
View File
@@ -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,
+16 -18
View File
@@ -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<string, string>) =>
({
get: (key: string) => data[key] ?? null,
}) as unknown as FormData;
beforeEach(() => {
vi.clearAllMocks();
mockAuth.mockResolvedValue({ user: { id: "42" } });
+2 -5
View File
@@ -6,6 +6,8 @@ vi.mock("next/server", () => ({
},
}));
import { fakeForm } from "@/test/fake-form";
vi.mock("next-auth", () => ({
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<string, string>) =>
({
get: (key: string) => data[key] ?? null,
}) as unknown as FormData;
beforeEach(() => {
vi.clearAllMocks();
mockAuth.mockResolvedValue({ user: { id: "1" } });
+3 -3
View File
@@ -3,7 +3,7 @@ import { getTranslations } from "next-intl/server";
import Link from "@/components/link";
import { 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<LeaderRow>(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,
+3 -4
View File
@@ -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<Failed>(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 = [];
}
+2 -3
View File
@@ -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<T>(query: ReturnType<typeof sql>): Promise<T[]> {
const [rows] = (await db.execute(query)) as unknown as [T[], unknown];
return rows ?? [];
return queryRows<T>(query);
}
async function safeRawCount(
+2 -2
View File
@@ -10,6 +10,7 @@ import {
db,
LogsShopPurchases,
MarketplaceItems,
queryRows,
RoomTradeLog,
User,
} from "@/lib/db";
@@ -63,8 +64,7 @@ async function safeCount(fn: () => Promise<number>): Promise<number> {
}
async function rawRows<T>(query: ReturnType<typeof sql>): Promise<T[]> {
const [rows] = (await db.execute(query)) as unknown as [T[], unknown];
return rows ?? [];
return queryRows<T>(query);
}
async function getEconomyData(
+2 -3
View File
@@ -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<T>(query: ReturnType<typeof sql>): Promise<T[]> {
const [rows] = (await db.execute(query)) as unknown as [T[], unknown];
return rows ?? [];
return queryRows<T>(query);
}
async function getAnalyticsData(
+2 -3
View File
@@ -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<T>(query: SqlFragment): Promise<T[]> {
const [rows] = (await db.execute(query)) as unknown as [T[], unknown];
return rows ?? [];
return queryRows<T>(query);
}
/**
+9 -4
View File
@@ -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<PublicRow>(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 = [];
}
+3 -3
View File
@@ -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<number, number> = {};
for (const row of rawCounts) {
countMap[Number(row.rank)] = Number(row.cnt);
@@ -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 () => ({
+9 -17
View File
@@ -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<string, string> = {};
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 (
+2 -3
View File
@@ -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<T>(query: ReturnType<typeof sql>): Promise<T[]> {
const [rows] = (await db.execute(query)) as unknown as [T[], unknown];
return rows ?? [];
return queryRows<T>(query);
}
export default async function AdminSubscriptions({
@@ -7,7 +7,10 @@ const state = vi.hoisted(() => ({
allowed: new Set<string>(),
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),
@@ -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<OverviewSection[]> {
const read = async (query: SQL): Promise<OverviewRow[]> => {
const [rows] = await db.execute(query);
return rows as unknown as OverviewRow[];
return queryRows<OverviewRow>(query);
};
const sources: {
key: string;
+43 -41
View File
@@ -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<MySqlDialect["sqlToQuery"]>[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<MySqlDialect["sqlToQuery"]>[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),
};
});
+2 -2
View File
@@ -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<T>(query: ReturnType<typeof sql>): Promise<T[]> {
const [rows] = (await db.execute(query)) as unknown as [T[], unknown];
return rows ?? [];
return queryRows<T>(query);
}
export async function loadUserById(
+10 -10
View File
@@ -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<ReadableStream<Uint8Array>> {
const [rows] = (await db.execute(sql`
const rows = await queryRows<{ day: string; cnt: bigint }>(sql`
SELECT FROM_UNIXTIME(account_created, '%Y-%m-%d') AS day, COUNT(*) AS cnt
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<ReadableStream<Uint8Array>> {
}
async function streamOnlineByHour(): Promise<ReadableStream<Uint8Array>> {
const [rows] = (await db.execute(sql`
const rows = await queryRows<{ hour: number; cnt: bigint }>(sql`
SELECT HOUR(FROM_UNIXTIME(last_online)) AS hour, COUNT(*) AS cnt
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<ReadableStream<Uint8Array>> {
}
async function streamEconomy(): Promise<ReadableStream<Uint8Array>> {
const [rows] = (await db.execute(sql`
const rows = await queryRows<{
total_credits: bigint;
total_pixels: bigint;
}>(sql`
SELECT SUM(credits) AS total_credits, SUM(pixels) AS total_pixels FROM users
`)) as unknown as [
{ total_credits: bigint; total_pixels: bigint }[],
unknown,
];
`);
const totals = rows[0];
const encoder = new TextEncoder();
return new ReadableStream({
@@ -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 = {
@@ -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);
}
+8 -11
View File
@@ -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) => {
+5 -5
View File
@@ -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<number, OrganizeItem>();
const itemIdsToResolve = new Set<number>();
if (pageIds.length > 0) {
const [rows] = (await db.execute(sql`
const rows = await queryRows<Record<string, unknown>>(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<string, unknown>[], 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);
+1 -4
View File
@@ -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 });
@@ -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<BlacklistWord>(
sql`SELECT id, word FROM custom_prefix_blacklist ORDER BY word ASC`,
)) as unknown as [BlacklistWord[], unknown];
);
return apiOk({ words });
});
+11 -12
View File
@@ -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<UserPrefix>(
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");
+3 -3
View File
@@ -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<string, string> = {
};
export const GET = withAdmin({ permission: PERMS.PREFIXES_VIEW }, async () => {
const [settings] = (await db.execute(
const settings = await queryRows<PrefixSetting>(
sql`SELECT \`key\`, \`value\` FROM custom_prefix_settings`,
)) as unknown as [PrefixSetting[], unknown];
);
const result: Record<string, string> = { ...DEFAULT_SETTINGS };
for (const s of settings) {
@@ -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 () => ({
+2 -3
View File
@@ -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<T>(query: ReturnType<typeof sql>): Promise<T[]> {
const [rows] = (await db.execute(query)) as unknown as [T[], unknown];
return rows;
return queryRows<T>(query);
}
async function loadTotalBadgesBoard(): Promise<BoardCore> {
+2 -3
View File
@@ -9,7 +9,6 @@
// table has no expires_at column, so it is never read or written here.
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);
}
+6 -7
View File
@@ -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<Response> {
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<Response> {
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<Response> {
// 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<Response> {
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<Response> {
// 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<Response> {
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);
+2 -3
View File
@@ -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(
{
@@ -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<unknown>) => {
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,
+16 -15
View File
@@ -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<OfferRow[]> {
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<OfferRow>(
await tx.execute(
sql`SELECT id, catalog_name AS catalogName, page_id AS pageId, cost_credits AS costCredits, cost_points AS costPoints, points_type AS pointsType FROM catalog_items WHERE id IN (${sql.join(ids, sql`, `)}) ORDER BY id ${lock ? sql`FOR UPDATE` : sql``}`,
),
).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.",
@@ -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<unknown>) => {
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,
@@ -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<unknown>) => {
const saved = state.writes;
@@ -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<DuplicateRow>(
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<DuplicateRow>(
await tx.execute(
sql`SELECT * FROM ${table(input.kind, true)} WHERE page_id IN (${sql.join(ids.map(String), sql`, `)}) ORDER BY id LIMIT 5001${suffix}`,
),
);
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)",
+9 -9
View File
@@ -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<number[]> {
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<number[]> {
/** Caption of the imported-furniture root page, when present. */
export async function getImportedRootCaption(): Promise<string | null> {
const [rows] = (await db.execute(sql`
const rows = await queryRows<{ caption: string }>(sql`
SELECT caption FROM catalog_pages
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;
}
@@ -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<unknown>) => {
+36 -25
View File
@@ -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<OfferRow[]> {
const [rows] = await tx.execute(
sql`SELECT id, page_id AS pageId, item_ids AS itemIds FROM ${offerTable(kind)} WHERE id IN (${sql.join(
[...ids].sort((a, b) => a - b),
sql`, `,
)}) ORDER BY id FOR UPDATE`,
const offers = rowsFrom<OfferRow>(
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<unknown>(
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;
});
+14 -10
View File
@@ -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<SourceRow[]> {
const [rows] = await tx.execute(
sql`SELECT * FROM catalog_pages ORDER BY id LIMIT 20001${lock ? sql` FOR UPDATE` : sql``}`,
const pages = rowsFrom<SourceRow>(
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<SourceRow>(
await tx.execute(
sql`SELECT * FROM catalog_items WHERE page_id IN (${sql.join(ids, sql`, `)}) ORDER BY id LIMIT 501${lock ? sql` FOR UPDATE` : sql``}`,
),
);
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");
+2 -1
View File
@@ -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<unknown>) => {
const before = structuredClone({
+11 -14
View File
@@ -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<StoredPackage> {
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<PackageListRow[]> {
const [rows] = await db.execute(
const rows = await queryRows<
Omit<PackageListRow, "updatedAt"> & { updatedAt: Date | string }
>(
sql`SELECT id,name,version,status,mode,updated_at AS updatedAt FROM website_catalog_packages ORDER BY updated_at DESC,id LIMIT 100`,
);
return (
rows as unknown as Array<
Omit<PackageListRow, "updatedAt"> & { 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;
@@ -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<unknown>) => {
+12 -9
View File
@@ -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<HierarchyPage[]> {
const { pages } = tables(kind);
// Structural commands serialize against a stable lock order, including parent validation.
const [rows] = await tx.execute(
sql`SELECT id, parent_id AS parentId, order_num AS orderNum FROM ${pages} ORDER BY id FOR UPDATE`,
const rows = rowsFrom<HierarchyPage>(
await tx.execute(
sql`SELECT id, parent_id AS parentId, order_num AS orderNum FROM ${pages} ORDER BY id FOR UPDATE`,
),
);
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<string, unknown>[])[0];
const current = rowsFrom<Record<string, unknown>>(
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(
+8 -4
View File
@@ -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";
+9 -15
View File
@@ -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<PageRow>(
sql`SELECT id, caption, parent_id AS parentId FROM ${pages} WHERE caption LIKE ${input.pattern} ESCAPE '=' ${number === null ? sql`` : sql`OR id=${number}`} ORDER BY ${number === null ? sql`id` : sql`CASE WHEN id=${number} THEN 0 ELSE 1 END, id`} LIMIT 12`,
),
db.execute(
queryRows<FurnitureRow>(
sql`SELECT id, item_name AS classname, sprite_id AS spriteId FROM items_base WHERE item_name LIKE ${input.pattern} ESCAPE '=' OR public_name LIKE ${input.pattern} ESCAPE '=' ${number === null ? sql`` : sql`OR id=${number} OR sprite_id=${number}`} ORDER BY ${number === null ? sql`id` : sql`CASE WHEN id=${number} THEN 0 ELSE 1 END, id`} LIMIT 25`,
),
]);
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<OfferRow>(
sql`SELECT i.id, i.catalog_name AS caption, i.page_id AS pageId, i.item_ids AS itemIds, p.caption AS pageCaption, p.parent_id AS parentId FROM ${offers} i INNER JOIN ${pages} p ON p.id=i.page_id WHERE ${sql.join(predicates, sql` OR `)} ORDER BY ${number === null ? sql`i.id` : sql`CASE WHEN i.id=${number} THEN 0 ELSE 1 END, i.id`} LIMIT ${LIMIT - categories.length}`,
);
const matches = (offerData as unknown as OfferRow[]).slice(
0,
LIMIT - categories.length,
);
const matches = offerData.slice(0, LIMIT - categories.length);
const pageMap = new Map<number, PageRow>();
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<PageRow>(
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),
+4 -1
View File
@@ -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";
+5 -1
View File
@@ -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 {
+11 -9
View File
@@ -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<string, unknown>[])[0];
const row = rowsFrom<Record<string, unknown>>(
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");
}
@@ -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";
+23 -26
View File
@@ -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<T>(
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<EffectClaim | null> {
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<UTC_TIMESTAMP(3)) ORDER BY available_at,id LIMIT 1 FOR UPDATE`,
);
const row = (
rows as unknown as Array<{
id: string;
operationId: string;
topic: EffectTopic;
attempts: number;
}>
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<UTC_TIMESTAMP(3)) ORDER BY available_at,id LIMIT 1 FOR UPDATE`,
),
)[0];
if (!row) return null;
const token = randomUUID();
@@ -105,10 +103,7 @@ export const effectRepository = {
export async function listEffects(id?: string) {
if (id !== undefined && !isDeliveryReference(id)) return [];
const filter = id === undefined ? sql`` : sql`WHERE e.id=${id}`;
const [rows] = await db.execute(
sql`SELECT e.id,e.operation_id AS operationId,e.topic,e.status,e.attempts,e.last_error AS lastError,e.created_at AS createdAt,e.available_at AS availableAt,o.actor_id AS actorId,o.kind,o.result_json AS resultJson FROM cms_outbox e JOIN cms_operations o ON o.id=e.operation_id ${filter} ORDER BY e.created_at DESC,e.id DESC LIMIT 100`,
);
return rows as unknown as Array<{
return queryRows<{
id: string;
operationId: string;
topic: EffectTopic;
@@ -120,7 +115,9 @@ export async function listEffects(id?: string) {
resultJson: string | null;
actorId: number;
kind: string;
}>;
}>(
sql`SELECT e.id,e.operation_id AS operationId,e.topic,e.status,e.attempts,e.last_error AS lastError,e.created_at AS createdAt,e.available_at AS availableAt,o.actor_id AS actorId,o.kind,o.result_json AS resultJson FROM cms_outbox e JOIN cms_operations o ON o.id=e.operation_id ${filter} ORDER BY e.created_at DESC,e.id DESC LIMIT 100`,
);
}
export async function retryEffect(id: string) {
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");
}
+1 -1
View File
@@ -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",
+5 -8
View File
@@ -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)
+2 -12
View File
@@ -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<LoginUser | null>(
`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,
+6 -1
View File
@@ -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" } }) }));
+22
View File
@@ -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<T>(result: unknown): T[] {
const [first] = result as [unknown, unknown];
return (Array.isArray(first) ? first : []) as T[];
}
export async function queryRows<T>(query: SQL | string): Promise<T[]> {
return rowsFrom<T>(await db.execute(query));
}
export async function execResult(
query: SQL | string,
): Promise<ResultSetHeader> {
const [first] = (await db.execute(query)) as unknown as [unknown, unknown];
return first as ResultSetHeader;
}
export async function affectedRows(query: SQL | string): Promise<number> {
return (await execResult(query)).affectedRows;
}
export type Db = ReturnType<typeof createDb>;
export * from "@/db/schema";
+3 -7
View File
@@ -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<string[]> => {
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);
+27 -52
View File
@@ -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<number>();
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<string, number>();
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<number>();
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<import("./catalog-integrity").IntegrityPage>(
sql`SELECT id, parent_id AS parentId, caption FROM ${pagesTable} ORDER BY id`,
),
db.execute(
queryRows<import("./catalog-integrity").IntegrityItem>(
sql`SELECT id, item_name, public_name, type, interaction_type FROM items_base ORDER BY id`,
),
db.execute(
queryRows<import("./catalog-integrity").IntegrityOffer>(
sql`SELECT id, page_id AS pageId, item_ids AS itemIds, catalog_name AS name, ${catalog === "bc" ? sql`NULL` : sql`offer_id`} AS offerId FROM ${offersTable} ORDER BY id`,
),
]);
@@ -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),
@@ -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", () => ({
+10 -15
View File
@@ -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<Record<string, unknown>>(sql`
SELECT * FROM catalog_items
WHERE CAST(page_id AS CHAR) = ${pageIdStr}
ORDER BY order_number ASC, id ASC
`)) as unknown as [Record<string, unknown>[], unknown[]];
const items: RawItem[] = rawItems.map((r: Record<string, unknown>) => ({
`);
const items: RawItem[] = rawItems.map((r) => ({
id: Number(r.id),
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<Record<string, unknown>>(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<string, unknown>[], unknown[]])
: [[], []];
`)
: [];
const baseItems: BaseItem[] = baseItemRows.map((r) => ({
id: Number(r.id),
@@ -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 },
}));
+32 -32
View File
@@ -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<OrphanedCatalogResult> {
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<number>();
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<OrphanedCatalogResult> {
* catalog_items when present, otherwise the lowest id.
*/
export async function repairDuplicateClassnames(): Promise<DuplicateClassnameResult> {
const [itemRows] = (await db.execute(sql`
const itemRows = await queryRows<{ id: number; item_name: string }>(sql`
SELECT id, item_name FROM items_base
`)) as unknown as [Array<{ id: number; item_name: string }>, unknown];
`);
const byName = new Map<string, number[]>();
for (const row of itemRows) {
@@ -524,9 +526,9 @@ export async function repairDuplicateClassnames(): Promise<DuplicateClassnameRes
byName.set(row.item_name, arr);
}
const [catalogRows] = (await db.execute(sql`
const catalogRows = await queryRows<{ item_ids: string }>(sql`
SELECT item_ids FROM catalog_items
`)) as unknown as [Array<{ item_ids: string }>, unknown];
`);
const referenced = new Set<number>();
for (const row of catalogRows) {
for (const part of (row.item_ids ?? "").split(";")) {
@@ -558,9 +560,9 @@ export async function repairDuplicateClassnames(): Promise<DuplicateClassnameRes
{ table: "logs_shop_purchases", idCol: "id" },
];
for (const { table, idCol } of listTables) {
const [rows] = (await db.execute(
const rows = await queryRows<{ pk: number; v: string | null }>(
sql`SELECT ${sql.raw(quoteIdentifier(idCol))} AS pk, item_ids AS v FROM ${sql.raw(quoteIdentifier(table))}`,
)) 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<DuplicateClassnameRes
);
const dupList = chunk.map(([dup]) => 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<string, unknown>, 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<import("./catalog-integrity").IntegrityPage>(
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<import("./catalog-integrity").IntegrityPage>(
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";
+11 -11
View File
@@ -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));
}
+2 -1
View File
@@ -65,7 +65,8 @@ vi.mock("node:fs", async (orig) => {
},
};
});
vi.mock("@/lib/db", () => ({
vi.mock("@/lib/db", async () => ({
...(await import("@/test/db-helpers")).createDbHelpers(executeFn),
db: {
execute: executeFn,
select: vi.fn(() => ({
+5 -5
View File
@@ -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<T>(
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`,
+5 -5
View File
@@ -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<CatalogOfferRebuildResult> {
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<string, unknown>, unknown];
`);
return { checked, fixed: Number(result.affectedRows ?? 0) };
});
+19 -28
View File
@@ -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<Set<number>> {
}
const set = new Set<number>();
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<number>();
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) {
+2 -1
View File
@@ -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",
+20 -15
View File
@@ -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<number> {
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<string, unknown>, unknown];
`);
return { fixed: Number(result.affectedRows ?? 0) };
}
@@ -486,9 +486,11 @@ export async function allocateCatalogItemId<T>(
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<string, unknown>, 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<string, unknown>, 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<string, unknown>, unknown];
`);
const costCreditsFixed = Number(creditResult.affectedRows ?? 0);
return { catalogNameFixed, haveOfferFixed, costCreditsFixed };
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff. Show more