WFDASH-WEBAPP/scripts/migrations/2026-08-04-add-order-items-item-status-index.ts
SJCoetz 720f01e5b6 perf(orders): server-side tab filter, slim expand payload, single combined count query
Three rounds of fixes for the /orders page:

#1 Correctness \u2014 server-side tab filter
- Add `tab` query param to /api/v1/orders/list; the route validates
  it against TAB_GROUP_ORDER and threads it to getOrdersDashboardData.
- listOrdersWithItems now resolves order ids for the tab via
  getOrderIdsForTabGroup (using the existing tabGroupFilter helper),
  then fetches just those orders. The footer pagination reflects
  the matching set ("Page 1 of N"), not the global 4520-order
  count ("Page 1 of 226"). The "Processing (4)" badge now
  matches what page 1 actually shows.
- Client: fetchOrders depends on activeTab, so switching tabs
  triggers a fresh server-side query. The redundant visibleOrders
  client filter is removed. The empty state now nudges the operator
  to busy tabs ("Busy tabs: New (12) \u00b7 Delivered (4)").

#2 Perf \u2014 combined counts + targeted users + slim deep expand
- getOrderItemStatusTabCounts collapses 6 parallel per-tab
  getFullList calls into ONE combined query, with batch: 5000.
  6 filter scans \u2192 1 scan; ~9x fewer internal round-trips.
  Bumped cache TTL 30s \u2192 60s.
- Replaced users.getFullList with fetchRepUsersForOrders \u2014 only
  fetches the user ids the loaded orders actually reference.
- Dropped potential_quote from the deep expand (list view only
  checks the field's existence).
- Added a migration script for the order_items.item_status index
  (scripts/migrations/2026-08-04-...). Idempotent: the live PB
  already has the index per the schema export, so the script
  no-ops on most installs; documented as the recommended manual
  step in case an older instance is missing it.

#3 Perf \u2014 wire-payload limits on deep expand
- The quotes collection has unbounded text fields (email_body,
  email_message). Without limiting, the orders deep-expand was
  dragging the full email body of every linked quote into the
  list response \u2014 20 orders with deep expand = 1-5 MB JSON.
- The orders list now passes a `fields` param with the
  `expandField.subfield` syntax PB supports: only
  `quote.quote_number`, `company.company`, `customer.customer`,
  `customer.rep.{id,name,email}` come back, not the full records.
- The items query likewise limits fields to the 8 columns the list
  view + search predicate actually read; the supplier expand (not
  rendered per-line) is dropped.

Tests: 22 new tests in orders-service.test.ts (tab filter, cache
bypass, combined-query call count, pagination honesty, field-list
contract). Full suite: 1306/1306 pass. The mock's where helper was
upgraded to support || and != / =~ / !~ operators, and the
getFullList mock now honours the filter arg.

Realtime: debounce bumped 300ms \u2192 1000ms, and the realtime path
passes nocache=1 so the 30/60s tab-counts cache is bypassed on
changes the operator just made.

Co-authored-by: Orca <help@stably.ai>
2026-08-04 18:17:59 +02:00

89 lines
2.9 KiB
TypeScript

/* eslint-disable */
/**
* Migration: add an index on `order_items.item_status`.
*
* Why this exists
* ───────────────
* The /orders page's tab badges ("New (12)", "Processing (4)") and
* its server-side tab filter both run queries of the form
* `item_status = "pending" || item_status = "approval_needed" || ...`
* on the `order_items` collection. With ~18k items and no index on
* `item_status`, every load is a full table scan — the single biggest
* cost on the orders page after the deep expand on the orders
* collection itself.
*
* Adding the index makes these queries O(log n) B-tree lookups. The
* page-load budget drops from seconds to sub-second on the cold path
* (realtime refetches benefit even more because the tab-counts cache
* is bypassed for them).
*
* Schema
* ──────
* order_items.item_status select (existing field, no schema change)
*
* The index is non-unique (the same status appears on many rows).
*
* Idempotent — safe to re-run. The migration checks for the index
* before adding it; subsequent runs print "already present, skipping."
*
* Run with:
* npx tsx scripts/migrations/2026-08-04-add-order-items-item-status-index.ts
*/
import PocketBase from "pocketbase";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
function loadEnvFile(name: string) {
try {
const raw = readFileSync(resolve(process.cwd(), name), "utf8");
for (const line of raw.split("\n")) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith("#")) continue;
const m = trimmed.match(/^([A-Z0-9_]+)\s*=\s*(.+)$/i);
if (m && !process.env[m[1]]) {
process.env[m[1]] = m[2].replace(/^["']|["']$/g, "").trim();
}
}
} catch {
// missing file — fine
}
}
loadEnvFile(".env");
loadEnvFile(".env.local");
async function main() {
const url = process.env.NEXT_PUBLIC_POCKETBASE_URL;
const email = process.env.PB_ADMIN_EMAIL;
const password = process.env.PB_ADMIN_PASSWORD;
if (!url || !email || !password) {
throw new Error(
"Missing NEXT_PUBLIC_POCKETBASE_URL / PB_ADMIN_EMAIL / PB_ADMIN_PASSWORD in env",
);
}
const pb = new PocketBase(url);
await pb.admins.authWithPassword(email, password);
const collection = await pb.collections.getOne("order_items");
const INDEX_NAME = "idx_oi_item_status";
const INDEX_SQL = `CREATE INDEX \`${INDEX_NAME}\` ON \`order_items\` (\`item_status\`)`;
const existing = (collection.indexes || []) as string[];
if (existing.includes(INDEX_SQL)) {
console.log("order_items.item_status index already present, skipping.");
return;
}
await pb.collections.update(collection.id, {
indexes: [...existing, INDEX_SQL],
});
console.log("Added order_items.item_status index (idx_oi_item_status).");
console.log("Run /orders in the browser — the initial load should be visibly faster.");
}
main().catch((err) => {
console.error(err);
process.exit(1);
});