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

@@ -32,8 +32,8 @@ const state = vi.hoisted(() => ({
commits: 0,
rollbacks: 0,
}));
vi.mock("@/lib/db", () => ({
db: {
vi.mock("@/lib/db", async () => {
const db = {
execute: async () => [state.pages, []],
transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
const before = state.writes;
@@ -112,11 +112,16 @@ vi.mock("@/lib/db", () => ({
throw error;
}
},
},
}));
};
return {
db,
...(await import("@/test/db-helpers")).createDbHelpers(db.execute),
};
});
vi.mock("@/features/operations/server", async () => {
const { db } = await import("@/lib/db");
import { db } from "@/lib/db";
vi.mock("@/features/operations/server", () => {
return {
runOperation: (
_input: unknown,
+16 -15
View File
@@ -8,7 +8,7 @@ import {
recordHistory,
} from "@/features/history/server";
import { enqueueEffect, runOperation } from "@/features/operations/server";
import { db } from "@/lib/db";
import { db, queryRows, rowsFrom } from "@/lib/db";
import {
type BulkOfferInput,
type BulkOfferPreview,
@@ -29,10 +29,11 @@ async function readOffers(
ids: number[],
lock = false,
): Promise<OfferRow[]> {
const [rows] = await tx.execute(
sql`SELECT id, catalog_name AS catalogName, page_id AS pageId, cost_credits AS costCredits, cost_points AS costPoints, points_type AS pointsType FROM catalog_items WHERE id IN (${sql.join(ids, sql`, `)}) ORDER BY id ${lock ? sql`FOR UPDATE` : sql``}`,
);
const offers = (rows as unknown as OfferRow[]).map((row) => ({
const offers = rowsFrom<OfferRow>(
await tx.execute(
sql`SELECT id, catalog_name AS catalogName, page_id AS pageId, cost_credits AS costCredits, cost_points AS costPoints, points_type AS pointsType FROM catalog_items WHERE id IN (${sql.join(ids, sql`, `)}) ORDER BY id ${lock ? sql`FOR UPDATE` : sql``}`,
),
).map((row) => ({
...row,
id: Number(row.id),
pageId: Number(row.pageId),
@@ -63,10 +64,11 @@ async function readPages(
].sort((a, b) => a - b);
if (ids.some((id) => !Number.isInteger(id) || id <= 0))
throw new CatalogInputError("An offer has an invalid category reference");
const [rows] = await tx.execute(
sql`SELECT id, caption FROM catalog_pages WHERE id IN (${sql.join(ids, sql`, `)}) ORDER BY id ${lock ? sql`FOR UPDATE` : sql``}`,
);
const pages = (rows as unknown as { id: number; caption: string }[])
const pages = rowsFrom<{ id: number; caption: string }>(
await tx.execute(
sql`SELECT id, caption FROM catalog_pages WHERE id IN (${sql.join(ids, sql`, `)}) ORDER BY id ${lock ? sql`FOR UPDATE` : sql``}`,
),
)
.filter((row) => ids.includes(Number(row.id)))
.map((row) => ({ id: Number(row.id), caption: row.caption }))
.sort((a, b) => a.id - b.id);
@@ -193,12 +195,11 @@ export async function applyBulkOffersCommand(
}
export async function listBulkOfferDestinationsCommand() {
const [rows] = await db.execute(
sql`SELECT id, caption FROM catalog_pages WHERE id > 0 ORDER BY caption, id LIMIT 5001`,
);
const pages = (rows as unknown as Array<{ id: number; caption: string }>).map(
(row) => ({ id: Number(row.id), caption: row.caption }),
);
const pages = (
await queryRows<{ id: number; caption: string }>(
sql`SELECT id, caption FROM catalog_pages WHERE id > 0 ORDER BY caption, id LIMIT 5001`,
)
).map((row) => ({ id: Number(row.id), caption: row.caption }));
if (pages.length > 5000)
throw new CatalogInputError(
"The catalog has more than 5000 categories. Use bulk editing from a category instead.",
@@ -10,7 +10,8 @@ const state = vi.hoisted(() => ({
audit: [] as unknown[],
queries: [] as string[],
}));
vi.mock("@/lib/db", () => ({
vi.mock("@/lib/db", async () => ({
rowsFrom: (await import("@/test/db-helpers")).rowsFrom,
db: {
transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
const saved = structuredClone(state.current);
@@ -79,8 +80,9 @@ vi.mock("@/lib/db", () => ({
},
}));
vi.mock("@/features/operations/server", async () => {
const { db } = await import("@/lib/db");
import { db } from "@/lib/db";
vi.mock("@/features/operations/server", () => {
return {
runOperation: (
_input: unknown,
@@ -27,7 +27,8 @@ const state = vi.hoisted(() => ({
vi.mock("@/lib/services/furni-import", () => ({
allocateCatalogItemId: async (fn: (id: number) => unknown) => fn(500),
}));
vi.mock("@/lib/db", () => ({
vi.mock("@/lib/db", async () => ({
rowsFrom: (await import("@/test/db-helpers")).rowsFrom,
db: {
transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
const saved = state.writes;
@@ -2,7 +2,7 @@ import "server-only";
import { createHash } from "node:crypto";
import { sql } from "drizzle-orm";
import { z } from "zod";
import { db } from "@/lib/db";
import { db, queryRows, rowsFrom } from "@/lib/db";
import { allocateCatalogItemId } from "@/lib/services/furni-import";
import {
type DuplicateInput,
@@ -28,10 +28,11 @@ function table(kind: DuplicateInput["kind"], offers = false) {
}
async function snapshot(tx: Transaction, input: DuplicateInput, lock: boolean) {
const suffix = lock ? sql` FOR UPDATE` : sql``;
const [rawPages] = await tx.execute(
sql`SELECT * FROM ${table(input.kind)} ORDER BY id LIMIT 20001${suffix}`,
const pages = rowsFrom<DuplicateRow>(
await tx.execute(
sql`SELECT * FROM ${table(input.kind)} ORDER BY id LIMIT 20001${suffix}`,
),
);
const pages = rawPages as unknown as DuplicateRow[];
if (pages.length > 20000)
throw new CatalogInputError(
"Catalog exceeds duplication safety limit (20000 categories)",
@@ -42,21 +43,23 @@ async function snapshot(tx: Transaction, input: DuplicateInput, lock: boolean) {
for (const page of subtree)
if (input.kind === "normal")
remapIncludes(page.includes, new Map(), existing);
const [rawOffers] = await tx.execute(
sql`SELECT * FROM ${table(input.kind, true)} WHERE page_id IN (${sql.join(ids.map(String), sql`, `)}) ORDER BY id LIMIT 5001${suffix}`,
const offers = rowsFrom<DuplicateRow>(
await tx.execute(
sql`SELECT * FROM ${table(input.kind, true)} WHERE page_id IN (${sql.join(ids.map(String), sql`, `)}) ORDER BY id LIMIT 5001${suffix}`,
),
);
const offers = rawOffers as unknown as DuplicateRow[];
if (offers.length > 5000)
throw new CatalogInputError("Duplicate at most 5000 offers at a time");
const furniture = [
...new Set(offers.flatMap((o) => parseFurnitureIds(String(o.item_ids)))),
].sort((a, b) => a - b);
if (furniture.length) {
const [rows] = await tx.execute(
sql`SELECT id FROM items_base WHERE id IN (${sql.join(furniture, sql`, `)}) ORDER BY id${suffix}`,
);
const found = new Set(
(rows as unknown as { id: number }[]).map((p) => Number(p.id)),
rowsFrom<{ id: number }>(
await tx.execute(
sql`SELECT id FROM items_base WHERE id IN (${sql.join(furniture, sql`, `)}) ORDER BY id${suffix}`,
),
).map((p) => Number(p.id)),
);
if (furniture.some((id) => !found.has(id)))
throw new CatalogInputError("Furniture reference not found");
@@ -134,9 +137,7 @@ export async function duplicateCategoryCommand(
sql`, `,
)}`,
);
const id = Number(
row.id ?? (result as unknown as { insertId: number }).insertId,
);
const id = Number(row.id ?? result.insertId);
if (!id) throw Error("Could not allocate duplicate category ID");
return id;
};
@@ -195,10 +196,9 @@ export async function duplicateDestinationsCommand(
kind: DuplicateInput["kind"],
) {
z.enum(["normal", "bc"]).parse(kind);
const [rows] = await db.execute(
const pages = await queryRows<{ id: number; caption: string }>(
sql`SELECT id, caption FROM ${table(kind)} ORDER BY caption, id LIMIT 20001`,
);
const pages = rows as unknown as { id: number; caption: string }[];
if (pages.length > 20000)
throw new CatalogInputError(
"Catalog exceeds duplication safety limit (20000 categories)",
+9 -9
View File
@@ -1,7 +1,7 @@
import "server-only";
import { sql } from "drizzle-orm";
import { db } from "@/lib/db";
import { queryRows } from "@/lib/db";
/**
* Locate the auto-created "Imported Furniture" tree (caption_save
@@ -10,26 +10,26 @@ import { db } from "@/lib/db";
* knows exactly which furniture is "inbox" work.
*/
export async function getImportedCategoryPageIds(): Promise<number[]> {
const [rows] = (await db.execute(sql`
const rows = await queryRows<{ id: number }>(sql`
SELECT id FROM catalog_pages
WHERE caption_save = 'imported_furni'
AND parent_id IN (-1, 0)
LIMIT 1
`)) as unknown as [Array<{ id: number }>, unknown];
`);
const rootId = Number(rows[0]?.id ?? 0);
if (!rootId) return [];
const [childRows] = (await db.execute(sql`
const childRows = await queryRows<{ id: number }>(sql`
SELECT id FROM catalog_pages WHERE parent_id = ${rootId}
`)) as unknown as [Array<{ id: number }>, unknown];
`);
const children = childRows.map((r) => Number(r.id));
const pageIds = [rootId, ...children];
if (children.length > 0) {
const [grandRows] = (await db.execute(sql`
const grandRows = await queryRows<{ id: number }>(sql`
SELECT id FROM catalog_pages
WHERE parent_id IN (${sql.join(children, sql`, `)})
`)) as unknown as [Array<{ id: number }>, unknown];
`);
pageIds.push(...grandRows.map((r) => Number(r.id)));
}
return pageIds;
@@ -37,11 +37,11 @@ export async function getImportedCategoryPageIds(): Promise<number[]> {
/** Caption of the imported-furniture root page, when present. */
export async function getImportedRootCaption(): Promise<string | null> {
const [rows] = (await db.execute(sql`
const rows = await queryRows<{ caption: string }>(sql`
SELECT caption FROM catalog_pages
WHERE caption_save = 'imported_furni'
AND parent_id IN (-1, 0)
LIMIT 1
`)) as unknown as [Array<{ caption: string }>, unknown];
`);
return rows[0]?.caption ?? null;
}
@@ -16,6 +16,7 @@ const state = vi.hoisted(() => ({
rollbacks: 0,
}));
vi.mock("@/lib/db", async () => ({
rowsFrom: (await import("@/test/db-helpers")).rowsFrom,
...(await import("@/db/schema")),
db: {
transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
+36 -25
View File
@@ -2,7 +2,13 @@ import "server-only";
import { getTableColumns, type SQL, sql } from "drizzle-orm";
import { z } from "zod";
import { historySnapshot, recordHistory } from "@/features/history/server";
import { CatalogItems, CatalogItemsBc, db, ItemsBase } from "@/lib/db";
import {
CatalogItems,
CatalogItemsBc,
db,
ItemsBase,
rowsFrom,
} from "@/lib/db";
import { CatalogInputError } from "../domain/hierarchy";
import {
distinctOfferIds,
@@ -31,13 +37,14 @@ async function lockedOffers(
ids: number[],
kind: OfferKind = "normal",
): Promise<OfferRow[]> {
const [rows] = await tx.execute(
sql`SELECT id, page_id AS pageId, item_ids AS itemIds FROM ${offerTable(kind)} WHERE id IN (${sql.join(
[...ids].sort((a, b) => a - b),
sql`, `,
)}) ORDER BY id FOR UPDATE`,
const offers = rowsFrom<OfferRow>(
await tx.execute(
sql`SELECT id, page_id AS pageId, item_ids AS itemIds FROM ${offerTable(kind)} WHERE id IN (${sql.join(
[...ids].sort((a, b) => a - b),
sql`, `,
)}) ORDER BY id FOR UPDATE`,
),
);
const offers = rows as unknown as OfferRow[];
if (
offers.length !== ids.length ||
ids.some((id) => !offers.some((offer) => Number(offer.id) === id))
@@ -51,21 +58,23 @@ async function lockPage(
kind: OfferKind = "normal",
) {
offerTable(kind);
const [rows] = await tx.execute(
sql`SELECT id FROM ${sql.identifier(kind === "bc" ? "catalog_pages_bc" : "catalog_pages")} WHERE id=${id} FOR UPDATE`,
);
if (
!(rows as unknown as { id: number }[]).some((row) => Number(row.id) === id)
!rowsFrom<{ id: number }>(
await tx.execute(
sql`SELECT id FROM ${sql.identifier(kind === "bc" ? "catalog_pages_bc" : "catalog_pages")} WHERE id=${id} FOR UPDATE`,
),
).some((row) => Number(row.id) === id)
)
throw Error("Catalog page not found");
}
async function lockFurniture(tx: Transaction, ids: number[]) {
const unique = [...new Set(ids)].sort((a, b) => a - b);
const [rows] = await tx.execute(
sql`SELECT id FROM items_base WHERE id IN (${sql.join(unique, sql`, `)}) ORDER BY id FOR UPDATE`,
);
const found = new Set(
(rows as unknown as { id: number }[]).map((row) => Number(row.id)),
rowsFrom<{ id: number }>(
await tx.execute(
sql`SELECT id FROM items_base WHERE id IN (${sql.join(unique, sql`, `)}) ORDER BY id FOR UPDATE`,
),
).map((row) => Number(row.id)),
);
if (unique.some((id) => !found.has(id)))
throw Error("Furniture reference not found");
@@ -98,10 +107,13 @@ async function assertOrderUnique(
orderNumber: number,
excludeId: number,
) {
const [dups] = await tx.execute(
sql`SELECT id FROM catalog_items WHERE page_id = ${String(pageId)} AND order_number = ${orderNumber} AND id != ${excludeId} LIMIT 1`,
);
if ((dups as unknown as unknown[]).length > 0)
if (
rowsFrom<unknown>(
await tx.execute(
sql`SELECT id FROM catalog_items WHERE page_id = ${String(pageId)} AND order_number = ${orderNumber} AND id != ${excludeId} LIMIT 1`,
),
).length > 0
)
throw new CatalogInputError(
`Order number ${orderNumber} is already used by another offer on this page`,
);
@@ -156,11 +168,10 @@ export async function updateOfferCommand(
fields.limitedStack !== undefined ||
fields.limitedSells !== undefined
) {
const [current] = await tx.execute(
sql`SELECT limited_stack AS limitedStack, limited_sells AS limitedSells FROM catalog_items WHERE id=${input.id}`,
);
const row = (
current as unknown as { limitedStack: number; limitedSells: number }[]
const row = rowsFrom<{ limitedStack: number; limitedSells: number }>(
await tx.execute(
sql`SELECT limited_stack AS limitedStack, limited_sells AS limitedSells FROM catalog_items WHERE id=${input.id}`,
),
)[0];
if (row) {
const stack = fields.limitedStack ?? Number(row.limitedStack);
@@ -267,7 +278,7 @@ export async function createBcOfferCommand(
const [result] = await tx.execute(
sql`INSERT INTO ${CatalogItemsBc} SET ${sql.join(assignments(CatalogItemsBc, fields), sql`, `)}`,
);
const id = Number((result as unknown as { insertId: number }).insertId);
const id = Number(result.insertId);
offerIdSchema.parse(id);
return id;
});
+14 -10
View File
@@ -2,6 +2,7 @@ import "server-only";
import { createHash } from "node:crypto";
import { sql } from "drizzle-orm";
import type { db } from "@/lib/db";
import { rowsFrom } from "@/lib/db";
import {
assertParent,
CatalogInputError,
@@ -84,10 +85,11 @@ export async function readPackagePages(
tx: PackageTx,
lock: boolean,
): Promise<SourceRow[]> {
const [rows] = await tx.execute(
sql`SELECT * FROM catalog_pages ORDER BY id LIMIT 20001${lock ? sql` FOR UPDATE` : sql``}`,
const pages = rowsFrom<SourceRow>(
await tx.execute(
sql`SELECT * FROM catalog_pages ORDER BY id LIMIT 20001${lock ? sql` FOR UPDATE` : sql``}`,
),
);
const pages = rows as unknown as SourceRow[];
if (pages.length > 20000)
throw new CatalogInputError(
"Catalog exceeds package safety limit (20000 categories)",
@@ -128,10 +130,11 @@ export async function readPackageSource(
}
const selectedPages = pages.filter((p) => selected.has(Number(p.id)));
const ids = selectedPages.map((p) => String(p.id));
const [rows] = await tx.execute(
sql`SELECT * FROM catalog_items WHERE page_id IN (${sql.join(ids, sql`, `)}) ORDER BY id LIMIT 501${lock ? sql` FOR UPDATE` : sql``}`,
const offers = rowsFrom<SourceRow>(
await tx.execute(
sql`SELECT * FROM catalog_items WHERE page_id IN (${sql.join(ids, sql`, `)}) ORDER BY id LIMIT 501${lock ? sql` FOR UPDATE` : sql``}`,
),
);
const offers = rows as unknown as SourceRow[];
if (offers.length > 500)
throw new CatalogInputError("A package supports at most 500 offers");
const furnitureIds = [
@@ -142,11 +145,12 @@ export async function readPackageSource(
"Package furniture references exceed safety limit",
);
if (furnitureIds.length) {
const [foundRows] = await tx.execute(
sql`SELECT id FROM items_base WHERE id IN (${sql.join(furnitureIds, sql`, `)}) ORDER BY id${lock ? sql` FOR UPDATE` : sql``}`,
);
const found = new Set(
(foundRows as unknown as { id: number }[]).map((row) => Number(row.id)),
rowsFrom<{ id: number }>(
await tx.execute(
sql`SELECT id FROM items_base WHERE id IN (${sql.join(furnitureIds, sql`, `)}) ORDER BY id${lock ? sql` FOR UPDATE` : sql``}`,
),
).map((row) => Number(row.id)),
);
if (furnitureIds.some((id) => !found.has(id)))
throw new CatalogNotFound("Package furniture reference no longer exists");
+2 -1
View File
@@ -18,7 +18,8 @@ vi.mock("@/lib/services/furni-import", () => ({
allocateCatalogItemId: async (fn: (id: number) => unknown) =>
fn(state.nextOffer++),
}));
vi.mock("@/lib/db", () => ({
vi.mock("@/lib/db", async () => ({
rowsFrom: (await import("@/test/db-helpers")).rowsFrom,
db: {
transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
const before = structuredClone({
+11 -14
View File
@@ -2,7 +2,7 @@ import "server-only";
import { randomUUID } from "node:crypto";
import { sql } from "drizzle-orm";
import { historySnapshot, recordHistory } from "@/features/history/server";
import { db } from "@/lib/db";
import { db, queryRows, rowsFrom } from "@/lib/db";
import { allocateCatalogItemId } from "@/lib/services/furni-import";
import { remapIncludes } from "../domain/duplicate";
import {
@@ -55,10 +55,11 @@ async function read(
id: string,
lock = false,
): Promise<StoredPackage> {
const [rows] = await tx.execute(
sql`SELECT payload FROM website_catalog_packages WHERE id=${packageIdSchema.parse(id)}${lock ? sql` FOR UPDATE` : sql``}`,
);
const row = (rows as unknown as { payload: string }[])[0];
const row = rowsFrom<{ payload: string }>(
await tx.execute(
sql`SELECT payload FROM website_catalog_packages WHERE id=${packageIdSchema.parse(id)}${lock ? sql` FOR UPDATE` : sql``}`,
),
)[0];
if (!row) throw new CatalogNotFound("Catalog package not found");
return JSON.parse(row.payload) as StoredPackage;
}
@@ -75,14 +76,12 @@ async function write(tx: PackageTx, stored: StoredPackage, create = false) {
);
}
export async function listCatalogPackagesCommand(): Promise<PackageListRow[]> {
const [rows] = await db.execute(
const rows = await queryRows<
Omit<PackageListRow, "updatedAt"> & { updatedAt: Date | string }
>(
sql`SELECT id,name,version,status,mode,updated_at AS updatedAt FROM website_catalog_packages ORDER BY updated_at DESC,id LIMIT 100`,
);
return (
rows as unknown as Array<
Omit<PackageListRow, "updatedAt"> & { updatedAt: Date | string }
>
).map((row) => ({
return rows.map((row) => ({
...row,
version: Number(row.version),
updatedAt: new Date(row.updatedAt).toISOString(),
@@ -238,9 +237,7 @@ async function insertRow(
sql`, `,
)}`,
);
const id = Number(
row.id ?? (result as unknown as { insertId: number }).insertId,
);
const id = Number(row.id ?? result.insertId);
if (!Number.isSafeInteger(id) || id <= 0)
throw Error("Could not allocate package row ID");
return id;
@@ -15,6 +15,7 @@ const state = vi.hoisted(() => ({
rollbacks: 0,
}));
vi.mock("@/lib/db", async () => ({
rowsFrom: (await import("@/test/db-helpers")).rowsFrom,
...(await import("@/db/schema")),
db: {
transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
+12 -9
View File
@@ -1,7 +1,7 @@
import "server-only";
import { getTableColumns, type SQL, sql } from "drizzle-orm";
import { historySnapshot, recordHistory } from "@/features/history/server";
import { CatalogPages, CatalogPagesBc, db } from "@/lib/db";
import { CatalogPages, CatalogPagesBc, db, rowsFrom } from "@/lib/db";
import {
assertParent,
CatalogConflict,
@@ -29,10 +29,12 @@ async function lockedPages(
): Promise<HierarchyPage[]> {
const { pages } = tables(kind);
// Structural commands serialize against a stable lock order, including parent validation.
const [rows] = await tx.execute(
sql`SELECT id, parent_id AS parentId, order_num AS orderNum FROM ${pages} ORDER BY id FOR UPDATE`,
const rows = rowsFrom<HierarchyPage>(
await tx.execute(
sql`SELECT id, parent_id AS parentId, order_num AS orderNum FROM ${pages} ORDER BY id FOR UPDATE`,
),
);
return (rows as unknown as HierarchyPage[]).map((row) => ({
return rows.map((row) => ({
id: Number(row.id),
parentId: Number(row.parentId),
orderNum: Number(row.orderNum),
@@ -64,10 +66,11 @@ export async function updatePageCommand(
throw new CatalogNotFound("Catalog page not found");
if (expected) {
const columns = getTableColumns(tables(kind).pages);
const [existingRows] = await tx.execute(
sql`SELECT * FROM ${tables(kind).pages} WHERE id=${id} FOR UPDATE`,
);
const current = (existingRows as unknown as Record<string, unknown>[])[0];
const current = rowsFrom<Record<string, unknown>>(
await tx.execute(
sql`SELECT * FROM ${tables(kind).pages} WHERE id=${id} FOR UPDATE`,
),
)[0];
if (!current) throw new CatalogNotFound("Catalog page not found");
for (const [key, value] of Object.entries(expected)) {
if (!Object.hasOwn(data, key) || !Object.hasOwn(columns, key)) continue;
@@ -107,7 +110,7 @@ export async function createPageCommand(
const [result] = await tx.execute(
sql`INSERT INTO ${tables(kind).pages} SET ${sql.join(assignments, sql`, `)}`,
);
return Number((result as unknown as { insertId: number }).insertId);
return Number(result.insertId);
});
}
export async function reorderPagesCommand(
+8 -4
View File
@@ -9,8 +9,8 @@ const state = vi.hoisted(() => ({
offers: [] as unknown[],
parents: [] as unknown[],
}));
vi.mock("@/lib/db", () => ({
db: {
vi.mock("@/lib/db", async () => {
const db = {
execute: vi.fn(async (query: SQL) => {
const result = new MySqlDialect().sqlToQuery(query);
state.queries.push(result);
@@ -19,8 +19,12 @@ vi.mock("@/lib/db", () => ({
if (result.sql.includes("WHERE id IN")) return [state.parents];
return [state.categories];
}),
},
}));
};
return {
db,
...(await import("@/test/db-helpers")).createDbHelpers(db.execute),
};
});
import { searchCatalog } from "./search";
+9 -15
View File
@@ -1,6 +1,6 @@
import "server-only";
import { sql } from "drizzle-orm";
import { db } from "@/lib/db";
import { queryRows } from "@/lib/db";
import { type CatalogSearchResult, parseCatalogSearch } from "../domain/search";
const LIMIT = 25;
@@ -29,18 +29,15 @@ export async function searchCatalog(
);
const number = input.numericId;
const [categoryData, furnitureData] = await Promise.all([
db.execute(
queryRows<PageRow>(
sql`SELECT id, caption, parent_id AS parentId FROM ${pages} WHERE caption LIKE ${input.pattern} ESCAPE '=' ${number === null ? sql`` : sql`OR id=${number}`} ORDER BY ${number === null ? sql`id` : sql`CASE WHEN id=${number} THEN 0 ELSE 1 END, id`} LIMIT 12`,
),
db.execute(
queryRows<FurnitureRow>(
sql`SELECT id, item_name AS classname, sprite_id AS spriteId FROM items_base WHERE item_name LIKE ${input.pattern} ESCAPE '=' OR public_name LIKE ${input.pattern} ESCAPE '=' ${number === null ? sql`` : sql`OR id=${number} OR sprite_id=${number}`} ORDER BY ${number === null ? sql`id` : sql`CASE WHEN id=${number} THEN 0 ELSE 1 END, id`} LIMIT 25`,
),
]);
const categories = (categoryData[0] as unknown as PageRow[]).slice(0, 12);
const furniture = (furnitureData[0] as unknown as FurnitureRow[]).slice(
0,
LIMIT,
);
const categories = categoryData.slice(0, 12);
const furniture = furnitureData.slice(0, LIMIT);
const predicates = [sql`i.catalog_name LIKE ${input.pattern} ESCAPE '='`];
if (number !== null) {
predicates.push(sql`i.id=${number}`, sql`i.page_id=${String(number)}`);
@@ -51,13 +48,10 @@ export async function searchCatalog(
predicates.push(
sql`FIND_IN_SET(${String(base.id)}, REPLACE(i.item_ids, ';', ',')) > 0`,
);
const [offerData] = await db.execute(
const offerData = await queryRows<OfferRow>(
sql`SELECT i.id, i.catalog_name AS caption, i.page_id AS pageId, i.item_ids AS itemIds, p.caption AS pageCaption, p.parent_id AS parentId FROM ${offers} i INNER JOIN ${pages} p ON p.id=i.page_id WHERE ${sql.join(predicates, sql` OR `)} ORDER BY ${number === null ? sql`i.id` : sql`CASE WHEN i.id=${number} THEN 0 ELSE 1 END, i.id`} LIMIT ${LIMIT - categories.length}`,
);
const matches = (offerData as unknown as OfferRow[]).slice(
0,
LIMIT - categories.length,
);
const matches = offerData.slice(0, LIMIT - categories.length);
const pageMap = new Map<number, PageRow>();
for (const page of categories)
pageMap.set(Number(page.id), {
@@ -79,11 +73,11 @@ export async function searchCatalog(
.slice(0, LIMIT);
if (!ids.length) break;
for (const id of ids) attempted.add(id);
const [parents] = await db.execute(
const parents = await queryRows<PageRow>(
sql`SELECT id, caption, parent_id AS parentId FROM ${pages} WHERE id IN (${sql.join(ids, sql`, `)}) LIMIT 25`,
);
frontier = [];
for (const raw of parents as unknown as PageRow[]) {
for (const raw of parents) {
const page = {
...raw,
id: Number(raw.id),
+4 -1
View File
@@ -2,7 +2,10 @@ import type { SQL } from "drizzle-orm";
import { MySqlDialect } from "drizzle-orm/mysql-core";
import { expect, it, vi } from "vitest";
vi.mock("@/lib/db", () => ({ db: {} }));
vi.mock("@/lib/db", async () => ({
rowsFrom: (await import("@/test/db-helpers")).rowsFrom,
db: {},
}));
import { applyHistory, type HistoryTransaction } from "./server";
+5 -1
View File
@@ -2,7 +2,11 @@ import type { SQL } from "drizzle-orm";
import { MySqlDialect } from "drizzle-orm/mysql-core";
import { describe, expect, it, vi } from "vitest";
vi.mock("@/lib/db", async () => ({ ...(await import("@/db/schema")), db: {} }));
vi.mock("@/lib/db", async () => ({
...(await import("@/db/schema")),
rowsFrom: (await import("@/test/db-helpers")).rowsFrom,
db: {},
}));
import { historyChanges, sameSnapshot } from "./model";
import {
+11 -9
View File
@@ -7,7 +7,7 @@ import {
CatalogPagesBc,
WebsiteArticles,
} from "@/db/schema";
import type { db } from "@/lib/db";
import { type db, rowsFrom } from "@/lib/db";
import { getOperationContext } from "@/lib/foundation/request-context";
import { type HistorySnapshot, sameSnapshot } from "./model";
export type HistoryKind =
@@ -84,10 +84,11 @@ export async function historySnapshot(
(key) =>
sql`${sql.identifier(columns[key as keyof typeof columns].name)} AS ${sql.identifier(key)}`,
);
const [rows] = await tx.execute(
sql`SELECT ${sql.join(selected, sql`, `)} FROM ${table} WHERE id=${id} FOR UPDATE`,
);
const row = (rows as unknown as Record<string, unknown>[])[0];
const row = rowsFrom<Record<string, unknown>>(
await tx.execute(
sql`SELECT ${sql.join(selected, sql`, `)} FROM ${table} WHERE id=${id} FOR UPDATE`,
),
)[0];
if (!row) throw Error("unavailable");
return Object.fromEntries(
fields.map((key) => {
@@ -223,11 +224,12 @@ export async function lockOfferHistoryPages(
].sort((a, b) => a - b);
if (ids.some((id) => !Number.isSafeInteger(id) || id < 1))
throw Error("unavailable");
const [rows] = await tx.execute(
sql`SELECT id FROM catalog_pages WHERE id IN (${sql.join(ids, sql`, `)}) ORDER BY id FOR UPDATE`,
);
const found = new Set(
(rows as unknown as Array<{ id: number }>).map((row) => Number(row.id)),
rowsFrom<{ id: number }>(
await tx.execute(
sql`SELECT id FROM catalog_pages WHERE id IN (${sql.join(ids, sql`, `)}) ORDER BY id FOR UPDATE`,
),
).map((row) => Number(row.id)),
);
if (ids.some((id) => !found.has(id))) throw Error("unavailable");
}
@@ -2,7 +2,10 @@ import { MySqlDialect } from "drizzle-orm/mysql-core";
import { beforeEach, expect, it, vi } from "vitest";
const mocks = vi.hoisted(() => ({ execute: vi.fn(), error: vi.fn() }));
vi.mock("@/lib/db", () => ({ db: { execute: mocks.execute } }));
vi.mock("@/lib/db", async () => ({
...(await import("@/test/db-helpers")).createDbHelpers(mocks.execute),
db: { execute: mocks.execute },
}));
vi.mock("@/lib/logger", () => ({ logger: { error: mocks.error } }));
import { effectRepository, listEffects } from "./server";
+23 -26
View File
@@ -1,7 +1,7 @@
import "server-only";
import { randomUUID } from "node:crypto";
import { sql } from "drizzle-orm";
import { db } from "@/lib/db";
import { db, queryRows, rowsFrom } from "@/lib/db";
import { logger } from "@/lib/logger";
import { isDeliveryReference } from "./delivery-diagnostics";
import {
@@ -26,15 +26,14 @@ export async function runOperation<T>(
await tx.execute(
sql`INSERT INTO cms_operations (id,actor_id,kind,request_key,request_hash) VALUES (${randomUUID()},${input.actorId},${input.kind},${input.key},${hash}) ON DUPLICATE KEY UPDATE id=id`,
);
const [rows] = await tx.execute(
sql`SELECT id,request_hash AS requestHash,result_json AS resultJson FROM cms_operations WHERE actor_id=${input.actorId} AND kind=${input.kind} AND request_key=${input.key} FOR UPDATE`,
);
const row = (
rows as unknown as Array<{
id: string;
requestHash: string;
resultJson: string | null;
}>
const row = rowsFrom<{
id: string;
requestHash: string;
resultJson: string | null;
}>(
await tx.execute(
sql`SELECT id,request_hash AS requestHash,result_json AS resultJson FROM cms_operations WHERE actor_id=${input.actorId} AND kind=${input.kind} AND request_key=${input.key} FOR UPDATE`,
),
)[0];
if (!row) throw new Error("Operation unavailable");
if (row.requestHash !== hash) throw new OperationConflict();
@@ -61,16 +60,15 @@ export async function enqueueEffect(
export const effectRepository = {
async claim(): Promise<EffectClaim | null> {
return db.transaction(async (tx) => {
const [rows] = await tx.execute(
sql`SELECT id,operation_id AS operationId,topic,attempts FROM cms_outbox WHERE (status='pending' AND available_at<=UTC_TIMESTAMP(3)) OR (status='running' AND lease_until<UTC_TIMESTAMP(3)) ORDER BY available_at,id LIMIT 1 FOR UPDATE`,
);
const row = (
rows as unknown as Array<{
id: string;
operationId: string;
topic: EffectTopic;
attempts: number;
}>
const row = rowsFrom<{
id: string;
operationId: string;
topic: EffectTopic;
attempts: number;
}>(
await tx.execute(
sql`SELECT id,operation_id AS operationId,topic,attempts FROM cms_outbox WHERE (status='pending' AND available_at<=UTC_TIMESTAMP(3)) OR (status='running' AND lease_until<UTC_TIMESTAMP(3)) ORDER BY available_at,id LIMIT 1 FOR UPDATE`,
),
)[0];
if (!row) return null;
const token = randomUUID();
@@ -105,10 +103,7 @@ export const effectRepository = {
export async function listEffects(id?: string) {
if (id !== undefined && !isDeliveryReference(id)) return [];
const filter = id === undefined ? sql`` : sql`WHERE e.id=${id}`;
const [rows] = await db.execute(
sql`SELECT e.id,e.operation_id AS operationId,e.topic,e.status,e.attempts,e.last_error AS lastError,e.created_at AS createdAt,e.available_at AS availableAt,o.actor_id AS actorId,o.kind,o.result_json AS resultJson FROM cms_outbox e JOIN cms_operations o ON o.id=e.operation_id ${filter} ORDER BY e.created_at DESC,e.id DESC LIMIT 100`,
);
return rows as unknown as Array<{
return queryRows<{
id: string;
operationId: string;
topic: EffectTopic;
@@ -120,7 +115,9 @@ export async function listEffects(id?: string) {
resultJson: string | null;
actorId: number;
kind: string;
}>;
}>(
sql`SELECT e.id,e.operation_id AS operationId,e.topic,e.status,e.attempts,e.last_error AS lastError,e.created_at AS createdAt,e.available_at AS availableAt,o.actor_id AS actorId,o.kind,o.result_json AS resultJson FROM cms_outbox e JOIN cms_operations o ON o.id=e.operation_id ${filter} ORDER BY e.created_at DESC,e.id DESC LIMIT 100`,
);
}
export async function retryEffect(id: string) {
if (
@@ -130,6 +127,6 @@ export async function retryEffect(id: string) {
const [result] = await db.execute(
sql`UPDATE cms_outbox SET status='pending',attempts=0,available_at=UTC_TIMESTAMP(3),last_error=NULL WHERE id=${id} AND status='failed'`,
);
if ((result as unknown as { affectedRows: number }).affectedRows !== 1)
if (result.affectedRows !== 1)
throw new Error("Delivery is no longer available for retry");
}