bunsane 0.3.0 → 0.3.2

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 (54) hide show
  1. package/.claude/scheduled_tasks.lock +1 -0
  2. package/CHANGELOG.md +104 -0
  3. package/CLAUDE.md +20 -0
  4. package/config/cache.config.ts +35 -1
  5. package/core/App.ts +24 -1060
  6. package/core/ArcheType.ts +78 -2110
  7. package/core/Entity.ts +136 -41
  8. package/core/RequestContext.ts +85 -36
  9. package/core/RequestLoaders.ts +89 -31
  10. package/core/SchedulerManager.ts +13 -13
  11. package/core/app/bootstrap.ts +133 -0
  12. package/core/app/cors.ts +94 -0
  13. package/core/app/graphqlSetup.ts +56 -0
  14. package/core/app/healthEndpoints.ts +31 -0
  15. package/core/app/metricsCollector.ts +27 -0
  16. package/core/app/preparedStatementWarmup.ts +55 -0
  17. package/core/app/processHandlers.ts +43 -0
  18. package/core/app/requestRouter.ts +309 -0
  19. package/core/app/restRegistry.ts +72 -0
  20. package/core/app/shutdown.ts +97 -0
  21. package/core/app/studioRouter.ts +83 -0
  22. package/core/archetype/customTypes.ts +100 -0
  23. package/core/archetype/decorators.ts +171 -0
  24. package/core/archetype/fieldResolvers.ts +621 -0
  25. package/core/archetype/helpers.ts +29 -0
  26. package/core/archetype/relationLoader.ts +118 -0
  27. package/core/archetype/schemaBuilder.ts +141 -0
  28. package/core/archetype/weaver.ts +218 -0
  29. package/core/archetype/zodSchemaBuilder.ts +527 -0
  30. package/core/cache/CacheManager.ts +144 -9
  31. package/core/components/BaseComponent.ts +12 -2
  32. package/core/middleware/AccessLog.ts +8 -1
  33. package/database/PreparedStatementCache.ts +17 -16
  34. package/database/cancellable.ts +22 -0
  35. package/database/instrumentedDb.ts +141 -0
  36. package/docs/RFC_APP_REFACTOR.md +248 -0
  37. package/docs/RFC_REFACTOR_TARGETS.md +251 -0
  38. package/package.json +1 -1
  39. package/query/ComponentInclusionNode.ts +5 -5
  40. package/query/Query.ts +65 -48
  41. package/service/ServiceRegistry.ts +7 -1
  42. package/service/index.ts +4 -2
  43. package/tests/integration/loaders/RequestLoaders.abort.test.ts +82 -0
  44. package/tests/integration/query/Query.abort.test.ts +66 -0
  45. package/tests/unit/cache/CacheManager.test.ts +152 -1
  46. package/tests/unit/database/cancellable.test.ts +81 -0
  47. package/tests/unit/database/instrumentedDb.test.ts +160 -0
  48. package/tests/unit/entity/Entity.components.test.ts +73 -0
  49. package/tests/unit/entity/Entity.drainSideEffects.test.ts +51 -0
  50. package/tests/unit/entity/Entity.reload.test.ts +63 -0
  51. package/tests/unit/entity/Entity.requireComponents.test.ts +72 -0
  52. package/tests/unit/query/Query.emptyString.test.ts +69 -0
  53. package/tests/unit/query/Query.test.ts +6 -4
  54. package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +95 -0
package/query/Query.ts CHANGED
@@ -8,6 +8,7 @@ import { QueryContext, QueryDAG, SourceNode, ComponentInclusionNode } from "./in
8
8
  import { OrQuery } from "./OrQuery";
9
9
  import { OrNode } from "./OrNode";
10
10
  import { preparedStatementCache } from "../database/PreparedStatementCache";
11
+ import { timedUnsafe, type PerRequestCounters } from "../database/instrumentedDb";
11
12
  import { getMetadataStorage } from "../core/metadata";
12
13
  import { shouldUseDirectPartition } from "../core/Config";
13
14
  import type { SQL } from "bun";
@@ -62,6 +63,21 @@ export interface QueryCacheOptions {
62
63
  component?: boolean;
63
64
  }
64
65
 
66
+ /**
67
+ * Options accepted by Query terminal methods (`exec`, `count`, `sum`, etc.).
68
+ * - `signal` cancels in-flight DB queries via Bun's `Query.cancel()` when
69
+ * fired. The request-scoped signal from `req.signal` is automatically
70
+ * threaded into resolver-level Query instances by the framework's
71
+ * GraphQL request context plugin; manual callers pass it explicitly.
72
+ * - `perRequest` is an opaque counter object incremented by the
73
+ * instrumented DB layer so per-request stats (dbQueryCount,
74
+ * dataLoaderCalls) are reported on access/timeout logs.
75
+ */
76
+ export interface QueryExecOptions {
77
+ signal?: AbortSignal;
78
+ perRequest?: PerRequestCounters;
79
+ }
80
+
65
81
  /**
66
82
  * New Query class that uses DAG internally for better modularity and extensibility.
67
83
  *
@@ -85,6 +101,8 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
85
101
  private trx: SQL | undefined;
86
102
  private skipPreparedCache: boolean = false;
87
103
  private skipComponentCache: boolean = false;
104
+ private execSignal?: AbortSignal;
105
+ private execPerRequest?: PerRequestCounters;
88
106
 
89
107
  /** Component constructors added to this query for type-safe access */
90
108
  private _componentCtors: ComponentConstructor[] = [];
@@ -110,12 +128,12 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
110
128
  return this;
111
129
  }
112
130
 
113
- public async findOneById(id: string): Promise<TypedEntity<TComponents> | null> {
131
+ public async findOneById(id: string, opts?: QueryExecOptions): Promise<TypedEntity<TComponents> | null> {
114
132
  // Validate ID to prevent PostgreSQL UUID parsing errors
115
133
  if (!id || typeof id !== 'string' || id.trim() === '') {
116
134
  return null;
117
135
  }
118
- const entities = await this.findById(id).exec();
136
+ const entities = await this.findById(id).exec(opts);
119
137
  return entities.length > 0 ? entities[0]! : null;
120
138
  }
121
139
 
@@ -300,7 +318,8 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
300
318
  return this;
301
319
  }
302
320
 
303
- public count(): Promise<number> {
321
+ public count(opts?: QueryExecOptions): Promise<number> {
322
+ this.applyExecOptions(opts);
304
323
  return new Promise<number>((resolve, reject) => {
305
324
  const timeout = setTimeout(() => {
306
325
  logger.error(`Query count execution timeout`);
@@ -318,6 +337,17 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
318
337
  });
319
338
  }
320
339
 
340
+ /**
341
+ * Apply terminal-method options to instance fields so internal helpers
342
+ * (doCount, doExec, populateComponents, doAggregate, …) can read them
343
+ * without threading parameters through every private method.
344
+ */
345
+ private applyExecOptions(opts?: QueryExecOptions): void {
346
+ if (!opts) return;
347
+ if (opts.signal !== undefined) this.execSignal = opts.signal;
348
+ if (opts.perRequest !== undefined) this.execPerRequest = opts.perRequest;
349
+ }
350
+
321
351
  /**
322
352
  * Get an estimated count using PostgreSQL statistics.
323
353
  * Much faster than exact count() for large tables - O(1) instead of O(n).
@@ -333,7 +363,8 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
333
363
  * const approxCount = await new Query().with(User).estimatedCount(User);
334
364
  * console.log(`Approximately ${approxCount} users`);
335
365
  */
336
- public async estimatedCount(component: new (...args: any[]) => BaseComponent): Promise<number> {
366
+ public async estimatedCount(component: new (...args: any[]) => BaseComponent, opts?: QueryExecOptions): Promise<number> {
367
+ this.applyExecOptions(opts);
337
368
  const typeId = ComponentRegistry.getComponentId(component.name);
338
369
  if (!typeId) {
339
370
  throw new Error(`Component ${component.name} not registered`);
@@ -354,7 +385,7 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
354
385
  ? `SELECT reltuples::bigint AS estimate FROM pg_class WHERE relname = $1`
355
386
  : `SELECT reltuples::bigint AS estimate FROM pg_class WHERE relname = 'entity_components'`;
356
387
 
357
- const result = await dbConn.unsafe(sql, [tableName || 'entity_components']);
388
+ const result = await timedUnsafe<any[]>(dbConn, sql, [tableName || 'entity_components'], this.execSignal, this.execPerRequest);
358
389
 
359
390
  if (!result || result.length === 0 || result[0].estimate === null) {
360
391
  // Fallback to exact count if statistics not available
@@ -410,13 +441,13 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
410
441
 
411
442
  if (this.skipPreparedCache) {
412
443
  // Bypass cache - execute directly
413
- countResult = await dbConn.unsafe(countSql, result.params);
444
+ countResult = await timedUnsafe<any[]>(dbConn, countSql, result.params, this.execSignal, this.execPerRequest);
414
445
  } else {
415
446
  // Check prepared statement cache
416
447
  // Add 'count:' prefix to differentiate count queries from exec queries
417
448
  const cacheKey = 'count:' + this.context.generateCacheKey();
418
449
  const { statement, isHit } = await preparedStatementCache.getOrCreate(countSql, cacheKey, dbConn);
419
- countResult = await preparedStatementCache.execute(statement, result.params, dbConn);
450
+ countResult = await preparedStatementCache.execute(statement, result.params, dbConn, this.execSignal, this.execPerRequest);
420
451
  }
421
452
 
422
453
  // Debug logging
@@ -430,14 +461,11 @@ class Query<TComponents extends readonly ComponentConstructor[] = []> {
430
461
  console.log('---');
431
462
  }
432
463
 
433
- // Validate params before execution to catch UUID errors early
434
- for (let i = 0; i < result.params.length; i++) {
435
- const param = result.params[i];
436
- if (param === '' || (typeof param === 'string' && param.trim() === '')) {
437
- logger.error(`Empty string parameter detected at position ${i + 1} in count query`);
438
- throw new Error(`Query count parameter $${i + 1} is an empty string. This will cause PostgreSQL UUID parsing errors.`);
439
- }
440
- }
464
+ // Empty-string params are legitimate for text-field filters
465
+ // (`c.data->>'field' = ''`). UUID-typed params never reach this
466
+ // point empty — findById guards at entry; cursor/excluded IDs come
467
+ // from saved entities. PG emits a clear error if a UUID cast meets
468
+ // an empty string at execution time.
441
469
 
442
470
  // Safely extract count from result - handle undefined/null cases
443
471
  if (!countResult || countResult.length === 0 || countResult[0] === undefined) {
@@ -606,11 +634,11 @@ AND c.deleted_at IS NULL`;
606
634
  let aggregateResult: any[];
607
635
 
608
636
  if (this.skipPreparedCache) {
609
- aggregateResult = await dbConn.unsafe(aggregateSql, result.params);
637
+ aggregateResult = await timedUnsafe<any[]>(dbConn, aggregateSql, result.params, this.execSignal, this.execPerRequest);
610
638
  } else {
611
639
  const cacheKey = `${aggregateType.toLowerCase()}:${typeId}:${field}:` + this.context.generateCacheKey();
612
640
  const { statement } = await preparedStatementCache.getOrCreate(aggregateSql, cacheKey, dbConn);
613
- aggregateResult = await preparedStatementCache.execute(statement, result.params, dbConn);
641
+ aggregateResult = await preparedStatementCache.execute(statement, result.params, dbConn, this.execSignal, this.execPerRequest);
614
642
  }
615
643
 
616
644
  // Debug logging
@@ -623,14 +651,8 @@ AND c.deleted_at IS NULL`;
623
651
  console.log('---');
624
652
  }
625
653
 
626
- // Validate params
627
- for (let i = 0; i < result.params.length; i++) {
628
- const param = result.params[i];
629
- if (param === '' || (typeof param === 'string' && param.trim() === '')) {
630
- logger.error(`Empty string parameter detected at position ${i + 1} in ${aggregateType} query`);
631
- throw new Error(`Query ${aggregateType} parameter $${i + 1} is an empty string.`);
632
- }
633
- }
654
+ // Empty-string params are legitimate for text-field filters; see
655
+ // comment above in doCount.
634
656
 
635
657
  // Extract result
636
658
  if (!aggregateResult || aggregateResult.length === 0 || aggregateResult[0] === undefined) {
@@ -654,7 +676,8 @@ AND c.deleted_at IS NULL`;
654
676
  * @returns Promise resolving to array of TypedEntity with accumulated component types
655
677
  */
656
678
  @timed("Query.exec")
657
- public async exec(): Promise<TypedEntity<TComponents>[]> {
679
+ public async exec(opts?: QueryExecOptions): Promise<TypedEntity<TComponents>[]> {
680
+ this.applyExecOptions(opts);
658
681
  // Apply default LIMIT so unbounded queries cannot load entire tables
659
682
  // into memory. Configurable via BUNSANE_DEFAULT_QUERY_LIMIT, 0 to
660
683
  // disable. When the default is applied without an explicit .take(),
@@ -794,14 +817,11 @@ AND c.deleted_at IS NULL`;
794
817
  console.log('---');
795
818
  }
796
819
 
797
- // Validate params before execution to catch UUID errors early
798
- for (let i = 0; i < result.params.length; i++) {
799
- const param = result.params[i];
800
- if (param === '' || (typeof param === 'string' && param.trim() === '')) {
801
- logger.error(`Empty string parameter detected at position ${i + 1}: SQL=${result.sql.substring(0, 200)}`);
802
- throw new Error(`Query parameter $${i + 1} is an empty string. This will cause PostgreSQL UUID parsing errors. SQL: ${result.sql.substring(0, 100)}...`);
803
- }
804
- }
820
+ // Empty-string params are legitimate for text-field filters
821
+ // (`c.data->>'field' = ''`). UUID-typed params never reach this
822
+ // point empty — findById guards at entry; cursor/excluded IDs
823
+ // originate from saved entities. PG emits a clear error at
824
+ // execution time if a UUID cast meets an empty string.
805
825
 
806
826
  // Validate parameters before execution
807
827
  for (let i = 0; i < result.params.length; i++) {
@@ -818,12 +838,12 @@ AND c.deleted_at IS NULL`;
818
838
  if (this.orQuery || this.skipPreparedCache) {
819
839
  // For OR queries or explicit cache bypass, execute directly
820
840
  // This avoids potential parameter type inference issues with Bun's SQL
821
- entities = await dbConn.unsafe(result.sql, result.params);
841
+ entities = await timedUnsafe<any[]>(dbConn, result.sql, result.params, this.execSignal, this.execPerRequest);
822
842
  } else {
823
843
  // Check prepared statement cache for regular queries
824
844
  const cacheKey = this.context.generateCacheKey();
825
845
  const { statement, isHit } = await preparedStatementCache.getOrCreate(result.sql, cacheKey, dbConn);
826
- entities = await preparedStatementCache.execute(statement, result.params, dbConn);
846
+ entities = await preparedStatementCache.execute(statement, result.params, dbConn, this.execSignal, this.execPerRequest);
827
847
  }
828
848
 
829
849
  // Convert to Entity objects
@@ -883,32 +903,32 @@ AND c.deleted_at IS NULL`;
883
903
  // Single component type - use direct partition if available
884
904
  const partitionTableName = ComponentRegistry.getPartitionTableName(componentTypeIds[0]!);
885
905
  if (partitionTableName) {
886
- components = await dbConn.unsafe(`
906
+ components = await timedUnsafe<any[]>(dbConn, `
887
907
  SELECT id, entity_id, type_id, data
888
908
  FROM ${partitionTableName}
889
909
  WHERE entity_id IN ${entityIdList.sql}
890
910
  AND type_id IN ${typeIdList.sql}
891
911
  AND deleted_at IS NULL
892
- `, [...entityIdList.params, ...typeIdList.params]);
912
+ `, [...entityIdList.params, ...typeIdList.params], this.execSignal, this.execPerRequest);
893
913
  } else {
894
914
  // Fallback to parent table
895
- components = await dbConn.unsafe(`
915
+ components = await timedUnsafe<any[]>(dbConn, `
896
916
  SELECT id, entity_id, type_id, data
897
917
  FROM components
898
918
  WHERE entity_id IN ${entityIdList.sql}
899
919
  AND type_id IN ${typeIdList.sql}
900
920
  AND deleted_at IS NULL
901
- `, [...entityIdList.params, ...typeIdList.params]);
921
+ `, [...entityIdList.params, ...typeIdList.params], this.execSignal, this.execPerRequest);
902
922
  }
903
923
  } else {
904
924
  // Multiple types or direct partition disabled - use parent table
905
- components = await dbConn.unsafe(`
925
+ components = await timedUnsafe<any[]>(dbConn, `
906
926
  SELECT id, entity_id, type_id, data
907
927
  FROM components
908
928
  WHERE entity_id IN ${entityIdList.sql}
909
929
  AND type_id IN ${typeIdList.sql}
910
930
  AND deleted_at IS NULL
911
- `, [...entityIdList.params, ...typeIdList.params]);
931
+ `, [...entityIdList.params, ...typeIdList.params], this.execSignal, this.execPerRequest);
912
932
  }
913
933
 
914
934
  // Get metadata storage for Date deserialization
@@ -957,7 +977,8 @@ AND c.deleted_at IS NULL`;
957
977
  * Execute query with EXPLAIN ANALYZE for performance debugging
958
978
  * Returns the query plan and execution statistics
959
979
  */
960
- public async explainAnalyze(buffers: boolean = true): Promise<string> {
980
+ public async explainAnalyze(buffers: boolean = true, opts?: QueryExecOptions): Promise<string> {
981
+ this.applyExecOptions(opts);
961
982
  // Reset context for fresh execution
962
983
  this.context.reset();
963
984
 
@@ -1005,7 +1026,7 @@ AND c.deleted_at IS NULL`;
1005
1026
  }
1006
1027
 
1007
1028
  // Execute the EXPLAIN ANALYZE query
1008
- const explainResult = await dbConn.unsafe(explainSql, result.params);
1029
+ const explainResult = await timedUnsafe<any[]>(dbConn, explainSql, result.params, this.execSignal, this.execPerRequest);
1009
1030
 
1010
1031
  // Format the result
1011
1032
  return explainResult.map((row: any) => row['QUERY PLAN']).join('\n');
@@ -1021,10 +1042,6 @@ AND c.deleted_at IS NULL`;
1021
1042
  static filterOp = FilterOp;
1022
1043
 
1023
1044
  public static filter(field: string, operator: FilterOperator, value: any): QueryFilter {
1024
- // Validate value to catch empty strings early
1025
- if (value === '' || (typeof value === 'string' && value.trim() === '')) {
1026
- throw new Error(`Query.filter: Cannot create filter for field "${field}" with empty string value. This would cause PostgreSQL UUID parsing errors.`);
1027
- }
1028
1045
  return { field, operator, value };
1029
1046
  }
1030
1047
 
@@ -3,7 +3,13 @@ import ApplicationLifecycle, {ApplicationPhase, type PhaseChangeEvent} from "../
3
3
  import { generateGraphQLSchemaV2 } from "../gql";
4
4
  import { GraphQLSchema } from "graphql";
5
5
 
6
- class ServiceRegistry {
6
+ /**
7
+ * ServiceRegistry is a singleton. The default export and the re-exported
8
+ * named `ServiceRegistry` from `service/index.ts` both resolve to the
9
+ * singleton instance (for backward compatibility). When you need the class
10
+ * itself (for typing or subclassing), import `ServiceRegistryClass`.
11
+ */
12
+ export class ServiceRegistry {
7
13
  static #instance: ServiceRegistry;
8
14
 
9
15
  private services: Map<string, BaseService> = new Map();
package/service/index.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import BaseService from "./Service";
2
- import ServiceRegistry from "./ServiceRegistry";
2
+ import ServiceRegistry from "./ServiceRegistry";
3
+ import { ServiceRegistry as ServiceRegistryClass } from "./ServiceRegistry";
3
4
  import { httpEndpoint } from "../rest";
4
5
 
5
6
  export {
6
7
  BaseService,
7
- ServiceRegistry
8
+ ServiceRegistry,
9
+ ServiceRegistryClass,
8
10
  }
9
11
 
10
12
  // Shorthand decorators for HTTP methods
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Integration tests for `createRequestLoaders` AbortSignal threading.
3
+ *
4
+ * The GraphQL request plugin (core/RequestContext.ts) wires the request's
5
+ * AbortSignal into each DataLoader's `db.unsafe()` call via timedUnsafe.
6
+ * On abort the in-flight query is cancelled, releasing the backend
7
+ * connection back to pgbouncer.
8
+ */
9
+ import { describe, test, expect, beforeAll, beforeEach } from 'bun:test';
10
+ import db from '../../../database';
11
+ import { Entity } from '../../../core/Entity';
12
+ import { createRequestLoaders } from '../../../core/RequestLoaders';
13
+ import { ComponentRegistry } from '../../../core/components';
14
+ import { TestUser } from '../../fixtures/components';
15
+ import { createTestContext, ensureComponentsRegistered } from '../../utils';
16
+
17
+ describe('RequestLoaders AbortSignal', () => {
18
+ const ctx = createTestContext();
19
+ let seededEntity: Entity;
20
+ let testUserTypeId: string;
21
+
22
+ beforeAll(async () => {
23
+ await ensureComponentsRegistered(TestUser);
24
+ testUserTypeId = ComponentRegistry.getComponentId('TestUser')!;
25
+ expect(testUserTypeId).toBeTruthy();
26
+ });
27
+
28
+ beforeEach(async () => {
29
+ // Fresh seed per test — createTestContext's afterEach cleans tracked
30
+ // entities, so seeds set up once would disappear after the first
31
+ // test in the suite runs.
32
+ seededEntity = ctx.tracker.create();
33
+ seededEntity.add(TestUser, { name: 'loader-seed', email: 'l@e.com', age: 1 });
34
+ await seededEntity.save();
35
+ await Entity.drainPendingSideEffects();
36
+ });
37
+
38
+ test('entityById loader rejects when pre-aborted', async () => {
39
+ const controller = new AbortController();
40
+ controller.abort(new Error('pre-aborted'));
41
+
42
+ const loaders = createRequestLoaders(db, undefined, controller.signal);
43
+ await expect(loaders.entityById.load(seededEntity.id)).rejects.toBeDefined();
44
+ });
45
+
46
+ test('componentsByEntityType loader rejects when pre-aborted', async () => {
47
+ const controller = new AbortController();
48
+ controller.abort(new Error('pre-aborted'));
49
+
50
+ const loaders = createRequestLoaders(db, undefined, controller.signal);
51
+ await expect(
52
+ loaders.componentsByEntityType.load({
53
+ entityId: seededEntity.id,
54
+ typeId: testUserTypeId,
55
+ }),
56
+ ).rejects.toBeDefined();
57
+ });
58
+
59
+ test('loaders without signal still work (backwards compatible)', async () => {
60
+ const loaders = createRequestLoaders(db);
61
+ const ent = await loaders.entityById.load(seededEntity.id);
62
+ expect(ent?.id).toBe(seededEntity.id);
63
+ });
64
+
65
+ test('perRequest counters track DataLoader invocations', async () => {
66
+ const perRequest = {
67
+ dbQueryCount: 0,
68
+ dataLoaderCalls: { entity: 0, component: 0, relation: 0 },
69
+ };
70
+ const loaders = createRequestLoaders(db, undefined, undefined, perRequest);
71
+
72
+ await loaders.entityById.load(seededEntity.id);
73
+ await loaders.componentsByEntityType.load({
74
+ entityId: seededEntity.id,
75
+ typeId: testUserTypeId,
76
+ });
77
+
78
+ expect(perRequest.dataLoaderCalls.entity).toBeGreaterThanOrEqual(1);
79
+ expect(perRequest.dataLoaderCalls.component).toBeGreaterThanOrEqual(1);
80
+ expect(perRequest.dbQueryCount).toBeGreaterThan(0);
81
+ });
82
+ });
@@ -0,0 +1,66 @@
1
+ /**
2
+ * Integration tests for Query.exec / Query.count AbortSignal propagation.
3
+ *
4
+ * The framework wall-clock timeout (core/app/requestRouter.ts) aborts a
5
+ * controller on 30s. The plugin in core/RequestContext.ts threads the
6
+ * request's AbortSignal into Query.exec via `{ signal }` options. These
7
+ * tests prove the abort actually cancels the underlying Bun SQL query
8
+ * (releasing the pgbouncer-backed connection) rather than just rejecting
9
+ * the outer promise.
10
+ */
11
+ import { describe, test, expect, beforeAll } from 'bun:test';
12
+ import { Query } from '../../../query/Query';
13
+ import { TestUser } from '../../fixtures/components';
14
+ import { createTestContext, ensureComponentsRegistered } from '../../utils';
15
+
16
+ describe('Query AbortSignal propagation', () => {
17
+ const ctx = createTestContext();
18
+
19
+ beforeAll(async () => {
20
+ await ensureComponentsRegistered(TestUser);
21
+
22
+ // Seed a small dataset so queries actually hit the DB.
23
+ for (let i = 0; i < 5; i++) {
24
+ const e = ctx.tracker.create();
25
+ e.add(TestUser, { name: `abort-seed-${i}`, email: `a${i}@e.com`, age: i });
26
+ await e.save();
27
+ }
28
+ });
29
+
30
+ test('exec() with pre-aborted signal rejects without running query', async () => {
31
+ const controller = new AbortController();
32
+ controller.abort(new Error('pre-aborted'));
33
+
34
+ const promise = new Query().with(TestUser).exec({ signal: controller.signal });
35
+ await expect(promise).rejects.toBeDefined();
36
+ });
37
+
38
+ test('exec() rejects when signal aborts mid-flight', async () => {
39
+ const controller = new AbortController();
40
+ queueMicrotask(() => controller.abort(new Error('mid-flight')));
41
+
42
+ const promise = new Query().with(TestUser).exec({ signal: controller.signal });
43
+ await expect(promise).rejects.toBeDefined();
44
+ });
45
+
46
+ test('exec() without signal still works (backwards compatible)', async () => {
47
+ const rows = await new Query().with(TestUser).take(5).exec();
48
+ expect(Array.isArray(rows)).toBe(true);
49
+ });
50
+
51
+ test('count() respects signal abort', async () => {
52
+ const controller = new AbortController();
53
+ controller.abort(new Error('pre-aborted'));
54
+
55
+ const promise = new Query().with(TestUser).count({ signal: controller.signal });
56
+ await expect(promise).rejects.toBeDefined();
57
+ });
58
+
59
+ test('perRequest counters increment when supplied', async () => {
60
+ const perRequest = { dbQueryCount: 0 };
61
+ await new Query().with(TestUser).take(5).exec({ perRequest });
62
+ // Exec performs at least one DB query (count guard / select). Real
63
+ // count depends on prepared-cache state; assert non-zero only.
64
+ expect(perRequest.dbQueryCount).toBeGreaterThan(0);
65
+ });
66
+ });
@@ -3,7 +3,7 @@
3
3
  * Tests cache configuration and management
4
4
  */
5
5
  import { describe, test, expect, beforeEach, afterEach, mock } from 'bun:test';
6
- import { CacheManager } from '../../../core/cache/CacheManager';
6
+ import { CacheManager, COMPONENT_TOMBSTONE } from '../../../core/cache/CacheManager';
7
7
  import { MemoryCache } from '../../../core/cache/MemoryCache';
8
8
  import { MultiLevelCache } from '../../../core/cache/MultiLevelCache';
9
9
 
@@ -141,6 +141,26 @@ describe('CacheManager', () => {
141
141
  expect(result).toBeNull();
142
142
  });
143
143
 
144
+ test('invalidateEntities clears entity + all component caches for a batch', async () => {
145
+ const provider = cacheManager.getProvider();
146
+ // Two entities, each with cached entity entry + one component
147
+ await provider.set('entity:e1', 'e1', 3600000);
148
+ await provider.set('entity:e2', 'e2', 3600000);
149
+ await provider.set('component:e1:t1', { data: 'a' }, 3600000);
150
+ await provider.set('component:e2:t1', { data: 'b' }, 3600000);
151
+
152
+ await cacheManager.invalidateEntities(['e1', 'e2']);
153
+
154
+ expect(await cacheManager.getEntity('e1')).toBeNull();
155
+ expect(await cacheManager.getEntity('e2')).toBeNull();
156
+ expect(await cacheManager.getComponentsByEntity('e1', 't1')).toBeNull();
157
+ expect(await cacheManager.getComponentsByEntity('e2', 't1')).toBeNull();
158
+ });
159
+
160
+ test('invalidateEntities is a noop for empty list', async () => {
161
+ await expect(cacheManager.invalidateEntities([])).resolves.toBeUndefined();
162
+ });
163
+
144
164
  test('getEntities returns null for missing entities', async () => {
145
165
  const results = await cacheManager.getEntities(['id1', 'id2', 'id3']);
146
166
  expect(results.length).toBe(3);
@@ -190,6 +210,137 @@ describe('CacheManager', () => {
190
210
  });
191
211
  });
192
212
 
213
+ describe('component negative cache (tombstones)', () => {
214
+ beforeEach(async () => {
215
+ await cacheManager.initialize({
216
+ enabled: true,
217
+ provider: 'memory',
218
+ strategy: 'write-through',
219
+ defaultTTL: 3600000,
220
+ entity: { enabled: true, ttl: 3600000 },
221
+ component: {
222
+ enabled: true,
223
+ ttl: 1800000,
224
+ negativeCacheEnabled: true,
225
+ negativeCacheTtl: 60_000,
226
+ },
227
+ query: { enabled: false, ttl: 300000, maxSize: 10000 },
228
+ });
229
+ });
230
+
231
+ test('setComponentsWriteThrough writes tombstones for absent requested keys', async () => {
232
+ const requested = [
233
+ { entityId: 'e1', typeId: 't1' },
234
+ { entityId: 'e2', typeId: 't2' },
235
+ ];
236
+ await cacheManager.setComponentsWriteThrough([], requested);
237
+ const results = await cacheManager.getComponents(requested);
238
+ expect(results[0]).toBe(COMPONENT_TOMBSTONE);
239
+ expect(results[1]).toBe(COMPONENT_TOMBSTONE);
240
+ });
241
+
242
+ test('found rows are written, absent are tombstoned, single setMany', async () => {
243
+ const requested = [
244
+ { entityId: 'e1', typeId: 't1' },
245
+ { entityId: 'e2', typeId: 't2' },
246
+ ];
247
+ const found = [{
248
+ id: 'c1', entityId: 'e1', typeId: 't1',
249
+ data: { x: 1 }, createdAt: new Date(), updatedAt: new Date(), deletedAt: null,
250
+ }];
251
+ await cacheManager.setComponentsWriteThrough(found, requested);
252
+ const results = await cacheManager.getComponents(requested);
253
+ expect((results[0] as any)?.id).toBe('c1');
254
+ expect(results[1]).toBe(COMPONENT_TOMBSTONE);
255
+ });
256
+
257
+ test('invalidateComponent drops tombstone', async () => {
258
+ await cacheManager.setComponentsWriteThrough([], [{ entityId: 'e1', typeId: 't1' }]);
259
+ await cacheManager.invalidateComponent('e1', 't1');
260
+ const results = await cacheManager.getComponents([{ entityId: 'e1', typeId: 't1' }]);
261
+ expect(results[0]).toBeNull();
262
+ });
263
+
264
+ test('negativeCacheEnabled=false skips tombstone writes (backward compat)', async () => {
265
+ await cacheManager.initialize({
266
+ enabled: true,
267
+ provider: 'memory',
268
+ strategy: 'write-through',
269
+ defaultTTL: 3600000,
270
+ entity: { enabled: true, ttl: 3600000 },
271
+ component: { enabled: true, ttl: 1800000 },
272
+ query: { enabled: false, ttl: 300000, maxSize: 10000 },
273
+ });
274
+ await cacheManager.setComponentsWriteThrough([], [{ entityId: 'e1', typeId: 't1' }]);
275
+ const results = await cacheManager.getComponents([{ entityId: 'e1', typeId: 't1' }]);
276
+ expect(results[0]).toBeNull();
277
+ });
278
+
279
+ test('legacy 2-arg signature (components, ttl) still works', async () => {
280
+ const data = [{
281
+ id: 'c1', entityId: 'e1', typeId: 't1',
282
+ data: { x: 1 }, createdAt: new Date(), updatedAt: new Date(), deletedAt: null,
283
+ }];
284
+ await cacheManager.setComponentsWriteThrough(data, 3600_000);
285
+ const results = await cacheManager.getComponents([{ entityId: 'e1', typeId: 't1' }]);
286
+ expect((results[0] as any)?.id).toBe('c1');
287
+ });
288
+ });
289
+
290
+ describe('relation negative cache', () => {
291
+ beforeEach(async () => {
292
+ await cacheManager.initialize({
293
+ enabled: true,
294
+ provider: 'memory',
295
+ strategy: 'write-through',
296
+ defaultTTL: 3600000,
297
+ entity: { enabled: true, ttl: 3600000 },
298
+ component: { enabled: true, ttl: 1800000 },
299
+ relation: { negativeCacheEnabled: true, negativeCacheTtl: 60_000 },
300
+ query: { enabled: false, ttl: 300000, maxSize: 10000 },
301
+ });
302
+ });
303
+
304
+ test('setRelationsEmpty + getRelationsEmpty round trip', async () => {
305
+ const keys = [
306
+ { entityId: 'u1', relationField: 'orders', relatedType: 'Order', foreignKey: 'user_id' },
307
+ { entityId: 'u2', relationField: 'orders', relatedType: 'Order', foreignKey: 'user_id' },
308
+ ];
309
+ await cacheManager.setRelationsEmpty(keys);
310
+ const flags = await cacheManager.getRelationsEmpty(keys);
311
+ expect(flags).toEqual([true, true]);
312
+ });
313
+
314
+ test('getRelationsEmpty returns false for keys never tombstoned', async () => {
315
+ const flags = await cacheManager.getRelationsEmpty([
316
+ { entityId: 'u-fresh', relationField: 'orders', relatedType: 'Order', foreignKey: 'user_id' },
317
+ ]);
318
+ expect(flags).toEqual([false]);
319
+ });
320
+
321
+ test('invalidateRelation drops tombstone', async () => {
322
+ const key = { entityId: 'u1', relationField: 'orders', relatedType: 'Order', foreignKey: 'user_id' };
323
+ await cacheManager.setRelationsEmpty([key]);
324
+ await cacheManager.invalidateRelation(key.entityId, key.relationField, key.relatedType, key.foreignKey);
325
+ const flags = await cacheManager.getRelationsEmpty([key]);
326
+ expect(flags).toEqual([false]);
327
+ });
328
+
329
+ test('relation cache disabled returns all-false (no-op)', async () => {
330
+ await cacheManager.initialize({
331
+ enabled: true,
332
+ provider: 'memory',
333
+ strategy: 'write-through',
334
+ defaultTTL: 3600000,
335
+ relation: { negativeCacheEnabled: false },
336
+ });
337
+ const key = { entityId: 'u1', relationField: 'orders', relatedType: 'Order', foreignKey: 'user_id' };
338
+ await cacheManager.setRelationsEmpty([key]);
339
+ const flags = await cacheManager.getRelationsEmpty([key]);
340
+ expect(flags).toEqual([false]);
341
+ });
342
+ });
343
+
193
344
  describe('cache disabled', () => {
194
345
  beforeEach(async () => {
195
346
  await cacheManager.initialize({ enabled: false });