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

+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 };
+8 -4
View File
@@ -12,8 +12,8 @@ const state = vi.hoisted(() => ({
queries: [] as { sql: string; params: unknown[] }[],
writes: 0,
}));
vi.mock("@/lib/db", () => ({
db: {
vi.mock("@/lib/db", async () => {
const db = {
execute: async (query: SQL) => {
const compiled = new MySqlDialect().sqlToQuery(query);
if (compiled.sql.includes("FROM catalog_items ci"))
@@ -50,8 +50,12 @@ vi.mock("@/lib/db", () => ({
},
});
},
},
}));
};
return {
db,
...(await import("@/test/db-helpers")).createDbHelpers(db.execute),
};
});
import { fixEmulatorIssues, listEmulatorIssues } from "./furni-maintenance";
+65 -63
View File
@@ -1,5 +1,5 @@
import { sql } from "drizzle-orm";
import { db } from "@/lib/db";
import { db, queryRows, rowsFrom } from "@/lib/db";
import {
type CatalogOfferRebuildResult,
type FurniDataReconcileResult,
@@ -56,11 +56,8 @@ const STR_COLS: Array<[string, string]> = [
["catalog_version_offers", "item_ids"],
];
type Rows<T> = [T[], unknown];
async function query<T>(q: unknown): Promise<T[]> {
const [rows] = (await db.execute(q as never)) as unknown as Rows<T>;
return rows ?? [];
return queryRows<T>(q as never);
}
async function exists(table: string): Promise<boolean> {
@@ -228,37 +225,33 @@ export async function countEmulatorIssues(): Promise<number> {
}
export async function listEmulatorIssues(): Promise<EmulatorCatalogIssue[]> {
const [items] = (await db.execute(sql`
const items = await queryRows<{
id: number;
pageId: number | string;
itemIds: string;
costCredits: number;
costPoints: number;
pointsType: number;
amount: number;
limitedStack: number;
limitedSells: number;
orderNumber: number;
}>(sql`
SELECT
ci.id, ci.page_id AS pageId, ci.item_ids AS itemIds,
ci.cost_credits AS costCredits, ci.cost_points AS costPoints,
ci.points_type AS pointsType, ci.amount, ci.limited_stack AS limitedStack,
ci.limited_sells AS limitedSells, ci.order_number AS orderNumber
FROM catalog_items ci
`)) as unknown as [
Array<{
id: number;
pageId: number | string;
itemIds: string;
costCredits: number;
costPoints: number;
pointsType: number;
amount: number;
limitedStack: number;
limitedSells: number;
orderNumber: number;
}>,
unknown,
];
const [pages] = (await db.execute(
`);
const pages = await queryRows<{
id: number;
parentId: number | string;
orderNum: number;
}>(
sql`SELECT id, parent_id AS parentId, order_num AS orderNum FROM catalog_pages`,
)) as unknown as [
Array<{ id: number; parentId: number | string; orderNum: number }>,
unknown,
];
const [bases] = (await db.execute(
sql`SELECT id FROM items_base`,
)) as unknown as [Array<{ id: number }>, unknown];
);
const bases = await queryRows<{ id: number }>(sql`SELECT id FROM items_base`);
const pageIds = new Set(pages.map((r) => Number(r.id)));
const baseIds = new Set(bases.map((r) => Number(r.id)));
@@ -504,13 +497,16 @@ export async function fixEmulatorIssues(): Promise<FixEmulatorResult> {
// duplicate sibling order -> re-number sequentially per page
const dupOrders = issues.filter((i) => i.type === "duplicate_order");
if (dupOrders.length > 0) {
const [allRows] = (await tx.execute(sql`
const allRows = rowsFrom<{
id: number;
pageId: number | string;
orderNumber: number;
}>(
await tx.execute(sql`
SELECT id, page_id AS pageId, order_number AS orderNumber
FROM catalog_items ORDER BY page_id, order_number, id
`)) as unknown as [
Array<{ id: number; pageId: number | string; orderNumber: number }>,
unknown,
];
`),
);
let lastPage: number | null = null;
let next = 1;
for (const row of allRows) {
@@ -535,12 +531,14 @@ export async function fixEmulatorIssues(): Promise<FixEmulatorResult> {
const cyclePages = issues.filter((i) => i.type === "page_cycle");
if (cyclePages.length > 0) {
const cycleIds = cyclePages.map((i) => i.id);
const [cycleRows] = (await tx.execute(sql`
const cycleRows = rowsFrom<{
id: number | string;
parentId: number | string;
}>(
await tx.execute(sql`
SELECT id, parent_id AS parentId FROM catalog_pages WHERE id IN (${sql.join(cycleIds, sql`, `)})
`)) as unknown as [
Array<{ id: number | string; parentId: number | string }>,
unknown,
];
`),
);
const parentOf = new Map(
cycleRows.map((r) => [Number(r.id), Number(r.parentId)]),
);
@@ -574,13 +572,15 @@ export async function fixEmulatorIssues(): Promise<FixEmulatorResult> {
if (pageOrderIssues.length > 0) {
const parents = [...new Set(pageOrderIssues.map((i) => i.pageId))];
for (const parent of parents) {
const [children] = (await tx.execute(sql`
const children = rowsFrom<{
id: number | string;
orderNum: number;
}>(
await tx.execute(sql`
SELECT id, order_num AS orderNum FROM catalog_pages
WHERE parent_id = ${String(parent)} ORDER BY order_num, id
`)) as unknown as [
Array<{ id: number | string; orderNum: number }>,
unknown,
];
`),
);
let next = 1;
for (const child of children) {
const newOrder = next++;
@@ -659,19 +659,19 @@ export async function dedupeCatalogPages(): Promise<CatalogPageDedupResult> {
if (pages.length < 2) continue;
const [winner, ...losers] = pages.map((p) => p.id);
for (const loser of losers) {
const ci = (await tx.execute(sql`
UPDATE catalog_items SET page_id = ${winner} WHERE page_id = ${loser}`)) as unknown as [
Record<string, unknown>,
unknown,
];
remapped += Number(ci[0]?.affectedRows ?? 0);
const affected =
rowsFrom<{ affectedRows: number }>(
await tx.execute(sql`
UPDATE catalog_items SET page_id = ${winner} WHERE page_id = ${loser}`),
)[0]?.affectedRows ?? 0;
remapped += Number(affected);
if (await exists("catalog_items_bc")) {
const bci = (await tx.execute(sql`
UPDATE catalog_items_bc SET page_id = ${winner} WHERE page_id = ${loser}`)) as unknown as [
Record<string, unknown>,
unknown,
];
remapped += Number(bci[0]?.affectedRows ?? 0);
const bciAffected =
rowsFrom<{ affectedRows: number }>(
await tx.execute(sql`
UPDATE catalog_items_bc SET page_id = ${winner} WHERE page_id = ${loser}`),
)[0]?.affectedRows ?? 0;
remapped += Number(bciAffected);
}
// Keep any child pages by reparenting them onto the survivor.
await tx.execute(sql`
@@ -738,10 +738,12 @@ export async function removeDuplicateItemsBase(): Promise<DedupResult> {
const ids = g.ids.filter((x) => x !== g.keeper);
for (const dupId of ids) {
for (const [table, col] of activeInt) {
const r = (await tx.execute(
sql`UPDATE ${sql.raw(table)} t SET ${sql.raw(col)} = ${g.keeper} WHERE ${sql.raw(col)} = ${dupId}`,
)) as unknown as [Record<string, unknown>, unknown];
remapped += Number(r[0]?.affectedRows ?? 0);
const r = rowsFrom<{ affectedRows: number }>(
await tx.execute(
sql`UPDATE ${sql.raw(table)} t SET ${sql.raw(col)} = ${g.keeper} WHERE ${sql.raw(col)} = ${dupId}`,
),
)[0];
remapped += Number(r?.affectedRows ?? 0);
}
for (const [table, col] of activeStr) {
const from = String(dupId);
@@ -783,12 +785,12 @@ async function dedupCatalogTable(table: string): Promise<number> {
? ["page_id", "item_ids", "catalog_name", "extradata"]
: ["page_id", "item_ids"];
const on = grp.map((c) => `t.${c} <=> d.${c}`).join(" AND ");
const r = (await db.execute(
const r = await queryRows<{ affectedRows: number }>(
sql.raw(
`DELETE t FROM ${table} t JOIN (SELECT ${grp.join(",")}, MIN(id) AS _keep FROM ${table} GROUP BY ${grp.join(",")} HAVING COUNT(*) > 1) d ON ${on} WHERE t.id <> d._keep`,
),
)) as unknown as [Record<string, unknown>, unknown];
return Number(r[0]?.affectedRows ?? 0);
);
return r[0]?.affectedRows ?? 0;
}
/**
@@ -3,7 +3,10 @@ import { MySqlDialect } from "drizzle-orm/mysql-core";
import { beforeEach, describe, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({ execute: vi.fn(), conflict: vi.fn() }));
vi.mock("@/lib/db", () => ({ db: { execute: mocks.execute } }));
vi.mock("@/lib/db", async () => ({
...(await import("@/test/db-helpers")).createDbHelpers(mocks.execute),
db: { execute: mocks.execute },
}));
vi.mock("./furni-data", () => ({ findFurniDataIdConflict: mocks.conflict }));
import { reserveFurnitureId } from "./furniture-id-reservation";
+5 -5
View File
@@ -1,5 +1,5 @@
import { sql } from "drizzle-orm";
import { db } from "@/lib/db";
import { queryRows } from "@/lib/db";
import { findFurniDataIdConflict } from "./furni-data";
let reservation: Promise<void> = Promise.resolve();
@@ -26,9 +26,9 @@ export async function reserveFurnitureId(
candidate > 2147483647
)
throw new Error("No valid furniture ID is available");
const [owners] = (await db.execute(
const owners = await queryRows<{ item_name: string }>(
sql`SELECT item_name FROM items_base WHERE id = ${candidate} OR sprite_id = ${candidate} LIMIT 1`,
)) as unknown as [Array<{ item_name: string }>, unknown];
);
const occupied =
owners.length > 0 ||
!!(await findFurniDataIdConflict(candidate, classname));
@@ -44,9 +44,9 @@ export async function reserveFurnitureId(
throw error;
}
}
const [rows] = (await db.execute(
const rows = await queryRows<{ maximum: number | string }>(
sql`SELECT GREATEST(COALESCE(MAX(id), 0), COALESCE(MAX(sprite_id), 0)) AS maximum FROM items_base`,
)) as unknown as [Array<{ maximum: number | string }>, unknown];
);
candidate = Math.max(candidate + 1, Number(rows[0]?.maximum ?? 0) + 1);
}
throw new Error("Cannot reserve a free furniture ID; retry the import");
+9 -11
View File
@@ -1,7 +1,7 @@
import { promises as fs } from "node:fs";
import path from "node:path";
import { sql } from "drizzle-orm";
import { db } from "@/lib/db";
import { queryRows } from "@/lib/db";
import { normalizeClassname } from "@/lib/furni/classname";
import type { FurnitureInspection } from "@/lib/furni/studio-inspection";
import { getFurniAssetDirs } from "@/lib/services/furni-asset-dirs";
@@ -42,18 +42,16 @@ export async function inspectFurniture(
names.map((name) => sql`${normalizeClassname(name)}`),
sql`, `,
);
const [rows] = (await db.execute(
const rows = await queryRows<
FurnitureInspection["sql"][number] & { classname: string }
>(
sql`SELECT id, sprite_id AS spriteId, item_name AS classname, public_name AS name, type FROM items_base WHERE LOWER(TRIM(item_name)) IN (${namesSql})`,
)) as unknown as [
Array<FurnitureInspection["sql"][number] & { classname: string }>,
unknown,
];
const [offers] = (await db.execute(
);
const offers = await queryRows<
FurnitureInspection["catalog"][number] & { classname: string }
>(
sql`SELECT ib.item_name AS classname, ci.id, ci.page_id AS pageId, ci.cost_credits AS credits, ci.cost_points AS points FROM items_base ib JOIN catalog_items ci ON FIND_IN_SET(ib.id, REPLACE(ci.item_ids, ';', ',')) > 0 WHERE LOWER(TRIM(ib.item_name)) IN (${namesSql})`,
)) as unknown as [
Array<FurnitureInspection["catalog"][number] & { classname: string }>,
unknown,
];
);
let readable = true;
let data: Record<string, unknown> = {};
try {
+2 -1
View File
@@ -22,7 +22,7 @@ const state = vi.hoisted(() => ({
}));
vi.mock("@/lib/logger", () => ({ logger: { error: vi.fn() } }));
vi.mock("./news-cache", () => ({ invalidateNewsCache: state.refresh }));
vi.mock("@/lib/db", () => {
vi.mock("@/lib/db", async () => {
const dialect = new MySqlDialect();
const timestamp = (value: unknown) =>
value instanceof Date
@@ -68,6 +68,7 @@ vi.mock("@/lib/db", () => {
},
};
return {
rowsFrom: (await import("@/test/db-helpers")).rowsFrom,
db: {
transaction: async (work: (tx: unknown) => Promise<unknown>) => {
state.transactionCalls++;
+8 -10
View File
@@ -3,7 +3,7 @@ import { randomUUID } from "node:crypto";
import { sql } from "drizzle-orm";
import { operationHash } from "@/features/operations/model";
import { enqueueEffect } from "@/features/operations/server";
import { db } from "@/lib/db";
import { db, rowsFrom } from "@/lib/db";
import { logger } from "@/lib/logger";
import { invalidateNewsCache } from "./news-cache";
@@ -35,23 +35,21 @@ export async function publishDueArticles(now = new Date()): Promise<number> {
for (let attempt = 0; ; attempt++) {
try {
published = await db.transaction(async (tx) => {
const [rows] = await tx.execute(
sql`SELECT CAST(id AS CHAR) AS id,user_id AS userId,title,publish_at AS publishAt FROM website_articles WHERE status='scheduled' AND publish_at<=${utcNow} ORDER BY publish_at,id LIMIT 100 FOR UPDATE`,
);
let published = 0;
for (const article of rows as unknown as Array<{
for (const article of rowsFrom<{
id: string;
userId: number | null;
title: string | null;
publishAt: Date | string;
}>) {
}>(
await tx.execute(
sql`SELECT CAST(id AS CHAR) AS id,user_id AS userId,title,publish_at AS publishAt FROM website_articles WHERE status='scheduled' AND publish_at<=${utcNow} ORDER BY publish_at,id LIMIT 100 FOR UPDATE`,
),
)) {
const [result] = await tx.execute(
sql`UPDATE website_articles SET status='published',published_at=${utcNow},updated_at=${utcNow} WHERE id=${article.id} AND status='scheduled' AND publish_at<=${utcNow}`,
);
if (
(result as unknown as { affectedRows: number }).affectedRows !== 1
)
continue;
if (result.affectedRows !== 1) continue;
const operationId = randomUUID();
const hash = operationHash({
articleId: article.id,
+4 -1
View File
@@ -52,7 +52,10 @@ const {
downloadFileFn: vi.fn<AnyFn>(async () => ({ ok: true, size: 100 })),
}));
vi.mock("@/lib/db", () => ({ db: { execute: executeFn } }));
vi.mock("@/lib/db", async () => ({
...(await import("@/test/db-helpers")).createDbHelpers(executeFn),
db: { execute: executeFn },
}));
vi.mock("@/lib/services/furni-asset-dirs", () => ({
getFurniAssetWriteTargets: getTargetsFn,
}));
+6 -6
View File
@@ -1,7 +1,7 @@
import { existsSync, promises as fs } from "node:fs";
import os from "node:os";
import { sql } from "drizzle-orm";
import { db } from "@/lib/db";
import { queryRows } from "@/lib/db";
import { getBaseClassname, getSafeClassnameStem } from "@/lib/furni/classname";
import {
DEFAULT_NITRO_SOURCES,
@@ -272,12 +272,12 @@ async function loadValidStems(): Promise<Set<string>> {
// fallback), mirroring repair-nitros.ts classname resolution.
const validStems = new Set<string>();
try {
const [result] = (await db.execute(sql`
const result = await queryRows<{
item_name: string;
public_name: string;
}>(sql`
SELECT item_name, public_name FROM items_base
`)) as unknown as [
Array<{ item_name: string; public_name: string }>,
unknown,
];
`);
for (const row of result) {
addStems(validStems, row.item_name);
if (
+29 -40
View File
@@ -1,6 +1,5 @@
import { type SQL, sql } from "drizzle-orm";
import type { ResultSetHeader } from "mysql2";
import type { Db } from "@/lib/db";
import { type Db, rowsFrom } from "@/lib/db";
const VALID_KEY = /^[a-z][a-z0-9_]{0,63}$/;
@@ -46,14 +45,9 @@ export interface EmulatorRankForEdit extends EmulatorRankSummary {
/** Surface of `Db` used by these helpers (plain raw-SQL exec + transactions). */
type RawDb = Pick<Db, "execute" | "transaction">;
async function rawRows<T>(db: RawDb, query: SQL | string): Promise<T> {
const [rows] = await db.execute(query);
return rows as T;
}
async function rawExec(db: RawDb, query: SQL | string): Promise<number> {
const [result] = await db.execute(query);
return (result as ResultSetHeader).affectedRows;
return result.affectedRows;
}
export function getRankPermissionColumn(rankId: number) {
@@ -88,13 +82,12 @@ export function splitRankUpdateFields(fields: Record<string, string | number>) {
export async function fetchEmulatorRankSummaries(
db: RawDb,
): Promise<EmulatorRankSummary[]> {
const rows = await rawRows<Record<string, unknown>[]>(
db,
sql`
const rows = rowsFrom<Record<string, unknown>>(
await db.execute(sql`
SELECT id, rank_name, badge, level, prefix, prefix_color, hidden_rank
FROM permission_ranks
ORDER BY id ASC
`,
`),
);
return rows.map((row) => ({
@@ -112,15 +105,14 @@ export async function fetchEmulatorRankForEdit(
db: RawDb,
rankId: number,
): Promise<EmulatorRankForEdit | null> {
const rankRows = await rawRows<Record<string, unknown>[]>(
db,
sql`
const rankRows = rowsFrom<Record<string, unknown>>(
await db.execute(sql`
SELECT id, rank_name, badge, level, prefix, prefix_color, hidden_rank,
log_commands, room_effect, auto_credits_amount, auto_pixels_amount,
auto_gotw_amount, auto_points_amount
FROM permission_ranks
WHERE id = ${rankId}
`,
`),
);
if (rankRows.length === 0) return null;
@@ -129,19 +121,16 @@ export async function fetchEmulatorRankForEdit(
const permissionMaxValues: Record<string, number> = {};
const definitionColumns = await getTableColumns(db, "permission_definitions");
if (definitionColumns.has(rankColumn)) {
const permissionRows = await rawRows<
{
permission_key: string;
max_value: number;
value: number | string | null;
}[]
>(
db,
sql`
const permissionRows = rowsFrom<{
permission_key: string;
max_value: number;
value: number | string | null;
}>(
await db.execute(sql`
SELECT permission_key, max_value, ${sql.raw(quoteIdentifier(rankColumn))} AS value
FROM permission_definitions
ORDER BY permission_key ASC
`,
`),
);
for (const permission of permissionRows) {
@@ -151,9 +140,10 @@ export async function fetchEmulatorRankForEdit(
);
}
} else {
const legacyRows = await rawRows<Record<string, unknown>[]>(
db,
sql`SELECT * FROM permissions WHERE id = ${rankId} LIMIT 1`,
const legacyRows = rowsFrom<Record<string, unknown>>(
await db.execute(
sql`SELECT * FROM permissions WHERE id = ${rankId} LIMIT 1`,
),
);
for (const [key, value] of Object.entries(legacyRows[0] ?? {})) {
if (
@@ -196,9 +186,10 @@ export async function createEmulatorRank(
db: RawDb,
data: { rank_name: string; level: number },
): Promise<number> {
const rows = await rawRows<{ next_id: number }[]>(
db,
sql`SELECT COALESCE(MAX(id), 0) + 1 AS next_id FROM permission_ranks`,
const rows = rowsFrom<{ next_id: number }>(
await db.execute(
sql`SELECT COALESCE(MAX(id), 0) + 1 AS next_id FROM permission_ranks`,
),
);
const id = Number(rows[0]?.next_id ?? 1);
await ensurePermissionRankColumn(db, id);
@@ -311,15 +302,14 @@ async function updateLegacyPermissionFields(
async function ensurePermissionRankColumn(db: RawDb, rankId: number) {
const rankColumn = getRankPermissionColumn(rankId);
const exists = await rawRows<{ c: number }[]>(
db,
sql`
const exists = rowsFrom<{ c: number }>(
await db.execute(sql`
SELECT COUNT(*) AS c
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = 'permission_definitions'
AND column_name = ${rankColumn}
`,
`),
);
if (Number(exists[0]?.c ?? 0) > 0) return;
@@ -344,14 +334,13 @@ async function resetPermissionRankColumn(db: RawDb, rankId: number) {
}
async function getTableColumns(db: RawDb, table: string) {
const rows = await rawRows<{ column_name: string }[]>(
db,
sql`
const rows = rowsFrom<{ column_name: string }>(
await db.execute(sql`
SELECT column_name
FROM information_schema.columns
WHERE table_schema = DATABASE()
AND table_name = ${table}
`,
`),
);
return new Set(rows.map((row) => row.column_name));
}
+3 -13
View File
@@ -1,7 +1,7 @@
import { existsSync, promises as fs } from "node:fs";
import os from "node:os";
import { sql } from "drizzle-orm";
import { db } from "@/lib/db";
import { queryRows } from "@/lib/db";
import { logServerError } from "@/lib/server-log";
import { extractFurniIconPng } from "@/lib/services/clone-icon";
import {
@@ -174,19 +174,9 @@ export async function repairMissingIcons(
interaction_type: string;
}>;
try {
const [rows] = (await db.execute(sql`
items = await queryRows<(typeof items)[number]>(sql`
SELECT id, item_name, public_name, type, interaction_type FROM items_base ORDER BY id
`)) as unknown as [
Array<{
id: number;
item_name: string;
public_name: string;
type: string;
interaction_type: string;
}>,
unknown,
];
items = rows;
`);
} catch (err) {
onEvent?.({
type: "error",
+3 -13
View File
@@ -1,7 +1,7 @@
import { existsSync, promises as fs } from "node:fs";
import os from "node:os";
import { sql } from "drizzle-orm";
import { db } from "@/lib/db";
import { queryRows } from "@/lib/db";
import {
type CloneSource,
DEFAULT_NITRO_SOURCES,
@@ -167,19 +167,9 @@ export async function repairMissingNitros(
interaction_type: string;
}>;
try {
const [rows] = (await db.execute(sql`
items = await queryRows<(typeof items)[number]>(sql`
SELECT id, item_name, public_name, type, interaction_type FROM items_base ORDER BY id
`)) as unknown as [
Array<{
id: number;
item_name: string;
public_name: string;
type: string;
interaction_type: string;
}>,
unknown,
];
items = rows;
`);
} catch (err) {
onEvent?.({
type: "error",
+2 -1
View File
@@ -33,7 +33,8 @@ vi.mock("drizzle-orm", () => ({
}),
}));
vi.mock("@/lib/db", () => ({
vi.mock("@/lib/db", async () => ({
...(await import("@/test/db-helpers")).createDbHelpers(dbExecuteMock),
ItemsBase: { id: "id", itemName: "item_name" },
db: {
execute: dbExecuteMock,
+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, ItemsBase, queryRows } from "@/lib/db";
import { autoDetectInteraction } from "@/lib/furni/auto-interaction";
import { logServerError } from "@/lib/server-log";
import { getFurniAssetWriteTargets } from "@/lib/services/furni-asset-dirs";
@@ -105,9 +105,9 @@ async function allocateItemsBaseId<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 {
@@ -410,9 +410,9 @@ export async function uploadSingleFurni(params: {
try {
// Skip when the item already has a catalog row — re-uploads must never
// create a second row for the same item_ids.
const [existingCatalog] = (await db.execute(sql`
const existingCatalog = await queryRows<{ id: number }>(sql`
SELECT id FROM catalog_items WHERE item_ids = ${String(newId)} LIMIT 1
`)) as unknown as [Array<{ id: number }>, unknown];
`);
if (existingCatalog.length > 0) {
catalogItemId = existingCatalog[0].id;
warnings.push(