fix(studio): harden organize-imports against long-running queries and hangs
CI / check (push) Successful in 2m33s
CI / deploy (push) Successful in 1m26s
CI / publish-container (push) Successful in 50s

The original GET route used a correlated NOT EXISTS / FIND_IN_SET
subquery over the entire catalog_items table for every recent import
audit entry, causing server timeouts when the audit log or catalog
grew large.  The per-item host-page validation inside the create
action also issued one SELECT + one UPDATE per moved offer.

Changes:
- GET /api/admin/import/organize: replace the correlated subquery
  with a bounded candidate list and a JS-side placed-set check, then
  resolve all needed base items in a single indexed SELECT.  This
  bounds the query cost regardless of catalog or audit log size.
- organizeImportFurni action: validate mover ids in one SELECT, then
  batch every move per group into a single UPDATE with a CASE
  expression instead of one UPDATE per item.
- OrganizeImportsDialog: add a 45-second abort timeout on the fetch
  and a distinct load-error state so the UI never silently hangs.
- Add 'loadError' translation key (en + nl).
This commit is contained in:
openhands committed 2026-09-11 20:24:38 +02:00
1 parent 01a85ebcd0
commit 19dc0347df
5 files changed
+181 -66

No files matched your search

+54 -15
View File
@@ -451,9 +451,37 @@ export async function organizeImportFurni(input: {
.where(inArray(ItemsBase.id, baseIds));
const baseMap = new Map(bases.map((b) => [b.id, b]));
// Verify every offer we're about to move still sits inside the import
// tree in ONE query, so we never hijack an unrelated catalog offer.
const moverIds = [
...new Set(
group.items
.map((r) => Number(r.catalogItemId))
.filter((n) => Number.isFinite(n) && n > 0),
),
];
const validMoveIds = new Set<number>();
if (moverIds.length > 0) {
const [hostRows] = (await db.execute(sql`
SELECT id, page_id FROM catalog_items WHERE id IN (${sql.join(
moverIds.map((id) => sql`${id}`),
sql`, `,
)})
`)) as unknown as [Array<{ id: number; page_id: number }>, unknown];
for (const row of hostRows) {
if (importPageIds.has(Number(row.page_id))) {
validMoveIds.add(Number(row.id));
}
}
}
let nextOrder = 1;
let moved = 0;
let added = 0;
const moveCaseOrder: string[] = [];
const moveCaseName: string[] = [];
const moveIds: number[] = [];
for (const row of group.items) {
const base = baseMap.get(row.itemId);
if (!base) continue;
@@ -461,23 +489,21 @@ export async function organizeImportFurni(input: {
base.publicName || base.itemName || String(row.itemId);
if (row.catalogItemId) {
// Verify the offer currently sits inside the import tree before
// moving it, so we never hijack an unrelated catalog offer.
const [host] = (await db.execute(sql`
SELECT page_id FROM catalog_items WHERE id = ${row.catalogItemId}
`)) as unknown as [Array<{ page_id: number }>, unknown];
const hostPageId = Number(host[0]?.page_id ?? -1);
if (!importPageIds.has(hostPageId)) {
console.error(
`[organizeImportFurni] skipping offer #${row.catalogItemId} on non-import page #${hostPageId}`,
);
if (!validMoveIds.has(Number(row.catalogItemId))) {
// Offer no longer lives in the import tree (moved elsewhere or
// deleted) — skip it instead of duplicating it.
continue;
}
await db.execute(sql`
UPDATE catalog_items
SET page_id = ${pageId}, order_number = ${nextOrder}, catalog_name = ${catalogName}
WHERE id = ${row.catalogItemId}
`);
moveCaseOrder.push(
`WHEN ${Number(row.catalogItemId)} THEN ${nextOrder}`,
);
moveCaseName.push(
`WHEN ${Number(row.catalogItemId)} THEN '${catalogName.replace(
/['\\]/g,
"\\$&",
)}'`,
);
moveIds.push(Number(row.catalogItemId));
moved++;
} else {
await insertCatalogItemRow({
@@ -502,6 +528,19 @@ export async function organizeImportFurni(input: {
nextOrder++;
}
if (moveIds.length > 0) {
await db.execute(sql`
UPDATE catalog_items
SET page_id = ${pageId},
order_number = CASE id ${sql.raw(moveCaseOrder.join(" "))} END,
catalog_name = CASE id ${sql.raw(moveCaseName.join(" "))} END
WHERE id IN (${sql.join(
moveIds.map((id) => sql`${id}`),
sql`, `,
)})
`);
}
await logStaffActivity({
staffId: staff.id,
action: "catalog_page_create",