Skip to content

Commit a2364df

Browse files
Braedon Saundersclaude
authored andcommitted
Stop preloading cost-intelligence rows on workspace load + add gzip
The workspace page was eagerly fetching up to 5000 EffectiveCost rows (each with a full resource + sourceObservation join) on every load, because the always-mounted AssemblyInsertModal called useAssemblyAuthoringContext() unconditionally. That single request took ~22s and dominated an ~8MB, 40s page load — even though the assembly picker was never opened and the cost dropdown already does indexed dynamic search. Three fixes: 1. Gate useAssemblyAuthoringContext on an `enabled` flag; the modal passes `open`, so the heavy picker data (catalogs, rates, labor units, effective costs) only loads when the picker is actually opened. 2. Add @fastify/compress (global gzip/brotli, 1KB threshold) so large JSON payloads no longer ship uncompressed. SSE routes use reply.hijack() and are unaffected; already-compressed file types are skipped by the mime filter. 3. Add an includeObservation flag to listEffectiveCosts; the assembly path passes false to skip the large sourceObservation (rawText) join it never uses. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 92c2b25 commit a2364df

8 files changed

Lines changed: 144 additions & 5 deletions

File tree

apps/api/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
"@bidwright/integrations": "workspace:*",
2020
"@bidwright/vector": "workspace:*",
2121
"@bidwright/vision": "workspace:*",
22+
"@fastify/compress": "^8.0.1",
2223
"@fastify/cors": "^10.0.2",
2324
"@fastify/multipart": "^9.3.0",
2425
"@fastify/rate-limit": "^10.2.1",

apps/api/src/routes/cost-intelligence-routes.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ const listQuerySchema = z.object({
1515
vendorName: z.string().optional(),
1616
scope: z.enum(["aggregate", "per_vendor", "all"]).optional(),
1717
limit: z.coerce.number().int().positive().max(50000).optional(),
18+
// String query param: "false" disables the heavy sourceObservation join.
19+
includeObservation: z.enum(["true", "false"]).optional(),
1820
});
1921

2022
const resourceSchema = z.object({
@@ -501,6 +503,7 @@ export async function costIntelligenceRoutes(app: FastifyInstance): Promise<void
501503
vendorName: parsed.data.vendorName,
502504
scope: parsed.data.scope,
503505
limit: parsed.data.limit,
506+
includeObservation: parsed.data.includeObservation !== "false",
504507
});
505508
});
506509

apps/api/src/server.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import compress from "@fastify/compress";
12
import cors from "@fastify/cors";
23
import multipart from "@fastify/multipart";
34
import rateLimit from "@fastify/rate-limit";
@@ -1848,6 +1849,11 @@ export function buildServer() {
18481849
await prisma.$disconnect();
18491850
});
18501851

1852+
// Gzip/brotli response compression. Workspace + cost/library payloads are
1853+
// large JSON (often several MB); without this they ship uncompressed. Only
1854+
// kicks in above the threshold and when the client sends Accept-Encoding.
1855+
app.register(compress, { global: true, threshold: 1024 });
1856+
18511857
app.register(cors, {
18521858
origin: true,
18531859
credentials: true

apps/api/src/services/cost-intelligence-service.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,10 @@ export interface CostIntelligenceListFilters {
132132
vendorName?: string;
133133
limit?: number;
134134
scope?: "aggregate" | "per_vendor" | "all";
135+
/** When false, skip the heavy sourceObservation join (rawText etc.). Callers
136+
* that only need cost + resource fields (e.g. the assembly pickers) pass
137+
* false to avoid pulling thousands of large observation rows. */
138+
includeObservation?: boolean;
135139
}
136140

137141
export interface VendorPdfIngestFile {
@@ -3026,7 +3030,9 @@ export class CostIntelligenceService {
30263030
orderBy: [{ updatedAt: "desc" }],
30273031
include: {
30283032
resource: true,
3029-
sourceObservation: true,
3033+
// Only join the (potentially large) observation when the caller needs
3034+
// it. The assembly pickers pull thousands of rows and use none of it.
3035+
...(filters.includeObservation === false ? {} : { sourceObservation: true }),
30303036
},
30313037
take: clampLimit(filters.limit),
30323038
});

apps/web/components/assembly-authoring.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,14 +170,21 @@ function parseMarkupPercent(value: string): number | null | undefined {
170170
// Loads assembly component sources from the API. Used by both the
171171
// settings-page authoring view and the estimate-page quick-create flow so the
172172
// pickers are populated identically.
173-
export function useAssemblyAuthoringContext() {
173+
export function useAssemblyAuthoringContext(options?: { enabled?: boolean }) {
174+
// Gate the fetch so this heavy data (catalogs + rate schedules + labor units +
175+
// thousands of effective costs) is only pulled when actually needed — e.g.
176+
// when the assembly picker opens — instead of eagerly on every page that
177+
// happens to mount a (closed) consumer. Defaults to enabled for standalone
178+
// pages like the assembly manager.
179+
const enabled = options?.enabled ?? true;
174180
const [catalogItems, setCatalogItems] = useState<CatalogItemRow[]>([]);
175181
const [rateItems, setRateItems] = useState<RateItemRow[]>([]);
176182
const [laborUnits, setLaborUnits] = useState<LaborUnitRow[]>([]);
177183
const [effectiveCosts, setEffectiveCosts] = useState<EffectiveCostRow[]>([]);
178184
const [loaded, setLoaded] = useState(false);
179185

180186
useEffect(() => {
187+
if (!enabled || loaded) return;
181188
let cancelled = false;
182189
(async () => {
183190
try {
@@ -247,7 +254,9 @@ export function useAssemblyAuthoringContext() {
247254
}
248255

249256
try {
250-
const rows = await listEffectiveCosts({ limit: 5000 });
257+
// includeObservation:false skips the heavy sourceObservation join —
258+
// the picker only needs cost + resource fields.
259+
const rows = await listEffectiveCosts({ limit: 5000, includeObservation: false });
251260
const flat: EffectiveCostRow[] = rows.map((cost: EffectiveCostRecord) => {
252261
const item = effectiveCostItem(cost);
253262
return {
@@ -280,7 +289,7 @@ export function useAssemblyAuthoringContext() {
280289
return () => {
281290
cancelled = true;
282291
};
283-
}, []);
292+
}, [enabled, loaded]);
284293

285294
return { catalogItems, rateItems, laborUnits, effectiveCosts, loaded };
286295
}

apps/web/components/workspace/assembly-insert-modal.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,10 @@ export function AssemblyInsertModal({
7070
const [editInstanceParams, setEditInstanceParams] = useState<Record<string, string>>({});
7171
const [instanceBusy, setInstanceBusy] = useState(false);
7272

73-
const { catalogItems, rateItems, laborUnits, effectiveCosts } = useAssemblyAuthoringContext();
73+
// Only load the heavy picker data once the modal is actually opened — the
74+
// modal stays mounted (closed) in the estimate grid, so an unconditional
75+
// fetch would pull thousands of cost rows on every workspace page load.
76+
const { catalogItems, rateItems, laborUnits, effectiveCosts } = useAssemblyAuthoringContext({ enabled: open });
7477

7578
const refreshList = useCallback(async () => {
7679
setLoading(true);

apps/web/lib/api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7361,6 +7361,7 @@ export async function listEffectiveCosts(input: {
73617361
vendorName?: string;
73627362
scope?: "aggregate" | "per_vendor" | "all";
73637363
limit?: number;
7364+
includeObservation?: boolean;
73647365
} = {}): Promise<EffectiveCostRecord[]> {
73657366
const params = new URLSearchParams();
73667367
for (const [key, value] of Object.entries(input)) {

0 commit comments

Comments
 (0)