feat(catalog): add saved packages, preview and bulk price editing
CI / check (push) Successful in 52s
CI / deploy (push) Successful in 1m23s
CI / publish-container (push) Failing after 27s

This commit is contained in:
Simo committed 2026-09-09 20:04:10 +02:00
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");
});
+428
View File
@@ -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;
}
}
}