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.
- package/.claude/scheduled_tasks.lock +1 -0
- package/CHANGELOG.md +104 -0
- package/CLAUDE.md +20 -0
- package/config/cache.config.ts +35 -1
- package/core/App.ts +24 -1060
- package/core/ArcheType.ts +78 -2110
- package/core/Entity.ts +136 -41
- package/core/RequestContext.ts +85 -36
- package/core/RequestLoaders.ts +89 -31
- package/core/SchedulerManager.ts +13 -13
- package/core/app/bootstrap.ts +133 -0
- package/core/app/cors.ts +94 -0
- package/core/app/graphqlSetup.ts +56 -0
- package/core/app/healthEndpoints.ts +31 -0
- package/core/app/metricsCollector.ts +27 -0
- package/core/app/preparedStatementWarmup.ts +55 -0
- package/core/app/processHandlers.ts +43 -0
- package/core/app/requestRouter.ts +309 -0
- package/core/app/restRegistry.ts +72 -0
- package/core/app/shutdown.ts +97 -0
- package/core/app/studioRouter.ts +83 -0
- package/core/archetype/customTypes.ts +100 -0
- package/core/archetype/decorators.ts +171 -0
- package/core/archetype/fieldResolvers.ts +621 -0
- package/core/archetype/helpers.ts +29 -0
- package/core/archetype/relationLoader.ts +118 -0
- package/core/archetype/schemaBuilder.ts +141 -0
- package/core/archetype/weaver.ts +218 -0
- package/core/archetype/zodSchemaBuilder.ts +527 -0
- package/core/cache/CacheManager.ts +144 -9
- package/core/components/BaseComponent.ts +12 -2
- package/core/middleware/AccessLog.ts +8 -1
- package/database/PreparedStatementCache.ts +17 -16
- package/database/cancellable.ts +22 -0
- package/database/instrumentedDb.ts +141 -0
- package/docs/RFC_APP_REFACTOR.md +248 -0
- package/docs/RFC_REFACTOR_TARGETS.md +251 -0
- package/package.json +1 -1
- package/query/ComponentInclusionNode.ts +5 -5
- package/query/Query.ts +65 -48
- package/service/ServiceRegistry.ts +7 -1
- package/service/index.ts +4 -2
- package/tests/integration/loaders/RequestLoaders.abort.test.ts +82 -0
- package/tests/integration/query/Query.abort.test.ts +66 -0
- package/tests/unit/cache/CacheManager.test.ts +152 -1
- package/tests/unit/database/cancellable.test.ts +81 -0
- package/tests/unit/database/instrumentedDb.test.ts +160 -0
- package/tests/unit/entity/Entity.components.test.ts +73 -0
- package/tests/unit/entity/Entity.drainSideEffects.test.ts +51 -0
- package/tests/unit/entity/Entity.reload.test.ts +63 -0
- package/tests/unit/entity/Entity.requireComponents.test.ts +72 -0
- package/tests/unit/query/Query.emptyString.test.ts +69 -0
- package/tests/unit/query/Query.test.ts +6 -4
- 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
|
|
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
|
|
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
|
-
//
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
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
|
|
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
|
-
//
|
|
627
|
-
|
|
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
|
-
//
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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 });
|