perf(app): batch catalog updates, tune caches, add captcha env fallback
- catalog: apply bulk offer edits in a single transaction with one RCON refresh instead of N round-trips, virtualize the items table past 150 rows and cap the shop preview at 200 tiles - cache: lift the public cache TTLs and the boot warm-up into env-tunable constants (src/lib/perf-ttls.ts; CMS_*_TTL_SEC, CMS_WARMUP_ENABLED) - auth: resolve the captcha provider, site key and secret from CAPTCHA_PROVIDER, TURNSTILE_SITE_KEY and TURNSTILE_SECRET_KEY when the admin settings are unset; an explicit DB value (including "none") still wins, so Turnstile can be enabled per-deploy without touching the DB.
This commit is contained in:
1 parent
05c2706eaf
commit
9f024dc6ac
15 files changed
+529
-83
No files matched your search
@@ -31,7 +31,13 @@ export type UpdateOfferInput = {
|
||||
catalogFields: Record<string, unknown>;
|
||||
baseItem?: { id: number; fields: Record<string, unknown> };
|
||||
};
|
||||
type OfferRow = { id: number; pageId: number | string; itemIds: string };
|
||||
type OfferRow = {
|
||||
id: number;
|
||||
pageId: number | string;
|
||||
itemIds: string;
|
||||
limitedStack?: number | string;
|
||||
limitedSells?: number | string;
|
||||
};
|
||||
async function lockedOffers(
|
||||
tx: Transaction,
|
||||
ids: number[],
|
||||
@@ -39,7 +45,7 @@ async function lockedOffers(
|
||||
): Promise<OfferRow[]> {
|
||||
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(
|
||||
sql`SELECT id, page_id AS pageId, item_ids AS itemIds, limited_stack AS limitedStack, limited_sells AS limitedSells FROM ${offerTable(kind)} WHERE id IN (${sql.join(
|
||||
[...ids].sort((a, b) => a - b),
|
||||
sql`, `,
|
||||
)}) ORDER BY id FOR UPDATE`,
|
||||
@@ -193,6 +199,118 @@ export async function updateOfferCommand(
|
||||
await recordHistory(tx, "prices", input.id, userId, before);
|
||||
});
|
||||
}
|
||||
/**
|
||||
* Many offer patches in ONE transaction: the inline table editor's "Save All"
|
||||
* used to issue a transaction + lock + RCON refresh + audit row per item, which
|
||||
* took tens of seconds on a page with hundreds of edited rows. Lock ordering
|
||||
* matches {@link updateOfferCommand}: pages → offers → furniture. A failing row
|
||||
* aborts the whole batch (full rollback) rather than leaving a half-applied save.
|
||||
*/
|
||||
export async function bulkUpdateOffersCommand(
|
||||
updates: UpdateOfferInput[],
|
||||
userId?: number,
|
||||
) {
|
||||
if (!updates.length) throw Error("No valid fields to update");
|
||||
if (updates.length > 500) throw Error("Max 500 items per bulk update");
|
||||
const parsed = updates.map((input) => {
|
||||
offerIdSchema.parse(input.id);
|
||||
const fields = offerPatchSchema.parse(input.catalogFields);
|
||||
const baseFields = input.baseItem
|
||||
? furniturePatchSchema.parse(input.baseItem.fields)
|
||||
: {};
|
||||
if (!Object.values(fields).some((value) => value !== undefined))
|
||||
throw Error("No valid fields to update");
|
||||
return { input, fields, baseFields };
|
||||
});
|
||||
|
||||
const offerIds = distinctOfferIds(parsed.map((row) => row.input.id));
|
||||
const furnitureIds: number[] = [];
|
||||
for (const row of parsed) {
|
||||
if (row.fields.itemIds !== undefined)
|
||||
furnitureIds.push(...parseFurnitureIds(row.fields.itemIds));
|
||||
if (row.input.baseItem) furnitureIds.push(row.input.baseItem.id);
|
||||
}
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
for (const pageId of new Set(
|
||||
parsed.map((row) => row.fields.pageId).filter((id) => id !== undefined),
|
||||
))
|
||||
await lockPage(tx, pageId as number);
|
||||
const offers = await lockedOffers(tx, offerIds);
|
||||
const offerById = new Map(offers.map((offer) => [Number(offer.id), offer]));
|
||||
if (furnitureIds.length) await lockFurniture(tx, furnitureIds);
|
||||
|
||||
const beforeById = new Map<
|
||||
number,
|
||||
Awaited<ReturnType<typeof historySnapshot>>
|
||||
>();
|
||||
if (userId)
|
||||
for (const id of offerIds)
|
||||
beforeById.set(id, await historySnapshot(tx, "prices", id));
|
||||
|
||||
for (const row of parsed) {
|
||||
const offer = offerById.get(row.input.id);
|
||||
if (!offer) throw Error("Catalog item not found");
|
||||
if (row.input.baseItem) {
|
||||
const currentIds = String(offer.itemIds).split(";").map(Number);
|
||||
const nextIds =
|
||||
row.fields.itemIds === undefined
|
||||
? currentIds
|
||||
: parseFurnitureIds(row.fields.itemIds);
|
||||
if (
|
||||
!currentIds.includes(row.input.baseItem.id) ||
|
||||
!nextIds.includes(row.input.baseItem.id)
|
||||
)
|
||||
throw Error("Shared furniture must belong to the catalog offer");
|
||||
}
|
||||
if (
|
||||
row.fields.orderNumber !== undefined ||
|
||||
row.fields.pageId !== undefined
|
||||
) {
|
||||
const targetPageId = row.fields.pageId ?? offer.pageId;
|
||||
if (row.fields.orderNumber !== undefined)
|
||||
await assertOrderUnique(
|
||||
tx,
|
||||
targetPageId,
|
||||
row.fields.orderNumber,
|
||||
row.input.id,
|
||||
);
|
||||
}
|
||||
if (
|
||||
row.fields.limitedStack !== undefined ||
|
||||
row.fields.limitedSells !== undefined
|
||||
) {
|
||||
const stack = row.fields.limitedStack ?? 0;
|
||||
const sells = row.fields.limitedSells ?? 0;
|
||||
const resolvedStack =
|
||||
row.fields.limitedStack !== undefined
|
||||
? stack
|
||||
: Number(offer.limitedStack ?? 0);
|
||||
const resolvedSells =
|
||||
row.fields.limitedSells !== undefined
|
||||
? sells
|
||||
: Number(offer.limitedSells ?? 0);
|
||||
assertLimitedStackOk(resolvedStack, resolvedSells);
|
||||
}
|
||||
const offerAssignments = assignments(CatalogItems, row.fields);
|
||||
if (offerAssignments.length)
|
||||
await tx.execute(
|
||||
sql`UPDATE ${CatalogItems} SET ${sql.join(offerAssignments, sql`, `)} WHERE id=${row.input.id}`,
|
||||
);
|
||||
const baseAssignments = assignments(ItemsBase, row.baseFields);
|
||||
if (row.input.baseItem && baseAssignments.length)
|
||||
await tx.execute(
|
||||
sql`UPDATE ${ItemsBase} SET ${sql.join(baseAssignments, sql`, `)} WHERE id=${row.input.baseItem.id}`,
|
||||
);
|
||||
}
|
||||
if (userId)
|
||||
for (const id of offerIds) {
|
||||
const before = beforeById.get(id);
|
||||
if (before) await recordHistory(tx, "prices", id, userId, before);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function reorderOffersCommand(
|
||||
orders: Array<{ id: number; orderNumber: number }>,
|
||||
) {
|
||||
|
||||
Reference in new issue
Block a user