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
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" } }) }));
|
||||
|
||||
@@ -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";
|
||||
@@ -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);
|
||||
|
||||
@@ -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", () => ({
|
||||
|
||||
@@ -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 },
|
||||
}));
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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(() => ({
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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) };
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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++;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user