refactor(db): typed query helpers, shared test FormData helper
Replace raw db.execute tuple casts with queryRows/rowsFrom/execResult/ affectedRows helpers from lib/db, drop redundant mysql2 casts on typed query builders, and centralize per-test fakeForm into test/fake-form. Update db mocks in tests so helpers resolve against mocked execute.
This commit is contained in:
1 parent
2e25b39364
commit
8638e81444
116 files changed
+866
-897
No files matched your search
@@ -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);
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,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 });
|
||||
});
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 () => ({
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
{
|
||||
|
||||
Reference in new issue
Block a user