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

+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(
{