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:
openhands committed 2026-10-15 16:00:00 +02:00
1 parent 05c2706eaf
commit 9f024dc6ac
15 files changed
+529 -83

No files matched your search

+120 -2
View File
@@ -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 }>,
) {