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