208 lines
7.0 KiB
TypeScript
208 lines
7.0 KiB
TypeScript
import "server-only";
|
|
import { createHash } from "node:crypto";
|
|
import { sql } from "drizzle-orm";
|
|
import { z } from "zod";
|
|
import { db } from "@/lib/db";
|
|
import { allocateCatalogItemId } from "@/lib/services/furni-import";
|
|
import {
|
|
type DuplicateInput,
|
|
type DuplicatePreview,
|
|
type DuplicateRow,
|
|
duplicateSubtree,
|
|
remapIncludes,
|
|
} from "../domain/duplicate";
|
|
import { CatalogConflict, CatalogInputError } from "../domain/hierarchy";
|
|
import { parseFurnitureIds } from "../domain/offer-input";
|
|
|
|
type Transaction = Parameters<Parameters<typeof db.transaction>[0]>[0];
|
|
const schema = z.object({
|
|
kind: z.enum(["normal", "bc"]),
|
|
sourceId: z.number().int().positive(),
|
|
parentId: z.number().int().min(-1),
|
|
name: z.string().trim().min(1).max(128),
|
|
});
|
|
function table(kind: DuplicateInput["kind"], offers = false) {
|
|
return sql.identifier(
|
|
`catalog_${offers ? "items" : "pages"}${kind === "bc" ? "_bc" : ""}`,
|
|
);
|
|
}
|
|
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 = rawPages as unknown as DuplicateRow[];
|
|
if (pages.length > 20000)
|
|
throw new CatalogInputError(
|
|
"Catalog exceeds duplication safety limit (20000 categories)",
|
|
);
|
|
const subtree = duplicateSubtree(pages, input.sourceId, input.parentId);
|
|
const ids = subtree.map((p) => Number(p.id));
|
|
const existing = new Set(pages.map((p) => Number(p.id)));
|
|
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 = 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)),
|
|
);
|
|
if (furniture.some((id) => !found.has(id)))
|
|
throw new CatalogInputError("Furniture reference not found");
|
|
}
|
|
const destination = pages.find((p) => Number(p.id) === input.parentId);
|
|
const fingerprint = createHash("sha256")
|
|
.update(
|
|
JSON.stringify({
|
|
input,
|
|
subtree,
|
|
offers,
|
|
destination,
|
|
siblings: pages.filter((page) =>
|
|
input.parentId <= 0
|
|
? Number(page.parent_id) <= 0
|
|
: Number(page.parent_id) === input.parentId,
|
|
),
|
|
}),
|
|
)
|
|
.digest("hex");
|
|
return {
|
|
pages,
|
|
subtree,
|
|
offers,
|
|
fingerprint,
|
|
destination: String(destination?.caption ?? "Root"),
|
|
};
|
|
}
|
|
export async function previewDuplicateCommand(
|
|
raw: DuplicateInput,
|
|
): Promise<DuplicatePreview> {
|
|
const input = schema.parse(raw);
|
|
return db.transaction(
|
|
async (tx) => {
|
|
const data = await snapshot(tx, input, false);
|
|
return {
|
|
...input,
|
|
fingerprint: data.fingerprint,
|
|
pages: data.subtree.length,
|
|
offers: data.offers.length,
|
|
destination: data.destination,
|
|
};
|
|
},
|
|
{ isolationLevel: "repeatable read" },
|
|
);
|
|
}
|
|
export async function duplicateCategoryCommand(
|
|
raw: DuplicatePreview,
|
|
): Promise<{ id: number; pages: number; offers: number }> {
|
|
const input = schema.parse(raw);
|
|
const expected = z
|
|
.string()
|
|
.regex(/^[a-f0-9]{64}$/)
|
|
.parse(raw.fingerprint);
|
|
const count = z.number().int().min(0).max(5000).parse(raw.offers);
|
|
// Reserve through the same allocator as regular imports before taking page locks.
|
|
// A database uniqueness conflict rolls back every copied row, including pages.
|
|
const reserved: number[] = [];
|
|
if (input.kind === "normal")
|
|
for (let i = 0; i < count; i++)
|
|
reserved.push(await allocateCatalogItemId(async (id) => id));
|
|
return db.transaction(
|
|
async (tx) => {
|
|
const data = await snapshot(tx, input, true);
|
|
if (data.fingerprint !== expected || data.offers.length !== count)
|
|
throw new CatalogConflict(
|
|
"Source or destination changed. Refresh the duplication preview.",
|
|
);
|
|
const ids = new Map<number, number>();
|
|
const insert = async (row: Record<string, unknown>, offers = false) => {
|
|
const entries = Object.entries(row).filter(([, v]) => v !== undefined);
|
|
const [result] = await tx.execute(
|
|
sql`INSERT INTO ${table(input.kind, offers)} SET ${sql.join(
|
|
entries.map(([k, v]) => sql`${sql.identifier(k)}=${v}`),
|
|
sql`, `,
|
|
)}`,
|
|
);
|
|
const id = Number(
|
|
row.id ?? (result as unknown as { insertId: number }).insertId,
|
|
);
|
|
if (!id) throw Error("Could not allocate duplicate category ID");
|
|
return id;
|
|
};
|
|
for (const source of data.subtree) {
|
|
const { id: oldId, ...row } = source;
|
|
const isRoot = Number(oldId) === input.sourceId;
|
|
row.parent_id = isRoot
|
|
? input.parentId
|
|
: ids.get(Number(source.parent_id));
|
|
if (isRoot) {
|
|
row.caption = input.name;
|
|
row.enabled = "0";
|
|
row.visible = "0";
|
|
if (input.kind === "normal")
|
|
row.caption_save = input.name.slice(0, 25);
|
|
}
|
|
if (input.kind === "normal") row.includes = "";
|
|
ids.set(Number(oldId), await insert(row));
|
|
}
|
|
const existing = new Set(data.pages.map((p) => Number(p.id)));
|
|
for (const source of data.subtree)
|
|
if (input.kind === "normal" && source.includes) {
|
|
const includes = remapIncludes(source.includes, ids, existing);
|
|
if (includes.length > 128)
|
|
throw new CatalogInputError(
|
|
"Remapped category references exceed 128 characters",
|
|
);
|
|
await tx.execute(
|
|
sql`UPDATE ${table(input.kind)} SET includes=${includes} WHERE id=${ids.get(Number(source.id))}`,
|
|
);
|
|
}
|
|
const offerIds = new Map(
|
|
data.offers.map((offer, index) => [Number(offer.id), reserved[index]]),
|
|
);
|
|
for (const [index, source] of data.offers.entries()) {
|
|
const { id: _id, ...row } = source;
|
|
row.page_id = String(ids.get(Number(source.page_id)));
|
|
if (input.kind === "normal") {
|
|
row.id = reserved[index];
|
|
if (Number(row.offer_id) > 0 && offerIds.has(Number(row.offer_id)))
|
|
row.offer_id = offerIds.get(Number(row.offer_id));
|
|
}
|
|
await insert(row, true);
|
|
}
|
|
return {
|
|
id: ids.get(input.sourceId) as number,
|
|
pages: data.subtree.length,
|
|
offers: data.offers.length,
|
|
};
|
|
},
|
|
{ isolationLevel: "repeatable read" },
|
|
);
|
|
}
|
|
|
|
export async function duplicateDestinationsCommand(
|
|
kind: DuplicateInput["kind"],
|
|
) {
|
|
z.enum(["normal", "bc"]).parse(kind);
|
|
const [rows] = await db.execute(
|
|
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)",
|
|
);
|
|
return pages.map((row) => ({ id: Number(row.id), caption: row.caption }));
|
|
}
|