feat(catalog): add saved packages, preview and bulk price editing
This commit is contained in:
1 parent
867113d5d4
commit
047cd9f3dd
48 files changed
+7712
No files matched your search
@@ -0,0 +1,30 @@
|
||||
import "server-only";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
|
||||
const messages: Record<string, string> = {
|
||||
"Source categories or offers changed since this package was created. Create a fresh package to include the current data.":
|
||||
"sourceChanged",
|
||||
"Package changed. Reload it before saving": "saveConflict",
|
||||
"Package changed. Refresh the publication preview": "publishConflict",
|
||||
"Catalog changed. Refresh the publication preview": "catalogChanged",
|
||||
"Catalog package not found": "missing",
|
||||
"A package supports at most 200 categories including descendants":
|
||||
"pageLimit",
|
||||
"A package supports at most 500 offers": "offerLimit",
|
||||
"Package data exceeds 8 MB safety limit": "payloadLimit",
|
||||
"Package membership cannot change": "membership",
|
||||
"Package hierarchy cannot change": "hierarchy",
|
||||
"Package furniture references cannot change": "references",
|
||||
"Package furniture reference no longer exists": "furnitureMissing",
|
||||
"Package published, but audit logging failed.": "auditWarning",
|
||||
"Package published, but the hotel update was not delivered.": "hotelWarning",
|
||||
"Package published, but export scheduling failed.": "exportWarning",
|
||||
"Package published. Refresh the page to see the latest catalog.":
|
||||
"refreshWarning",
|
||||
};
|
||||
export async function packageMessage(message: string): Promise<string> {
|
||||
const key = messages[message];
|
||||
if (!key) return message;
|
||||
const t = await getTranslations("pages.admin.catalogPackages.backend");
|
||||
return t(key);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import "server-only";
|
||||
import { createHash } from "node:crypto";
|
||||
import { sql } from "drizzle-orm";
|
||||
import type { db } from "@/lib/db";
|
||||
import {
|
||||
assertParent,
|
||||
CatalogInputError,
|
||||
CatalogNotFound,
|
||||
} from "../domain/hierarchy";
|
||||
import { parseFurnitureIds } from "../domain/offer-input";
|
||||
import type { PackageSnapshot } from "../domain/packages";
|
||||
import { packageSnapshotSchema } from "../domain/packages";
|
||||
export type PackageTx = Parameters<Parameters<typeof db.transaction>[0]>[0];
|
||||
export type SourceRow = Record<string, unknown> & { id: number };
|
||||
export interface PackageSource {
|
||||
pages: SourceRow[];
|
||||
offers: SourceRow[];
|
||||
}
|
||||
export function packageHash(value: unknown): string {
|
||||
const canonical = (v: unknown): unknown => {
|
||||
if (Array.isArray(v)) return v.map(canonical);
|
||||
if (v && typeof v === "object")
|
||||
return Object.fromEntries(
|
||||
Object.entries(v)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([k, val]) => [k, canonical(val)]),
|
||||
);
|
||||
return v;
|
||||
};
|
||||
return createHash("sha256")
|
||||
.update(JSON.stringify(canonical(JSON.parse(JSON.stringify(value)))))
|
||||
.digest("hex");
|
||||
}
|
||||
export const pageColumns = {
|
||||
parentId: "parent_id",
|
||||
caption: "caption",
|
||||
enabled: "enabled",
|
||||
visible: "visible",
|
||||
iconImage: "icon_image",
|
||||
minRank: "min_rank",
|
||||
clubOnly: "club_only",
|
||||
vipOnly: "vip_only",
|
||||
orderNum: "order_num",
|
||||
pageLayout: "page_layout",
|
||||
} as const;
|
||||
export const offerColumns = {
|
||||
pageId: "page_id",
|
||||
catalogName: "catalog_name",
|
||||
itemIds: "item_ids",
|
||||
costCredits: "cost_credits",
|
||||
costPoints: "cost_points",
|
||||
pointsType: "points_type",
|
||||
amount: "amount",
|
||||
} as const;
|
||||
export function publicSnapshot(source: PackageSource): PackageSnapshot {
|
||||
return packageSnapshotSchema.parse({
|
||||
pages: source.pages.map((p) => ({
|
||||
id: Number(p.id),
|
||||
parentId: Number(p.parent_id),
|
||||
caption: String(p.caption),
|
||||
enabled: String(p.enabled),
|
||||
visible: String(p.visible),
|
||||
iconImage: Number(p.icon_image),
|
||||
minRank: Number(p.min_rank),
|
||||
clubOnly: String(p.club_only),
|
||||
vipOnly: String(p.vip_only),
|
||||
orderNum: Number(p.order_num),
|
||||
pageLayout: String(p.page_layout),
|
||||
})),
|
||||
offers: source.offers.map((o) => ({
|
||||
id: Number(o.id),
|
||||
pageId: Number(o.page_id),
|
||||
catalogName: String(o.catalog_name),
|
||||
clubOnly: String(o.club_only ?? "0"),
|
||||
itemIds: String(o.item_ids),
|
||||
costCredits: Number(o.cost_credits),
|
||||
costPoints: Number(o.cost_points),
|
||||
pointsType: Number(o.points_type),
|
||||
amount: Number(o.amount),
|
||||
})),
|
||||
});
|
||||
}
|
||||
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 = rows as unknown as SourceRow[];
|
||||
if (pages.length > 20000)
|
||||
throw new CatalogInputError(
|
||||
"Catalog exceeds package safety limit (20000 categories)",
|
||||
);
|
||||
return pages;
|
||||
}
|
||||
export async function readPackageSource(
|
||||
tx: PackageTx,
|
||||
pages: SourceRow[],
|
||||
rootIds: number[],
|
||||
lock: boolean,
|
||||
): Promise<PackageSource> {
|
||||
const hierarchy = pages.map((p) => ({
|
||||
id: Number(p.id),
|
||||
parentId: Number(p.parent_id),
|
||||
}));
|
||||
const selected = new Set(rootIds);
|
||||
for (const id of rootIds) {
|
||||
const page = hierarchy.find((p) => p.id === id);
|
||||
if (!page)
|
||||
throw new CatalogNotFound(
|
||||
`Package source category #${id} no longer exists`,
|
||||
);
|
||||
assertParent(hierarchy, id, page.parentId);
|
||||
}
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const page of hierarchy)
|
||||
if (selected.has(page.parentId) && !selected.has(page.id)) {
|
||||
selected.add(page.id);
|
||||
changed = true;
|
||||
}
|
||||
if (selected.size > 200)
|
||||
throw new CatalogInputError(
|
||||
"A package supports at most 200 categories including descendants",
|
||||
);
|
||||
}
|
||||
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 = rows as unknown as SourceRow[];
|
||||
if (offers.length > 500)
|
||||
throw new CatalogInputError("A package supports at most 500 offers");
|
||||
const furnitureIds = [
|
||||
...new Set(offers.flatMap((o) => parseFurnitureIds(String(o.item_ids)))),
|
||||
].sort((a, b) => a - b);
|
||||
if (furnitureIds.length > 5000)
|
||||
throw new CatalogInputError(
|
||||
"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)),
|
||||
);
|
||||
if (furnitureIds.some((id) => !found.has(id)))
|
||||
throw new CatalogNotFound("Package furniture reference no longer exists");
|
||||
}
|
||||
return { pages: selectedPages, offers };
|
||||
}
|
||||
export function columns(
|
||||
row: Record<string, unknown>,
|
||||
mapping: Record<string, string>,
|
||||
): Record<string, unknown> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(mapping).map(([key, column]) => [column, row[key]]),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,367 @@
|
||||
import type { SQL } from "drizzle-orm";
|
||||
import { MySqlDialect } from "drizzle-orm/mysql-core";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const state = vi.hoisted(() => ({
|
||||
pages: [] as Record<string, unknown>[],
|
||||
offers: [] as Record<string, unknown>[],
|
||||
stored: {} as Record<string, string>,
|
||||
queries: [] as string[],
|
||||
liveWrites: 0,
|
||||
failAt: 0,
|
||||
duplicateAt: 0,
|
||||
nextPage: 200,
|
||||
nextOffer: 500,
|
||||
bases: [8],
|
||||
}));
|
||||
vi.mock("@/lib/services/furni-import", () => ({
|
||||
allocateCatalogItemId: async (fn: (id: number) => unknown) =>
|
||||
fn(state.nextOffer++),
|
||||
}));
|
||||
vi.mock("@/lib/db", () => ({
|
||||
db: {
|
||||
transaction: async (fn: (tx: unknown) => Promise<unknown>) => {
|
||||
const before = structuredClone({
|
||||
pages: state.pages,
|
||||
offers: state.offers,
|
||||
stored: state.stored,
|
||||
});
|
||||
try {
|
||||
return await fn({
|
||||
execute: async (q: SQL) => {
|
||||
const { sql, params } = new MySqlDialect().sqlToQuery(q);
|
||||
state.queries.push(sql);
|
||||
if (sql.startsWith("SELECT")) {
|
||||
if (sql.includes("website_catalog_packages"))
|
||||
return [
|
||||
[
|
||||
...(state.stored[String(params[0])]
|
||||
? [{ payload: state.stored[String(params[0])] }]
|
||||
: []),
|
||||
],
|
||||
[],
|
||||
];
|
||||
if (sql.includes("FROM catalog_pages"))
|
||||
return [structuredClone(state.pages), []];
|
||||
if (sql.includes("FROM catalog_items"))
|
||||
return [
|
||||
structuredClone(
|
||||
state.offers.filter((o) =>
|
||||
params.map(String).includes(String(o.page_id)),
|
||||
),
|
||||
),
|
||||
[],
|
||||
];
|
||||
if (sql.includes("items_base"))
|
||||
return [state.bases.map((id) => ({ id })), []];
|
||||
}
|
||||
if (sql.startsWith("INSERT INTO website_catalog_packages")) {
|
||||
state.stored[String(params[0])] = String(params[5]);
|
||||
return [{}, []];
|
||||
}
|
||||
if (sql.startsWith("UPDATE website_catalog_packages")) {
|
||||
state.stored[String(params[4])] = String(params[3]);
|
||||
return [{}, []];
|
||||
}
|
||||
if (sql.startsWith("INSERT") || sql.startsWith("UPDATE")) {
|
||||
state.liveWrites++;
|
||||
if (state.duplicateAt === state.liveWrites)
|
||||
throw Object.assign(Error("reserved ID collision"), {
|
||||
errno: 1062,
|
||||
});
|
||||
if (state.failAt === state.liveWrites)
|
||||
throw Error("simulated SQL failure");
|
||||
const isPage = sql.includes("`catalog_pages`"),
|
||||
rows = isPage ? state.pages : state.offers;
|
||||
const keys = [...sql.matchAll(/`([^`]+)`=/g)].map((m) => m[1]);
|
||||
const fields = Object.fromEntries(
|
||||
keys.map((key, index) => [key, params[index]]),
|
||||
);
|
||||
if (sql.startsWith("INSERT")) {
|
||||
const id = Number(fields.id ?? state.nextPage++);
|
||||
rows.push({ ...fields, id });
|
||||
return [{ insertId: id }, []];
|
||||
}
|
||||
Object.assign(
|
||||
rows.find((row) => Number(row.id) === Number(params.at(-1))) ??
|
||||
{},
|
||||
fields,
|
||||
);
|
||||
return [{}, []];
|
||||
}
|
||||
throw Error(`Unexpected query ${sql}`);
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
Object.assign(state, before);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { validatePackageDraft } from "../domain/packages";
|
||||
import {
|
||||
createCatalogPackageCommand,
|
||||
getCatalogPackageCommand,
|
||||
previewCatalogPackageCommand,
|
||||
publishCatalogPackageCommand,
|
||||
saveCatalogPackageCommand,
|
||||
} from "./packages";
|
||||
|
||||
function page(id: number, parent_id: number) {
|
||||
return {
|
||||
id,
|
||||
parent_id,
|
||||
caption: `Page ${id}`,
|
||||
enabled: "1",
|
||||
visible: "1",
|
||||
icon_image: 1,
|
||||
min_rank: 1,
|
||||
club_only: "0",
|
||||
vip_only: "0",
|
||||
order_num: 1,
|
||||
page_layout: "default_3x3",
|
||||
includes: "",
|
||||
page_text1: "Keep source text",
|
||||
page_headline: "header",
|
||||
caption_save: "saved",
|
||||
};
|
||||
}
|
||||
beforeEach(() => {
|
||||
state.pages = [page(1, -1), page(2, 1), page(3, -1)];
|
||||
state.offers = [
|
||||
{
|
||||
id: 10,
|
||||
page_id: "2",
|
||||
catalog_name: "Chair",
|
||||
item_ids: "8",
|
||||
cost_credits: 3,
|
||||
cost_points: 0,
|
||||
points_type: 0,
|
||||
amount: 1,
|
||||
limited_stack: 40,
|
||||
limited_sells: 7,
|
||||
extradata: "preserve",
|
||||
offer_id: 10,
|
||||
},
|
||||
];
|
||||
state.stored = {};
|
||||
state.queries = [];
|
||||
state.liveWrites = 0;
|
||||
state.failAt = 0;
|
||||
state.duplicateAt = 0;
|
||||
state.nextPage = 200;
|
||||
state.nextOffer = 500;
|
||||
state.bases = [8];
|
||||
});
|
||||
const create = (mode: "update" | "copy" = "update") =>
|
||||
createCatalogPackageCommand({
|
||||
name: "Release",
|
||||
pageIds: [1],
|
||||
mode,
|
||||
targetParentId: -1,
|
||||
});
|
||||
async function publish(id: string, version: number) {
|
||||
const preview = await previewCatalogPackageCommand(id);
|
||||
return publishCatalogPackageCommand({
|
||||
id,
|
||||
version,
|
||||
fingerprint: preview.fingerprint,
|
||||
});
|
||||
}
|
||||
describe("catalog packages", () => {
|
||||
it("creates a private descendant snapshot and saves drafts without changing live rows", async () => {
|
||||
const p = await create();
|
||||
expect(p.baseline.pages.map((p) => p.id)).toEqual([1, 2]);
|
||||
expect(p.baseline.offers).toHaveLength(1);
|
||||
const saved = await saveCatalogPackageCommand({
|
||||
id: p.id,
|
||||
version: p.version,
|
||||
name: p.name,
|
||||
targetParentId: -1,
|
||||
draft: {
|
||||
...p.draft,
|
||||
offers: p.draft.offers.map((o) => ({ ...o, costCredits: 25 })),
|
||||
},
|
||||
});
|
||||
expect(saved.version).toBe(2);
|
||||
expect(state.liveWrites).toBe(0);
|
||||
expect(state.offers[0].cost_credits).toBe(3);
|
||||
});
|
||||
it("applies reviewed changes atomically and makes publication retry idempotent", async () => {
|
||||
const p = await create();
|
||||
p.draft.offers[0].costCredits = 25;
|
||||
const saved = await saveCatalogPackageCommand({
|
||||
id: p.id,
|
||||
version: 1,
|
||||
name: p.name,
|
||||
targetParentId: -1,
|
||||
draft: p.draft,
|
||||
});
|
||||
const preview = await previewCatalogPackageCommand(p.id);
|
||||
expect(preview.changedOffers).toBe(1);
|
||||
const input = {
|
||||
id: p.id,
|
||||
version: saved.version,
|
||||
fingerprint: preview.fingerprint,
|
||||
};
|
||||
const result = await publishCatalogPackageCommand(input);
|
||||
expect(result.alreadyPublished).toBe(false);
|
||||
expect(state.offers[0].cost_credits).toBe(25);
|
||||
const writes = state.liveWrites;
|
||||
expect((await publishCatalogPackageCommand(input)).alreadyPublished).toBe(
|
||||
true,
|
||||
);
|
||||
expect(state.liveWrites).toBe(writes);
|
||||
expect(
|
||||
state.queries.some(
|
||||
(q) => q.includes("catalog_pages") && q.includes("FOR UPDATE"),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
it("copies untouched source columns and remaps local page and offer references", async () => {
|
||||
state.pages[0].includes = "2";
|
||||
const p = await create("copy");
|
||||
const result = await publish(p.id, p.version);
|
||||
expect(result.pageIds).toEqual([200, 201]);
|
||||
expect(state.pages.find((p) => p.id === 200)).toMatchObject({
|
||||
parent_id: -1,
|
||||
includes: "201",
|
||||
page_text1: "Keep source text",
|
||||
caption_save: "saved",
|
||||
});
|
||||
expect(state.pages.find((p) => p.id === 201)?.parent_id).toBe(200);
|
||||
expect(state.offers.find((o) => o.id === 500)).toMatchObject({
|
||||
page_id: "201",
|
||||
offer_id: 500,
|
||||
item_ids: "8",
|
||||
limited_stack: 40,
|
||||
limited_sells: 7,
|
||||
extradata: "preserve",
|
||||
});
|
||||
});
|
||||
it("rolls back all category and offer changes plus package status on a SQL failure", async () => {
|
||||
const p = await create("copy");
|
||||
const preview = await previewCatalogPackageCommand(p.id);
|
||||
state.failAt = 2;
|
||||
await expect(
|
||||
publishCatalogPackageCommand({
|
||||
id: p.id,
|
||||
version: 1,
|
||||
fingerprint: preview.fingerprint,
|
||||
}),
|
||||
).rejects.toThrow("simulated SQL failure");
|
||||
expect(state.pages).toHaveLength(3);
|
||||
expect(state.offers).toHaveLength(1);
|
||||
expect((await getCatalogPackageCommand(p.id)).status).toBe("draft");
|
||||
});
|
||||
it.each([
|
||||
"field",
|
||||
"offerAdded",
|
||||
"offerMoved",
|
||||
"pageAdded",
|
||||
"pageMissing",
|
||||
"furnitureMissing",
|
||||
])("blocks changed live state: %s", async (change) => {
|
||||
const p = await create("copy"),
|
||||
preview = await previewCatalogPackageCommand(p.id);
|
||||
if (change === "field") state.offers[0].limited_stack = 99;
|
||||
if (change === "offerAdded")
|
||||
state.offers.push({ ...state.offers[0], id: 11 });
|
||||
if (change === "offerMoved") state.offers[0].page_id = "3";
|
||||
if (change === "pageAdded") state.pages.push(page(4, 1));
|
||||
if (change === "pageMissing")
|
||||
state.pages = state.pages.filter((p) => p.id !== 1);
|
||||
if (change === "furnitureMissing") state.bases = [];
|
||||
await expect(
|
||||
publishCatalogPackageCommand({
|
||||
id: p.id,
|
||||
version: 1,
|
||||
fingerprint: preview.fingerprint,
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
expect(state.liveWrites).toBe(0);
|
||||
});
|
||||
it("requires a fresh preview after destination changes", async () => {
|
||||
const p = await createCatalogPackageCommand({
|
||||
name: "Copy",
|
||||
pageIds: [1],
|
||||
mode: "copy",
|
||||
targetParentId: 3,
|
||||
}),
|
||||
preview = await previewCatalogPackageCommand(p.id);
|
||||
state.pages[2].min_rank = 99;
|
||||
await expect(
|
||||
publishCatalogPackageCommand({
|
||||
id: p.id,
|
||||
version: 1,
|
||||
fingerprint: preview.fingerprint,
|
||||
}),
|
||||
).rejects.toThrow("Refresh");
|
||||
expect(state.liveWrites).toBe(0);
|
||||
});
|
||||
it("rejects stale draft versions and rogue members or furniture references", async () => {
|
||||
const p = await create();
|
||||
const input = {
|
||||
id: p.id,
|
||||
version: 1,
|
||||
name: p.name,
|
||||
targetParentId: -1,
|
||||
draft: p.draft,
|
||||
};
|
||||
await saveCatalogPackageCommand(input);
|
||||
await expect(saveCatalogPackageCommand(input)).rejects.toThrow(
|
||||
"Package changed",
|
||||
);
|
||||
expect(() =>
|
||||
validatePackageDraft(p.baseline, {
|
||||
...p.draft,
|
||||
offers: [{ ...p.draft.offers[0], id: 99 }],
|
||||
}),
|
||||
).toThrow();
|
||||
expect(() =>
|
||||
validatePackageDraft(p.baseline, {
|
||||
...p.draft,
|
||||
offers: [{ ...p.draft.offers[0], itemIds: "99" }],
|
||||
}),
|
||||
).toThrow();
|
||||
});
|
||||
it("bounds descendants, offers and raw package payload", async () => {
|
||||
state.pages = Array.from({ length: 201 }, (_, i) =>
|
||||
page(i + 1, i ? 1 : -1),
|
||||
);
|
||||
await expect(create()).rejects.toThrow("200 categories");
|
||||
state.pages = [page(1, -1), page(2, 1)];
|
||||
state.offers = Array.from({ length: 501 }, (_, i) => ({
|
||||
...state.offers[0],
|
||||
id: i + 1,
|
||||
}));
|
||||
await expect(create()).rejects.toThrow("500 offers");
|
||||
state.offers = [];
|
||||
state.pages[0].page_text1 = "x".repeat(8_000_001);
|
||||
await expect(create()).rejects.toThrow("8 MB");
|
||||
});
|
||||
});
|
||||
|
||||
it("retries a rolled-back copy with fresh offer IDs after a concurrent allocation collision", async () => {
|
||||
const p = await create("copy");
|
||||
state.duplicateAt = 3;
|
||||
const result = await publish(p.id, 1);
|
||||
expect(result.alreadyPublished).toBe(false);
|
||||
expect(state.pages).toHaveLength(5);
|
||||
expect(state.offers).toHaveLength(2);
|
||||
expect(state.offers.at(-1)?.id).toBe(501);
|
||||
expect((await getCatalogPackageCommand(p.id)).status).toBe("published");
|
||||
});
|
||||
it("keeps offer membership access immutable in editable drafts", async () => {
|
||||
state.offers[0].club_only = "1";
|
||||
const p = await create();
|
||||
expect(p.baseline.offers[0].clubOnly).toBe("1");
|
||||
expect(() =>
|
||||
validatePackageDraft(p.baseline, {
|
||||
...p.draft,
|
||||
offers: [{ ...p.draft.offers[0], clubOnly: "0" }],
|
||||
}),
|
||||
).toThrow("references");
|
||||
});
|
||||
@@ -0,0 +1,428 @@
|
||||
import "server-only";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { sql } from "drizzle-orm";
|
||||
import { db } from "@/lib/db";
|
||||
import { allocateCatalogItemId } from "@/lib/services/furni-import";
|
||||
import { remapIncludes } from "../domain/duplicate";
|
||||
import {
|
||||
assertParent,
|
||||
CatalogConflict,
|
||||
CatalogInputError,
|
||||
CatalogNotFound,
|
||||
} from "../domain/hierarchy";
|
||||
import {
|
||||
type CatalogPackage,
|
||||
type CreateCatalogPackageInput,
|
||||
createPackageSchema,
|
||||
type PackageListRow,
|
||||
type PackagePreview,
|
||||
type PackagePublication,
|
||||
type PublishCatalogPackageInput,
|
||||
packageIdSchema,
|
||||
publishPackageSchema,
|
||||
type SaveCatalogPackageInput,
|
||||
savePackageSchema,
|
||||
validatePackageDraft,
|
||||
} from "../domain/packages";
|
||||
import {
|
||||
columns,
|
||||
offerColumns,
|
||||
type PackageSource,
|
||||
type PackageTx,
|
||||
packageHash,
|
||||
pageColumns,
|
||||
publicSnapshot,
|
||||
readPackagePages,
|
||||
readPackageSource,
|
||||
type SourceRow,
|
||||
} from "./package-snapshot";
|
||||
|
||||
interface StoredPackage {
|
||||
package: CatalogPackage;
|
||||
roots: number[];
|
||||
source: PackageSource;
|
||||
publication?: PackagePublication;
|
||||
}
|
||||
function encode(stored: StoredPackage) {
|
||||
const value = JSON.stringify(stored);
|
||||
if (Buffer.byteLength(value) > 8_000_000)
|
||||
throw new CatalogInputError("Package data exceeds 8 MB safety limit");
|
||||
return value;
|
||||
}
|
||||
async function read(
|
||||
tx: PackageTx,
|
||||
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];
|
||||
if (!row) throw new CatalogNotFound("Catalog package not found");
|
||||
return JSON.parse(row.payload) as StoredPackage;
|
||||
}
|
||||
async function write(tx: PackageTx, stored: StoredPackage, create = false) {
|
||||
const p = stored.package,
|
||||
payload = encode(stored);
|
||||
if (create)
|
||||
await tx.execute(
|
||||
sql`INSERT INTO website_catalog_packages (id,name,version,status,mode,payload,updated_at) VALUES (${p.id},${p.name},${p.version},${p.status},${p.mode},${payload},CURRENT_TIMESTAMP)`,
|
||||
);
|
||||
else
|
||||
await tx.execute(
|
||||
sql`UPDATE website_catalog_packages SET name=${p.name}, version=${p.version}, status=${p.status}, payload=${payload}, updated_at=CURRENT_TIMESTAMP WHERE id=${p.id}`,
|
||||
);
|
||||
}
|
||||
export async function listCatalogPackagesCommand(): Promise<PackageListRow[]> {
|
||||
const [rows] = await db.execute(
|
||||
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) => ({
|
||||
...row,
|
||||
version: Number(row.version),
|
||||
updatedAt: new Date(row.updatedAt).toISOString(),
|
||||
}));
|
||||
}
|
||||
export async function getCatalogPackageCommand(
|
||||
id: string,
|
||||
): Promise<CatalogPackage> {
|
||||
return db.transaction(async (tx) => (await read(tx, id)).package);
|
||||
}
|
||||
export async function createCatalogPackageCommand(
|
||||
raw: CreateCatalogPackageInput,
|
||||
): Promise<CatalogPackage> {
|
||||
const input = createPackageSchema.parse(raw);
|
||||
return db.transaction(
|
||||
async (tx) => {
|
||||
const roots = [...new Set(input.pageIds)].sort((a, b) => a - b);
|
||||
const pages = await readPackagePages(tx, false);
|
||||
assertParent(
|
||||
pages.map((p) => ({ id: Number(p.id), parentId: Number(p.parent_id) })),
|
||||
0,
|
||||
input.targetParentId,
|
||||
);
|
||||
const source = await readPackageSource(tx, pages, roots, false),
|
||||
baseline = publicSnapshot(source);
|
||||
const stored: StoredPackage = {
|
||||
package: {
|
||||
id: randomUUID(),
|
||||
name: input.name,
|
||||
version: 1,
|
||||
status: "draft",
|
||||
mode: input.mode,
|
||||
targetParentId: input.targetParentId,
|
||||
baseline,
|
||||
draft: structuredClone(baseline),
|
||||
updatedAt: new Date().toISOString(),
|
||||
publishedAt: null,
|
||||
},
|
||||
roots,
|
||||
source,
|
||||
};
|
||||
await write(tx, stored, true);
|
||||
return stored.package;
|
||||
},
|
||||
{ isolationLevel: "repeatable read" },
|
||||
);
|
||||
}
|
||||
export async function saveCatalogPackageCommand(
|
||||
raw: SaveCatalogPackageInput,
|
||||
): Promise<CatalogPackage> {
|
||||
const input = savePackageSchema.parse(raw);
|
||||
return db.transaction(async (tx) => {
|
||||
const stored = await read(tx, input.id, true),
|
||||
p = stored.package;
|
||||
if (p.status !== "draft" || p.version !== input.version)
|
||||
throw new CatalogConflict("Package changed. Reload it before saving");
|
||||
p.draft = validatePackageDraft(p.baseline, input.draft);
|
||||
p.name = input.name;
|
||||
p.targetParentId = input.targetParentId;
|
||||
p.version++;
|
||||
p.updatedAt = new Date().toISOString();
|
||||
await write(tx, stored);
|
||||
return p;
|
||||
});
|
||||
}
|
||||
async function review(
|
||||
tx: PackageTx,
|
||||
stored: StoredPackage,
|
||||
lock: boolean,
|
||||
): Promise<{
|
||||
preview: PackagePreview;
|
||||
source?: PackageSource;
|
||||
pages: SourceRow[];
|
||||
}> {
|
||||
const p = stored.package,
|
||||
conflicts: string[] = [];
|
||||
const pages = await readPackagePages(tx, lock);
|
||||
let source: PackageSource | undefined;
|
||||
try {
|
||||
validatePackageDraft(p.baseline, p.draft);
|
||||
source = await readPackageSource(tx, pages, stored.roots, lock);
|
||||
if (packageHash(source) !== packageHash(stored.source))
|
||||
conflicts.push(
|
||||
"Source categories or offers changed since this package was created. Create a fresh package to include the current data.",
|
||||
);
|
||||
assertParent(
|
||||
pages.map((row) => ({
|
||||
id: Number(row.id),
|
||||
parentId: Number(row.parent_id),
|
||||
})),
|
||||
0,
|
||||
p.targetParentId,
|
||||
);
|
||||
const existing = new Set(pages.map((row) => Number(row.id)));
|
||||
if (p.mode === "copy")
|
||||
for (const page of source.pages)
|
||||
remapIncludes(page.includes, new Map(), existing);
|
||||
} catch (error) {
|
||||
if (error instanceof CatalogInputError || error instanceof CatalogNotFound)
|
||||
conflicts.push(error.message);
|
||||
else throw error;
|
||||
}
|
||||
const destination = pages.find(
|
||||
(page) => Number(page.id) === p.targetParentId,
|
||||
);
|
||||
const count = (kind: "pages" | "offers") =>
|
||||
p.mode === "copy"
|
||||
? p.draft[kind].length
|
||||
: p.draft[kind].filter(
|
||||
(row) =>
|
||||
packageHash(row) !==
|
||||
packageHash(p.baseline[kind].find((base) => base.id === row.id)),
|
||||
).length;
|
||||
return {
|
||||
pages,
|
||||
source,
|
||||
preview: {
|
||||
version: p.version,
|
||||
fingerprint: packageHash({
|
||||
id: p.id,
|
||||
version: p.version,
|
||||
mode: p.mode,
|
||||
target: p.targetParentId,
|
||||
draft: p.draft,
|
||||
source,
|
||||
destination,
|
||||
conflicts,
|
||||
}),
|
||||
changedPages: count("pages"),
|
||||
changedOffers: count("offers"),
|
||||
conflicts,
|
||||
},
|
||||
};
|
||||
}
|
||||
export async function previewCatalogPackageCommand(
|
||||
id: string,
|
||||
): Promise<PackagePreview> {
|
||||
return db.transaction(
|
||||
async (tx) => (await review(tx, await read(tx, id), false)).preview,
|
||||
{ isolationLevel: "repeatable read" },
|
||||
);
|
||||
}
|
||||
async function insertRow(
|
||||
tx: PackageTx,
|
||||
table: string,
|
||||
row: Record<string, unknown>,
|
||||
): Promise<number> {
|
||||
const [result] = await tx.execute(
|
||||
sql`INSERT INTO ${sql.identifier(table)} SET ${sql.join(
|
||||
Object.entries(row)
|
||||
.filter(([, value]) => value !== undefined)
|
||||
.map(([key, value]) => sql`${sql.identifier(key)}=${value}`),
|
||||
sql`, `,
|
||||
)}`,
|
||||
);
|
||||
const id = Number(
|
||||
row.id ?? (result as unknown as { insertId: number }).insertId,
|
||||
);
|
||||
if (!Number.isSafeInteger(id) || id <= 0)
|
||||
throw Error("Could not allocate package row ID");
|
||||
return id;
|
||||
}
|
||||
async function updateRow(
|
||||
tx: PackageTx,
|
||||
table: string,
|
||||
id: number,
|
||||
row: Record<string, unknown>,
|
||||
) {
|
||||
await tx.execute(
|
||||
sql`UPDATE ${sql.identifier(table)} SET ${sql.join(
|
||||
Object.entries(row).map(
|
||||
([key, value]) => sql`${sql.identifier(key)}=${value}`,
|
||||
),
|
||||
sql`, `,
|
||||
)} WHERE id=${id}`,
|
||||
);
|
||||
}
|
||||
async function publishCatalogPackageAttempt(
|
||||
raw: PublishCatalogPackageInput,
|
||||
): Promise<PackagePublication> {
|
||||
const input = publishPackageSchema.parse(raw);
|
||||
const initial = await getCatalogPackageCommand(input.id);
|
||||
// Reserve offer identifiers outside catalog row locks, following the normal importer lock order.
|
||||
const reserved: number[] = [];
|
||||
if (initial.mode === "copy" && initial.status === "draft")
|
||||
for (let i = 0; i < initial.draft.offers.length; i++)
|
||||
reserved.push(await allocateCatalogItemId(async (id) => id));
|
||||
return db.transaction(
|
||||
async (tx) => {
|
||||
const stored = await read(tx, input.id, true),
|
||||
p = stored.package;
|
||||
if (p.status === "published" && stored.publication)
|
||||
return { ...stored.publication, alreadyPublished: true };
|
||||
if (p.version !== input.version)
|
||||
throw new CatalogConflict(
|
||||
"Package changed. Refresh the publication preview",
|
||||
);
|
||||
const { preview, source, pages } = await review(tx, stored, true);
|
||||
if (
|
||||
preview.conflicts.length ||
|
||||
preview.fingerprint !== input.fingerprint ||
|
||||
!source
|
||||
)
|
||||
throw new CatalogConflict(
|
||||
preview.conflicts.join(" ") ||
|
||||
"Catalog changed. Refresh the publication preview",
|
||||
);
|
||||
const pageIds: number[] = [];
|
||||
if (p.mode === "update") {
|
||||
for (const page of p.draft.pages) {
|
||||
await updateRow(
|
||||
tx,
|
||||
"catalog_pages",
|
||||
page.id,
|
||||
columns(page, pageColumns),
|
||||
);
|
||||
pageIds.push(page.id);
|
||||
}
|
||||
for (const offer of p.draft.offers)
|
||||
await updateRow(
|
||||
tx,
|
||||
"catalog_items",
|
||||
offer.id,
|
||||
columns(offer, offerColumns),
|
||||
);
|
||||
} else {
|
||||
if (reserved.length !== source.offers.length)
|
||||
throw new CatalogConflict(
|
||||
"Package changed. Refresh the publication preview",
|
||||
);
|
||||
const map = new Map<number, number>(),
|
||||
selected = new Set(source.pages.map((page) => Number(page.id)));
|
||||
const pending = [...p.draft.pages];
|
||||
while (pending.length) {
|
||||
const index = pending.findIndex(
|
||||
(page) => !selected.has(page.parentId) || map.has(page.parentId),
|
||||
);
|
||||
if (index < 0)
|
||||
throw new CatalogInputError("Package hierarchy contains a cycle");
|
||||
const [page] = pending.splice(index, 1),
|
||||
original = source.pages.find((row) => Number(row.id) === page.id);
|
||||
if (!original)
|
||||
throw new CatalogConflict(
|
||||
"Package source category no longer exists",
|
||||
);
|
||||
const { id: _id, ...row } = original;
|
||||
Object.assign(row, columns(page, pageColumns), {
|
||||
parent_id: map.get(page.parentId) ?? p.targetParentId,
|
||||
includes: "",
|
||||
});
|
||||
const id = await insertRow(tx, "catalog_pages", row);
|
||||
map.set(page.id, id);
|
||||
pageIds.push(id);
|
||||
}
|
||||
const existing = new Set(pages.map((page) => Number(page.id)));
|
||||
for (const page of source.pages)
|
||||
if (page.includes) {
|
||||
const includes = remapIncludes(page.includes, map, existing);
|
||||
if (includes.length > 128)
|
||||
throw new CatalogInputError(
|
||||
"Remapped included category references exceed 128 characters",
|
||||
);
|
||||
await updateRow(
|
||||
tx,
|
||||
"catalog_pages",
|
||||
Number(map.get(Number(page.id))),
|
||||
{
|
||||
includes,
|
||||
},
|
||||
);
|
||||
}
|
||||
const offerMap = new Map(
|
||||
source.offers.map((offer, index) => [
|
||||
Number(offer.id),
|
||||
reserved[index],
|
||||
]),
|
||||
);
|
||||
for (const offer of p.draft.offers) {
|
||||
const original = source.offers.find(
|
||||
(row) => Number(row.id) === offer.id,
|
||||
);
|
||||
if (!original)
|
||||
throw new CatalogConflict("Package source offer no longer exists");
|
||||
const row: Record<string, unknown> = {
|
||||
...original,
|
||||
...columns(offer, offerColumns),
|
||||
id: offerMap.get(offer.id),
|
||||
page_id: String(map.get(offer.pageId)),
|
||||
};
|
||||
if (
|
||||
Number(original.offer_id) > 0 &&
|
||||
offerMap.has(Number(original.offer_id))
|
||||
)
|
||||
row.offer_id = offerMap.get(Number(original.offer_id));
|
||||
await insertRow(tx, "catalog_items", row);
|
||||
}
|
||||
}
|
||||
const result: PackagePublication = {
|
||||
pageIds,
|
||||
offerCount: p.draft.offers.length,
|
||||
alreadyPublished: false,
|
||||
};
|
||||
p.status = "published";
|
||||
p.version++;
|
||||
p.publishedAt = new Date().toISOString();
|
||||
p.updatedAt = p.publishedAt;
|
||||
stored.publication = result;
|
||||
await write(tx, stored);
|
||||
return result;
|
||||
},
|
||||
{ isolationLevel: "repeatable read" },
|
||||
);
|
||||
}
|
||||
|
||||
/** An ID reserved by another process can race our process-local allocator. A collision rolls back the entire copy before retry. */
|
||||
export async function publishCatalogPackageCommand(
|
||||
input: PublishCatalogPackageInput,
|
||||
): Promise<PackagePublication> {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
return await publishCatalogPackageAttempt(input);
|
||||
} catch (error) {
|
||||
let cause: unknown = error,
|
||||
duplicate = false;
|
||||
for (
|
||||
let depth = 0;
|
||||
cause && typeof cause === "object" && depth < 5;
|
||||
depth++
|
||||
) {
|
||||
const record = cause as {
|
||||
code?: string;
|
||||
errno?: number;
|
||||
cause?: unknown;
|
||||
};
|
||||
if (record.code === "ER_DUP_ENTRY" || record.errno === 1062) {
|
||||
duplicate = true;
|
||||
break;
|
||||
}
|
||||
cause = record.cause;
|
||||
}
|
||||
if (!duplicate || attempt >= 2) throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user