bunsane 0.2.4 → 0.2.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/core/ArcheType.ts +67 -34
  2. package/core/BatchLoader.ts +215 -30
  3. package/core/Entity.ts +2 -2
  4. package/core/RequestContext.ts +15 -10
  5. package/core/RequestLoaders.ts +4 -2
  6. package/core/cache/CacheProvider.ts +1 -0
  7. package/core/cache/MemoryCache.ts +10 -1
  8. package/core/cache/RedisCache.ts +16 -2
  9. package/core/validateEnv.ts +8 -0
  10. package/database/DatabaseHelper.ts +113 -1
  11. package/database/index.ts +78 -45
  12. package/docs/SCALABILITY_PLAN.md +175 -0
  13. package/package.json +13 -2
  14. package/query/CTENode.ts +44 -24
  15. package/query/ComponentInclusionNode.ts +181 -91
  16. package/query/Query.ts +9 -9
  17. package/tests/benchmark/BENCHMARK_DATABASES_PLAN.md +338 -0
  18. package/tests/benchmark/bunfig.toml +9 -0
  19. package/tests/benchmark/fixtures/EcommerceComponents.ts +283 -0
  20. package/tests/benchmark/fixtures/EcommerceDataGenerators.ts +301 -0
  21. package/tests/benchmark/fixtures/RelationTracker.ts +159 -0
  22. package/tests/benchmark/fixtures/index.ts +6 -0
  23. package/tests/benchmark/index.ts +22 -0
  24. package/tests/benchmark/noop-preload.ts +3 -0
  25. package/tests/benchmark/runners/BenchmarkLoader.ts +132 -0
  26. package/tests/benchmark/runners/index.ts +4 -0
  27. package/tests/benchmark/scenarios/query-benchmarks.test.ts +465 -0
  28. package/tests/benchmark/scripts/generate-db.ts +344 -0
  29. package/tests/benchmark/scripts/run-benchmarks.ts +97 -0
  30. package/tests/integration/query/Query.complexAnalysis.test.ts +557 -0
  31. package/tests/integration/query/Query.explainAnalyze.test.ts +233 -0
  32. package/tests/stress/fixtures/RealisticComponents.ts +235 -0
  33. package/tests/stress/scenarios/realistic-scenarios.test.ts +1081 -0
  34. package/tests/stress/scenarios/timeout-investigation.test.ts +522 -0
  35. package/tests/unit/BatchLoader.test.ts +139 -25
@@ -3,7 +3,33 @@ import type { QueryResult } from "./QueryNode";
3
3
  import { QueryContext } from "./QueryContext";
4
4
  import { shouldUseLateralJoins, shouldUseDirectPartition } from "../core/Config";
5
5
  import { FilterBuilderRegistry } from "./FilterBuilderRegistry";
6
- import {ComponentRegistry} from "../core/components";
6
+ import { ComponentRegistry } from "../core/components";
7
+ import { getMetadataStorage } from "../core/metadata";
8
+
9
+ /**
10
+ * Check if a component property is numeric based on metadata
11
+ * Used to apply proper casting in ORDER BY clauses for index usage
12
+ */
13
+ function isNumericProperty(componentName: string, propertyName: string): boolean {
14
+ const storage = getMetadataStorage();
15
+ const typeId = storage.getComponentId(componentName);
16
+
17
+ // Check indexed fields first (most reliable)
18
+ const indexedFields = storage.getIndexedFields(typeId);
19
+ const indexedField = indexedFields.find(f => f.propertyKey === propertyName);
20
+ if (indexedField?.indexType === 'numeric') {
21
+ return true;
22
+ }
23
+
24
+ // Check property metadata for Number type
25
+ const props = storage.getComponentProperties(typeId);
26
+ const prop = props.find(p => p.propertyKey === propertyName);
27
+ if (prop?.propertyType === Number) {
28
+ return true;
29
+ }
30
+
31
+ return false;
32
+ }
7
33
 
8
34
  export class ComponentInclusionNode extends QueryNode {
9
35
  private getComponentTableName(compId: string): string {
@@ -108,7 +134,10 @@ export class ComponentInclusionNode extends QueryNode {
108
134
  }
109
135
 
110
136
  // Apply component filters for single component (normal path)
111
- sql = this.applyComponentFilters(context, componentIds, useCTE, useLateralJoins, lateralJoins, lateralConditions, sql, new Map());
137
+ // For single component, alias is 'ec' (or CTE name if using CTE)
138
+ // Single component queries have WHERE from the initial select, so pass true
139
+ const singleCompHasWhere = sql.includes(' WHERE ');
140
+ sql = this.applyComponentFilters(context, componentIds, useCTE, useLateralJoins, lateralJoins, lateralConditions, sql, new Map(), useCTE ? context.cteName : "ec", singleCompHasWhere);
112
141
 
113
142
  // Apply sorting with component data joins if sortOrders are specified
114
143
  if (hasSortOrders) {
@@ -145,17 +174,11 @@ export class ComponentInclusionNode extends QueryNode {
145
174
  // Multiple components case
146
175
  // Create parameter indices for component IDs to avoid duplicates
147
176
  const componentParamIndices: Map<string, number> = new Map();
148
- const componentPlaceholders = componentIds.map((id) => {
149
- if (!componentParamIndices.has(id)) {
150
- componentParamIndices.set(id, context.addParam(id));
151
- }
152
- return `$${componentParamIndices.get(id)}::text`;
153
- }).join(', ');
154
-
177
+
155
178
  if (useCTE) {
156
179
  // Use CTE for base entity filtering
157
180
  sql = `SELECT DISTINCT ${context.cteName}.entity_id as id FROM ${context.cteName}`;
158
-
181
+
159
182
  // Ensure all required components are present
160
183
  sql += ` WHERE (`;
161
184
  const componentChecks = componentIds.map(compId => {
@@ -171,57 +194,77 @@ export class ComponentInclusionNode extends QueryNode {
171
194
  });
172
195
  sql += componentChecks.join(' AND ') + `)`;
173
196
  } else {
174
- sql = `SELECT DISTINCT ec.entity_id as id FROM entity_components ec WHERE ec.type_id IN (${componentPlaceholders}) AND ec.deleted_at IS NULL`;
197
+ // Use INTERSECT for multi-component queries (much faster than GROUP BY + HAVING)
198
+ // INTERSECT lets PostgreSQL use index scans independently on each component type
199
+ // then merge the results efficiently with a hash or merge join
200
+ const intersectQueries = componentIds.map((compId) => {
201
+ if (!componentParamIndices.has(compId)) {
202
+ componentParamIndices.set(compId, context.addParam(compId));
203
+ }
204
+ return `SELECT ec.entity_id FROM entity_components ec WHERE ec.type_id = $${componentParamIndices.get(compId)}::text AND ec.deleted_at IS NULL`;
205
+ });
206
+ sql = `SELECT intersected.entity_id as id FROM (${intersectQueries.join(' INTERSECT ')}) AS intersected`;
175
207
  }
176
208
 
209
+ // For INTERSECT queries, the alias is 'intersected', not 'ec'
210
+ const multiCompAlias = useCTE ? context.cteName : "intersected";
211
+
212
+ // Track if outer query has WHERE clause (don't count WHERE inside INTERSECT subqueries)
213
+ // For INTERSECT queries, the outer query starts without WHERE
214
+ let outerHasWhere = useCTE && sql.indexOf('WHERE', sql.lastIndexOf('FROM')) > -1;
215
+
177
216
  if (context.withId) {
178
- const tableAlias = useCTE ? context.cteName : "ec";
179
- const whereKeyword = sql.includes('WHERE') ? 'AND' : 'WHERE';
180
- sql += ` ${whereKeyword} ${tableAlias}.entity_id = $${context.addParam(context.withId)}`;
217
+ const whereKeyword = outerHasWhere ? 'AND' : 'WHERE';
218
+ sql += ` ${whereKeyword} ${multiCompAlias}.entity_id = $${context.addParam(context.withId)}`;
219
+ outerHasWhere = true;
181
220
  }
182
221
 
183
222
  // Add exclusions
184
223
  if (excludedIds.length > 0) {
185
- const tableAlias = useCTE ? context.cteName : "ec";
186
- const whereKeyword = sql.includes('WHERE') ? 'AND' : 'WHERE';
224
+ const whereKeyword = outerHasWhere ? 'AND' : 'WHERE';
187
225
  const excludedPlaceholders = excludedIds.map((id) => `$${context.addParam(id)}`).join(', ');
188
226
  sql += ` ${whereKeyword} NOT EXISTS (
189
227
  SELECT 1 FROM entity_components ec_ex
190
- WHERE ec_ex.entity_id = ${tableAlias}.entity_id
228
+ WHERE ec_ex.entity_id = ${multiCompAlias}.entity_id
191
229
  AND ec_ex.type_id IN (${excludedPlaceholders})
192
230
  AND ec_ex.deleted_at IS NULL
193
231
  )`;
232
+ outerHasWhere = true;
194
233
  }
195
234
 
196
235
  // Add entity exclusions
197
236
  if (context.excludedEntityIds.size > 0) {
198
- const tableAlias = useCTE ? context.cteName : "ec";
199
- const whereKeyword = sql.includes('WHERE') ? 'AND' : 'WHERE';
237
+ const whereKeyword = outerHasWhere ? 'AND' : 'WHERE';
200
238
  const entityExcludedIds = Array.from(context.excludedEntityIds);
201
239
  const entityPlaceholders = entityExcludedIds.map((id) => `$${context.addParam(id)}`).join(', ');
202
- sql += ` ${whereKeyword} ${tableAlias}.entity_id NOT IN (${entityPlaceholders})`;
240
+ sql += ` ${whereKeyword} ${multiCompAlias}.entity_id NOT IN (${entityPlaceholders})`;
241
+ outerHasWhere = true;
203
242
  }
204
243
 
205
244
  // Apply component filters for multiple components
206
- sql = this.applyComponentFilters(context, componentIds, useCTE, useLateralJoins, lateralJoins, lateralConditions, sql, componentParamIndices);
245
+ // For INTERSECT queries, alias is 'intersected'; for CTE, use cteName
246
+ // Pass outerHasWhere to correctly track WHERE clause in outer query (not in INTERSECT subqueries)
247
+ const filterResult = this.applyComponentFiltersWithState(context, componentIds, useCTE, useLateralJoins, lateralJoins, lateralConditions, sql, componentParamIndices, multiCompAlias, outerHasWhere);
248
+ sql = filterResult.sql;
249
+ outerHasWhere = filterResult.hasWhere;
250
+
251
+ // Note: GROUP BY HAVING removed - INTERSECT already ensures all components are present
207
252
 
208
- if (!useCTE) {
209
- sql += ` GROUP BY ec.entity_id HAVING COUNT(DISTINCT ec.type_id) = $${context.addParam(componentCount)}`;
210
- }
211
-
212
253
  // Apply sorting with component data joins if sortOrders are specified
213
254
  if (hasSortOrders) {
214
255
  sql = this.applySortingWithComponentJoins(sql, context);
215
256
  } else {
216
257
  // Default: order by entity_id
217
- const tableAlias = useCTE ? context.cteName : "ec";
218
- const idColumn = useCTE ? `${context.cteName}.entity_id` : `${tableAlias}.entity_id`;
258
+ // For INTERSECT queries, use 'intersected' alias; for CTE, use cteName
259
+ const idColumn = `${multiCompAlias}.entity_id`;
219
260
 
220
261
  // Apply cursor-based pagination if cursor is set (more efficient than OFFSET)
221
262
  if (context.cursorId !== null && !context.paginationAppliedInCTE) {
222
263
  const operator = context.cursorDirection === 'after' ? '>' : '<';
223
- const whereKeyword = sql.includes('WHERE') ? 'AND' : 'WHERE';
264
+ // Use tracked WHERE state for INTERSECT queries
265
+ const whereKeyword = outerHasWhere ? 'AND' : 'WHERE';
224
266
  sql += ` ${whereKeyword} ${idColumn} ${operator} $${context.addParam(context.cursorId)}`;
267
+ outerHasWhere = true;
225
268
  }
226
269
 
227
270
  // Order direction depends on cursor direction
@@ -266,44 +309,48 @@ export class ComponentInclusionNode extends QueryNode {
266
309
  if (singlePass) return singlePass;
267
310
  }
268
311
 
269
- // Wrap the base query as a subquery to get entity ids
270
- let sql = `SELECT base_entities.id FROM (${baseQuery}) AS base_entities`;
271
-
272
- // Build LEFT JOINs for each sort order to access component data
273
- const sortJoins: string[] = [];
312
+ // Use scalar subquery approach for sorting to avoid cartesian product explosion
313
+ // This forces PostgreSQL to evaluate the sort expression for each entity row,
314
+ // rather than joining all component rows first and filtering later.
315
+ // This is dramatically faster when base_entities is a small subset of total entities.
274
316
  const orderByClauses: string[] = [];
275
-
317
+
276
318
  for (let i = 0; i < context.sortOrders.length; i++) {
277
319
  const sortOrder = context.sortOrders[i]!;
278
- const sortAlias = `sort_${i}`;
279
- const compAlias = `comp_${i}`;
280
-
320
+
281
321
  // Get the component type ID for this sort order
282
322
  const typeId = ComponentRegistry.getComponentId(sortOrder.component);
283
323
  if (!typeId) {
284
324
  continue; // Skip if component not registered
285
325
  }
286
-
287
- // LEFT JOIN to entity_components and components to get the sort data
326
+
288
327
  const sortComponentTableName = this.getComponentTableName(typeId);
289
- sortJoins.push(`
290
- LEFT JOIN entity_components ${sortAlias}
291
- ON ${sortAlias}.entity_id = base_entities.id
292
- AND ${sortAlias}.type_id = $${context.addParam(typeId)}::text
293
- AND ${sortAlias}.deleted_at IS NULL
294
- LEFT JOIN ${sortComponentTableName} ${compAlias}
295
- ON ${compAlias}.id = ${sortAlias}.component_id
296
- AND ${compAlias}.deleted_at IS NULL`);
297
-
298
- // Build ORDER BY clause for this sort order
299
- // Access the property from JSONB data
300
328
  const nullsClause = sortOrder.nullsFirst ? 'NULLS FIRST' : 'NULLS LAST';
301
- orderByClauses.push(`${compAlias}.data->>'${sortOrder.property}' ${sortOrder.direction} ${nullsClause}`);
329
+ const isNumeric = isNumericProperty(sortOrder.component, sortOrder.property);
330
+
331
+ // Build scalar subquery to get sort value for each entity
332
+ // This avoids nested loop join by forcing row-by-row evaluation
333
+ const sortExpr = isNumeric
334
+ ? `(sort_c.data->>'${sortOrder.property}')::numeric`
335
+ : `sort_c.data->>'${sortOrder.property}'`;
336
+
337
+ const subquery = `(
338
+ SELECT ${sortExpr}
339
+ FROM entity_components sort_ec
340
+ JOIN ${sortComponentTableName} sort_c ON sort_c.id = sort_ec.component_id
341
+ WHERE sort_ec.entity_id = base_entities.id
342
+ AND sort_ec.type_id = $${context.addParam(typeId)}::text
343
+ AND sort_ec.deleted_at IS NULL
344
+ AND sort_c.deleted_at IS NULL
345
+ LIMIT 1
346
+ )`;
347
+
348
+ orderByClauses.push(`${subquery} ${sortOrder.direction} ${nullsClause}`);
302
349
  }
303
-
304
- // Combine joins
305
- sql += sortJoins.join('');
306
-
350
+
351
+ // Wrap the base query as a subquery to get entity ids
352
+ let sql = `SELECT base_entities.id FROM (${baseQuery}) AS base_entities`;
353
+
307
354
  // Add ORDER BY clause
308
355
  if (orderByClauses.length > 0) {
309
356
  sql += ` ORDER BY ${orderByClauses.join(', ')}`;
@@ -311,7 +358,7 @@ export class ComponentInclusionNode extends QueryNode {
311
358
  // Fallback to entity id if no valid sort orders
312
359
  sql += ` ORDER BY base_entities.id`;
313
360
  }
314
-
361
+
315
362
  // Add LIMIT and OFFSET only if not already applied in CTE
316
363
  // When pagination is applied at CTE level, skip it here to avoid double pagination
317
364
  if (!context.paginationAppliedInCTE) {
@@ -334,10 +381,17 @@ export class ComponentInclusionNode extends QueryNode {
334
381
  *
335
382
  * This is dramatically faster because PostgreSQL can use indexes to find
336
383
  * the top N matching rows directly instead of finding ALL matches first.
384
+ *
385
+ * NOTE: This optimization cannot be used when multiple component types are required,
386
+ * because it only queries one component table and would miss the join requirement.
337
387
  */
338
388
  private applySinglePassFilterSort(context: QueryContext): string | null {
339
389
  if (context.sortOrders.length !== 1) return null;
340
390
 
391
+ // Can't use single-pass when multiple components are required
392
+ // (we need to ensure entities have ALL required components)
393
+ if (context.componentIds.size > 1) return null;
394
+
341
395
  const sortOrder = context.sortOrders[0]!;
342
396
  const sortTypeId = ComponentRegistry.getComponentId(sortOrder.component);
343
397
  if (!sortTypeId) return null;
@@ -392,6 +446,10 @@ export class ComponentInclusionNode extends QueryNode {
392
446
  }
393
447
 
394
448
  const nullsClause = sortOrder.nullsFirst ? 'NULLS FIRST' : 'NULLS LAST';
449
+ const isNumeric = isNumericProperty(sortOrder.component, sortOrder.property);
450
+ const sortExpr = isNumeric
451
+ ? `(c.data->>'${sortOrder.property}')::numeric`
452
+ : `c.data->>'${sortOrder.property}'`;
395
453
 
396
454
  let sql: string;
397
455
  if (useDirectPartition) {
@@ -401,7 +459,7 @@ export class ComponentInclusionNode extends QueryNode {
401
459
  WHERE c.type_id = $${context.addParam(sortTypeId)}::text
402
460
  AND c.deleted_at IS NULL
403
461
  AND ${filterConditions.join(' AND ')}
404
- ORDER BY c.data->>'${sortOrder.property}' ${sortOrder.direction} ${nullsClause}`;
462
+ ORDER BY ${sortExpr} ${sortOrder.direction} ${nullsClause}`;
405
463
  } else {
406
464
  // Use entity_components junction
407
465
  // No DISTINCT needed since each entity has one component of this type
@@ -410,7 +468,7 @@ export class ComponentInclusionNode extends QueryNode {
410
468
  WHERE ec.type_id = $${context.addParam(sortTypeId)}::text
411
469
  AND ec.deleted_at IS NULL
412
470
  AND ${filterConditions.join(' AND ')}
413
- ORDER BY c.data->>'${sortOrder.property}' ${sortOrder.direction} ${nullsClause}`;
471
+ ORDER BY ${sortExpr} ${sortOrder.direction} ${nullsClause}`;
414
472
  }
415
473
 
416
474
  // Add pagination
@@ -427,29 +485,41 @@ export class ComponentInclusionNode extends QueryNode {
427
485
  }
428
486
 
429
487
  /**
430
- * Optimized sorting for direct partition access
431
- * Queries the partition table directly without going through entity_components for the sort join
488
+ * Optimized sorting for direct partition access.
489
+ * Uses scalar subquery to avoid cartesian product explosion when sorting.
490
+ * Queries the partition table directly without going through entity_components.
432
491
  */
433
492
  private applySortingOptimized(baseQuery: string, context: QueryContext): string | null {
434
493
  if (context.sortOrders.length !== 1) return null;
435
-
494
+
436
495
  const sortOrder = context.sortOrders[0]!;
437
496
  const typeId = ComponentRegistry.getComponentId(sortOrder.component);
438
497
  if (!typeId) return null;
439
-
498
+
440
499
  const partitionTable = ComponentRegistry.getPartitionTableName(typeId);
441
500
  if (!partitionTable) return null;
442
-
501
+
443
502
  const nullsClause = sortOrder.nullsFirst ? 'NULLS FIRST' : 'NULLS LAST';
444
-
445
- // Optimized query: Direct join to partition table, skip entity_components for sort
446
- // This is faster because we go directly to the partition table
503
+ const isNumeric = isNumericProperty(sortOrder.component, sortOrder.property);
504
+ const sortExpr = isNumeric
505
+ ? `(sort_c.data->>'${sortOrder.property}')::numeric`
506
+ : `sort_c.data->>'${sortOrder.property}'`;
507
+
508
+ // Use scalar subquery to avoid cartesian product explosion
509
+ // This forces PostgreSQL to evaluate sort value per-entity, preventing
510
+ // the nested loop join that scans all component rows before filtering
511
+ const sortSubquery = `(
512
+ SELECT ${sortExpr}
513
+ FROM ${partitionTable} sort_c
514
+ WHERE sort_c.entity_id = base.id
515
+ AND sort_c.type_id = $${context.addParam(typeId)}::text
516
+ AND sort_c.deleted_at IS NULL
517
+ LIMIT 1
518
+ )`;
519
+
447
520
  let sql = `SELECT base.id FROM (${baseQuery}) AS base
448
- JOIN ${partitionTable} c ON c.entity_id = base.id
449
- AND c.type_id = $${context.addParam(typeId)}::text
450
- AND c.deleted_at IS NULL
451
- ORDER BY c.data->>'${sortOrder.property}' ${sortOrder.direction} ${nullsClause}`;
452
-
521
+ ORDER BY ${sortSubquery} ${sortOrder.direction} ${nullsClause}`;
522
+
453
523
  // Add LIMIT and OFFSET only if not already applied in CTE
454
524
  // When pagination is applied at CTE level, skip it here to avoid double pagination
455
525
  if (!context.paginationAppliedInCTE) {
@@ -467,6 +537,7 @@ export class ComponentInclusionNode extends QueryNode {
467
537
 
468
538
  /**
469
539
  * Apply component filters using either EXISTS subqueries or LATERAL joins
540
+ * Wrapper that returns just the SQL string for backward compatibility
470
541
  */
471
542
  private applyComponentFilters(
472
543
  context: QueryContext,
@@ -476,8 +547,34 @@ export class ComponentInclusionNode extends QueryNode {
476
547
  lateralJoins: string[],
477
548
  lateralConditions: string[],
478
549
  sql: string,
479
- componentParamIndices: Map<string, number>
550
+ componentParamIndices: Map<string, number>,
551
+ entityTableAlias?: string,
552
+ outerHasWhere: boolean = false
480
553
  ): string {
554
+ return this.applyComponentFiltersWithState(context, componentIds, useCTE, useLateralJoins, lateralJoins, lateralConditions, sql, componentParamIndices, entityTableAlias, outerHasWhere).sql;
555
+ }
556
+
557
+ /**
558
+ * Apply component filters using either EXISTS subqueries or LATERAL joins
559
+ * Returns both SQL and updated WHERE state for proper tracking
560
+ * @param entityTableAlias - The alias for the entity table (e.g., 'ec', 'intersected', or CTE name)
561
+ * @param outerHasWhere - Track if outer query already has WHERE clause (for INTERSECT queries)
562
+ */
563
+ private applyComponentFiltersWithState(
564
+ context: QueryContext,
565
+ componentIds: string[],
566
+ useCTE: boolean,
567
+ useLateralJoins: boolean,
568
+ lateralJoins: string[],
569
+ lateralConditions: string[],
570
+ sql: string,
571
+ componentParamIndices: Map<string, number>,
572
+ entityTableAlias?: string,
573
+ outerHasWhere: boolean = false
574
+ ): { sql: string; hasWhere: boolean } {
575
+ // Track whether we've added WHERE to the outer query
576
+ let hasOuterWhere = outerHasWhere;
577
+
481
578
  for (const [compId, filters] of context.componentFilters) {
482
579
  for (const filter of filters) {
483
580
  let condition: string;
@@ -500,20 +597,11 @@ export class ComponentInclusionNode extends QueryNode {
500
597
  if (filter.value === '' || (typeof filter.value === 'string' && filter.value.trim() === '')) {
501
598
  throw new Error(`Filter value for field "${filter.field}" is an empty string. This would cause PostgreSQL UUID parsing errors.`);
502
599
  }
503
-
600
+
504
601
  // Check if value looks like a UUID (case-insensitive, with or without hyphens)
505
602
  const valueStr = String(filter.value);
506
603
  const isUUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(valueStr);
507
-
508
- // Debug logging
509
- // console.log('[ComponentInclusionNode] Filter:', {
510
- // field: filter.field,
511
- // operator: filter.operator,
512
- // value: filter.value,
513
- // valueStr,
514
- // isUUID
515
- // });
516
-
604
+
517
605
  // Build JSON path for nested fields (e.g., "device.unique_id" -> "c.data->'device'->>'unique_id'")
518
606
  let jsonPath: string;
519
607
  if (filter.field.includes('.')) {
@@ -524,7 +612,7 @@ export class ComponentInclusionNode extends QueryNode {
524
612
  } else {
525
613
  jsonPath = `c.data->>'${filter.field}'`;
526
614
  }
527
-
615
+
528
616
  if (isUUID && filter.operator === '=') {
529
617
  // UUID equality comparison - only cast the parameter, compare as text
530
618
  // This allows matching UUID parameter against both UUID and text fields
@@ -550,12 +638,12 @@ export class ComponentInclusionNode extends QueryNode {
550
638
  // Default: text comparison without casting
551
639
  condition = `${jsonPath} ${filter.operator} $${context.addParam(filter.value)}`;
552
640
  }
553
-
554
- // console.log('[ComponentInclusionNode] Condition:', condition);
555
641
  }
556
-
557
- const tableAlias = useCTE ? context.cteName : "ec";
558
- const whereKeyword = sql.includes('WHERE') ? 'AND' : 'WHERE';
642
+
643
+ // Use provided alias, or fall back to CTE name or 'ec'
644
+ const tableAlias = entityTableAlias || (useCTE ? context.cteName : "ec");
645
+ // Use tracked WHERE state instead of sql.includes() to handle INTERSECT correctly
646
+ const whereKeyword = hasOuterWhere ? 'AND' : 'WHERE';
559
647
 
560
648
  if (useLateralJoins) {
561
649
  // Use LATERAL join approach
@@ -600,7 +688,7 @@ export class ComponentInclusionNode extends QueryNode {
600
688
  // Use traditional EXISTS subquery
601
689
  const componentTableName = this.getComponentTableName(compId);
602
690
  const useDirectPartition = shouldUseDirectPartition() && componentTableName !== 'components';
603
-
691
+
604
692
  if (useDirectPartition) {
605
693
  // Direct partition access - query partition table directly by entity_id
606
694
  sql += ` ${whereKeyword} EXISTS (
@@ -622,6 +710,8 @@ export class ComponentInclusionNode extends QueryNode {
622
710
  AND c.deleted_at IS NULL
623
711
  )`;
624
712
  }
713
+ // Mark that we've added WHERE to the outer query
714
+ hasOuterWhere = true;
625
715
  }
626
716
  }
627
717
  }
@@ -680,7 +770,7 @@ export class ComponentInclusionNode extends QueryNode {
680
770
  }
681
771
  }
682
772
 
683
- return sql;
773
+ return { sql, hasWhere: hasOuterWhere };
684
774
  }
685
775
 
686
776
  public getNodeType(): string {
package/query/Query.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import {ComponentRegistry , type BaseComponent, type ComponentDataType } from "../core/components";
2
2
  import { Entity } from "../core/Entity";
3
3
  import { logger } from "../core/Logger";
4
- import db from "../database";
4
+ import db, { QUERY_TIMEOUT_MS } from "../database";
5
5
  import { timed } from "../core/Decorators";
6
6
  import { inList } from "../database/sqlHelpers";
7
7
  import { QueryContext, QueryDAG, SourceNode, ComponentInclusionNode } from "./index";
@@ -299,8 +299,8 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
299
299
  return new Promise<number>((resolve, reject) => {
300
300
  const timeout = setTimeout(() => {
301
301
  logger.error(`Query count execution timeout`);
302
- reject(new Error(`Query count execution timeout after 30 seconds`));
303
- }, 30000);
302
+ reject(new Error(`Query count execution timeout after ${QUERY_TIMEOUT_MS / 1000} seconds`));
303
+ }, QUERY_TIMEOUT_MS);
304
304
  this.doCount()
305
305
  .then(result => {
306
306
  clearTimeout(timeout);
@@ -455,8 +455,8 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
455
455
  return new Promise<number>((resolve, reject) => {
456
456
  const timeout = setTimeout(() => {
457
457
  logger.error(`Query sum execution timeout`);
458
- reject(new Error(`Query sum execution timeout after 30 seconds`));
459
- }, 30000);
458
+ reject(new Error(`Query sum execution timeout after ${QUERY_TIMEOUT_MS / 1000} seconds`));
459
+ }, QUERY_TIMEOUT_MS);
460
460
  this.doAggregate('SUM', componentCtor, field as string)
461
461
  .then(result => {
462
462
  clearTimeout(timeout);
@@ -483,8 +483,8 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
483
483
  return new Promise<number>((resolve, reject) => {
484
484
  const timeout = setTimeout(() => {
485
485
  logger.error(`Query average execution timeout`);
486
- reject(new Error(`Query average execution timeout after 30 seconds`));
487
- }, 30000);
486
+ reject(new Error(`Query average execution timeout after ${QUERY_TIMEOUT_MS / 1000} seconds`));
487
+ }, QUERY_TIMEOUT_MS);
488
488
  this.doAggregate('AVG', componentCtor, field as string)
489
489
  .then(result => {
490
490
  clearTimeout(timeout);
@@ -641,8 +641,8 @@ AND c.deleted_at IS NULL`;
641
641
  // Add timeout to prevent hanging queries
642
642
  const timeout = setTimeout(() => {
643
643
  logger.error(`Query execution timeout`);
644
- reject(new Error(`Query execution timeout after 30 seconds`));
645
- }, 30000); // 30 second timeout
644
+ reject(new Error(`Query execution timeout after ${QUERY_TIMEOUT_MS / 1000} seconds`));
645
+ }, QUERY_TIMEOUT_MS); // 30 second timeout
646
646
 
647
647
  this.doExec()
648
648
  .then(result => {