bunsane 0.3.1 → 0.4.0
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/CHANGELOG.md +445 -318
- package/config/cache.config.ts +35 -1
- package/core/App.ts +24 -1064
- package/core/ArcheType.ts +78 -2110
- package/core/BatchLoader.ts +56 -32
- package/core/Entity.ts +85 -1043
- package/core/EntityHookManager.ts +52 -754
- package/core/Logger.ts +10 -0
- package/core/RequestContext.ts +64 -6
- package/core/RequestLoaders.ts +187 -36
- package/core/SchedulerManager.ts +28 -600
- package/core/app/bootstrap.ts +133 -0
- package/core/app/cors.ts +85 -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 +15 -0
- package/core/app/processHandlers.ts +43 -0
- package/core/app/requestRouter.ts +310 -0
- package/core/app/restRegistry.ts +80 -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 +666 -0
- package/core/archetype/helpers.ts +29 -0
- package/core/archetype/relationLoader.ts +161 -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 +173 -267
- package/core/cache/CompressionUtils.ts +34 -3
- package/core/cache/MemoryCache.ts +40 -37
- package/core/cache/RedisCache.ts +4 -4
- package/core/cache/health.ts +30 -0
- package/core/cache/invalidation.ts +96 -0
- package/core/cache/strategies/writeInvalidate.ts +111 -0
- package/core/cache/strategies/writeThrough.ts +233 -0
- package/core/components/BaseComponent.ts +16 -8
- package/core/components/ComponentRegistry.ts +28 -0
- package/core/decorators/IndexedField.ts +1 -1
- package/core/entity/cacheStrategies.ts +97 -0
- package/core/entity/componentAccess.ts +364 -0
- package/core/entity/finders.ts +202 -0
- package/core/entity/pendingOps.ts +72 -0
- package/core/entity/saveEntity.ts +377 -0
- package/core/hooks/dispatcher.ts +439 -0
- package/core/hooks/guards.ts +155 -0
- package/core/hooks/registry.ts +247 -0
- package/core/metadata/definitions/Component.ts +1 -1
- package/core/metadata/index.ts +15 -4
- package/core/middleware/AccessLog.ts +8 -1
- package/core/middleware/RateLimit.ts +102 -105
- package/core/middleware/RequestId.ts +2 -9
- package/core/middleware/SecurityHeaders.ts +2 -11
- package/core/middleware/headers.ts +28 -0
- package/core/remote/OutboxWorker.ts +213 -183
- package/core/remote/RemoteManager.ts +401 -400
- package/core/remote/types.ts +153 -151
- package/core/requestScope.ts +34 -0
- package/core/scheduler/cronEvaluator.ts +174 -0
- package/core/scheduler/lifecycleHooks.ts +21 -0
- package/core/scheduler/lockCoordinator.ts +27 -0
- package/core/scheduler/metrics.ts +14 -0
- package/core/scheduler/taskRunner.ts +420 -0
- package/database/DatabaseHelper.ts +128 -101
- package/database/IndexingStrategy.ts +72 -2
- package/database/PreparedStatementCache.ts +20 -5
- package/database/cancellable.ts +35 -0
- package/database/index.ts +15 -3
- package/database/instrumentedDb.ts +141 -0
- package/endpoints/archetypes.ts +2 -8
- package/endpoints/tables.ts +6 -1
- package/gql/index.ts +1 -1
- package/gql/visitors/ResolverGeneratorVisitor.ts +25 -4
- package/package.json +22 -1
- package/query/CTENode.ts +5 -3
- package/query/ComponentInclusionNode.ts +240 -13
- package/query/OrNode.ts +6 -5
- package/query/Query.ts +203 -59
- package/query/QueryContext.ts +6 -0
- package/query/QueryDAG.ts +7 -2
- package/query/membershipSource.ts +66 -0
- package/storage/LocalStorageProvider.ts +8 -3
- package/studio/dist/assets/index-BMZ67Npg.js +254 -0
- package/studio/dist/assets/index-BpbuYz9g.css +1 -0
- package/studio/{index.html → dist/index.html} +3 -2
- package/swagger/generator.ts +11 -1
- package/upload/UploadManager.ts +8 -6
- package/utils/uuid.ts +40 -10
- package/.claude/settings.local.json +0 -47
- package/.prettierrc +0 -4
- package/.serena/memories/architectural-decision-no-dependency-injection.md +0 -76
- package/.serena/memories/architecture.md +0 -154
- package/.serena/memories/cache-interface-refactoring-2026-01-24.md +0 -165
- package/.serena/memories/code_style_and_conventions.md +0 -76
- package/.serena/memories/project_overview.md +0 -43
- package/.serena/memories/schema-dsl-plan.md +0 -107
- package/.serena/memories/suggested_commands.md +0 -80
- package/.serena/memories/typescript-compilation-status.md +0 -54
- package/.serena/project.yml +0 -114
- package/BunSane.jpg +0 -0
- package/CLAUDE.md +0 -198
- package/TODO.md +0 -2
- package/bun.lock +0 -302
- package/bunfig.toml +0 -10
- package/docs/SCALABILITY_PLAN.md +0 -175
- package/studio/bun.lock +0 -482
- package/studio/package.json +0 -39
- package/studio/postcss.config.js +0 -6
- package/studio/src/components/DataTable.tsx +0 -211
- package/studio/src/components/Layout.tsx +0 -13
- package/studio/src/components/PageContainer.tsx +0 -9
- package/studio/src/components/PageHeader.tsx +0 -13
- package/studio/src/components/SearchBar.tsx +0 -57
- package/studio/src/components/Sidebar.tsx +0 -294
- package/studio/src/components/ui/button.tsx +0 -56
- package/studio/src/components/ui/checkbox.tsx +0 -26
- package/studio/src/components/ui/input.tsx +0 -25
- package/studio/src/hooks/useDataTable.ts +0 -131
- package/studio/src/index.css +0 -36
- package/studio/src/lib/api.ts +0 -186
- package/studio/src/lib/utils.ts +0 -13
- package/studio/src/main.tsx +0 -17
- package/studio/src/pages/ArcheType.tsx +0 -239
- package/studio/src/pages/Components.tsx +0 -124
- package/studio/src/pages/EntityInspector.tsx +0 -302
- package/studio/src/pages/QueryRunner.tsx +0 -246
- package/studio/src/pages/Table.tsx +0 -94
- package/studio/src/pages/Welcome.tsx +0 -241
- package/studio/src/routes.tsx +0 -45
- package/studio/src/store/archeTypeSettings.ts +0 -30
- package/studio/src/store/studio.ts +0 -65
- package/studio/src/utils/columnHelpers.tsx +0 -114
- package/studio/studio-instructions.md +0 -81
- package/studio/tailwind.config.js +0 -77
- package/studio/utils.ts +0 -54
- package/studio/vite.config.js +0 -19
- package/tests/benchmark/BENCHMARK_DATABASES_PLAN.md +0 -338
- package/tests/benchmark/bunfig.toml +0 -9
- package/tests/benchmark/fixtures/EcommerceComponents.ts +0 -283
- package/tests/benchmark/fixtures/EcommerceDataGenerators.ts +0 -301
- package/tests/benchmark/fixtures/RelationTracker.ts +0 -159
- package/tests/benchmark/fixtures/index.ts +0 -6
- package/tests/benchmark/index.ts +0 -22
- package/tests/benchmark/noop-preload.ts +0 -3
- package/tests/benchmark/query-lateral-benchmark.test.ts +0 -372
- package/tests/benchmark/runners/BenchmarkLoader.ts +0 -132
- package/tests/benchmark/runners/index.ts +0 -4
- package/tests/benchmark/scenarios/query-benchmarks.test.ts +0 -465
- package/tests/benchmark/scripts/generate-db.ts +0 -344
- package/tests/benchmark/scripts/run-benchmarks.ts +0 -97
- package/tests/e2e/http.test.ts +0 -130
- package/tests/fixtures/archetypes/TestUserArchetype.ts +0 -21
- package/tests/fixtures/components/TestOrder.ts +0 -23
- package/tests/fixtures/components/TestProduct.ts +0 -23
- package/tests/fixtures/components/TestUser.ts +0 -20
- package/tests/fixtures/components/index.ts +0 -6
- package/tests/graphql/SchemaGeneration.test.ts +0 -90
- package/tests/graphql/builders/ResolverBuilder.test.ts +0 -223
- package/tests/graphql/builders/TypeDefBuilder.test.ts +0 -153
- package/tests/helpers/MockRedisClient.ts +0 -113
- package/tests/helpers/MockRedisStreamServer.ts +0 -448
- package/tests/integration/archetype/ArcheType.persistence.test.ts +0 -241
- package/tests/integration/cache/CacheInvalidation.test.ts +0 -259
- package/tests/integration/entity/Entity.persistence.test.ts +0 -333
- package/tests/integration/entity/Entity.saveTimeout.test.ts +0 -110
- package/tests/integration/query/Query.complexAnalysis.test.ts +0 -557
- package/tests/integration/query/Query.edgeCases.test.ts +0 -595
- package/tests/integration/query/Query.exec.test.ts +0 -576
- package/tests/integration/query/Query.explainAnalyze.test.ts +0 -233
- package/tests/integration/query/Query.jsonbArray.test.ts +0 -214
- package/tests/integration/remote/dlq.test.ts +0 -175
- package/tests/integration/remote/event-dispatch.test.ts +0 -114
- package/tests/integration/remote/outbox.test.ts +0 -130
- package/tests/integration/remote/rpc.test.ts +0 -177
- package/tests/pglite-setup.ts +0 -62
- package/tests/setup.ts +0 -164
- package/tests/stress/BenchmarkRunner.ts +0 -203
- package/tests/stress/DataSeeder.ts +0 -190
- package/tests/stress/StressTestReporter.ts +0 -229
- package/tests/stress/cursor-perf-test.ts +0 -171
- package/tests/stress/fixtures/RealisticComponents.ts +0 -235
- package/tests/stress/fixtures/StressTestComponents.ts +0 -58
- package/tests/stress/index.ts +0 -7
- package/tests/stress/scenarios/query-benchmarks.test.ts +0 -285
- package/tests/stress/scenarios/realistic-scenarios.test.ts +0 -1081
- package/tests/stress/scenarios/timeout-investigation.test.ts +0 -522
- package/tests/unit/BatchLoader.test.ts +0 -196
- package/tests/unit/archetype/ArcheType.test.ts +0 -107
- package/tests/unit/cache/CacheManager.test.ts +0 -367
- package/tests/unit/cache/MemoryCache.test.ts +0 -260
- package/tests/unit/cache/RedisCache.test.ts +0 -411
- package/tests/unit/entity/Entity.components.test.ts +0 -317
- package/tests/unit/entity/Entity.drainSideEffects.test.ts +0 -51
- package/tests/unit/entity/Entity.reload.test.ts +0 -63
- package/tests/unit/entity/Entity.requireComponents.test.ts +0 -72
- package/tests/unit/entity/Entity.test.ts +0 -345
- package/tests/unit/gql/depthLimit.test.ts +0 -203
- package/tests/unit/gql/operationMiddleware.test.ts +0 -293
- package/tests/unit/health/Health.test.ts +0 -129
- package/tests/unit/middleware/AccessLog.test.ts +0 -37
- package/tests/unit/middleware/Middleware.test.ts +0 -98
- package/tests/unit/middleware/RequestId.test.ts +0 -54
- package/tests/unit/middleware/SecurityHeaders.test.ts +0 -66
- package/tests/unit/query/FilterBuilder.test.ts +0 -111
- package/tests/unit/query/JsonbArrayBuilder.test.ts +0 -178
- package/tests/unit/query/Query.emptyString.test.ts +0 -69
- package/tests/unit/query/Query.test.ts +0 -310
- package/tests/unit/remote/CircuitBreaker.test.ts +0 -159
- package/tests/unit/remote/RemoteError.test.ts +0 -55
- package/tests/unit/remote/decorators.test.ts +0 -195
- package/tests/unit/remote/metrics.test.ts +0 -115
- package/tests/unit/remote/mockRedisStreamServer.test.ts +0 -104
- package/tests/unit/scheduler/DistributedLock.test.ts +0 -274
- package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +0 -95
- package/tests/unit/schema/schema-integration.test.ts +0 -426
- package/tests/unit/schema/schema.test.ts +0 -580
- package/tests/unit/storage/S3StorageProvider.test.ts +0 -567
- package/tests/unit/upload/RestUpload.test.ts +0 -267
- package/tests/unit/validateEnv.test.ts +0 -82
- package/tests/utils/entity-tracker.ts +0 -57
- package/tests/utils/index.ts +0 -13
- package/tests/utils/test-context.ts +0 -149
package/core/Logger.ts
CHANGED
|
@@ -27,3 +27,13 @@ export const logger = pino({
|
|
|
27
27
|
}
|
|
28
28
|
})
|
|
29
29
|
});
|
|
30
|
+
|
|
31
|
+
// pino-pretty serializes each log line synchronously on the main thread — 5-10x
|
|
32
|
+
// slower than production JSON output. Warn once so operators don't accidentally
|
|
33
|
+
// ship a pretty-print config to production.
|
|
34
|
+
if (usePretty && process.env.NODE_ENV === 'production') {
|
|
35
|
+
logger.warn(
|
|
36
|
+
'LOG_PRETTY=true is set in a production environment. ' +
|
|
37
|
+
'pino-pretty is 5-10x slower than JSON output and should not run in production.'
|
|
38
|
+
);
|
|
39
|
+
}
|
package/core/RequestContext.ts
CHANGED
|
@@ -4,6 +4,14 @@ import type { RequestLoaders } from './RequestLoaders';
|
|
|
4
4
|
import db from '../database';
|
|
5
5
|
import { CacheManager } from './cache/CacheManager';
|
|
6
6
|
import { getRequestId } from './middleware/RequestId';
|
|
7
|
+
import { runWithRequestScope } from './requestScope';
|
|
8
|
+
|
|
9
|
+
export interface RequestStats {
|
|
10
|
+
operationName: string;
|
|
11
|
+
dataLoaderCalls: { entity: number; component: number; relation: number };
|
|
12
|
+
dbQueryCount: number;
|
|
13
|
+
startTime: number;
|
|
14
|
+
}
|
|
7
15
|
|
|
8
16
|
declare module 'graphql-yoga' {
|
|
9
17
|
interface Context {
|
|
@@ -11,6 +19,8 @@ declare module 'graphql-yoga' {
|
|
|
11
19
|
loaders: RequestLoaders;
|
|
12
20
|
requestId: string;
|
|
13
21
|
cacheManager: CacheManager;
|
|
22
|
+
requestStats: RequestStats;
|
|
23
|
+
signal?: AbortSignal;
|
|
14
24
|
}
|
|
15
25
|
}
|
|
16
26
|
|
|
@@ -20,17 +30,65 @@ declare module 'graphql-yoga' {
|
|
|
20
30
|
* IMPORTANT: Loaders are mounted at context.loaders (NOT context.locals.loaders)
|
|
21
31
|
* to match what ArcheType.ts resolvers expect. This enables DataLoader batching
|
|
22
32
|
* for BelongsTo/HasMany relations, preventing N+1 queries.
|
|
33
|
+
*
|
|
34
|
+
* Also threads the request `AbortSignal` into Query/DataLoader DB calls so
|
|
35
|
+
* the framework's wall-clock timeout (handled in core/app/requestRouter.ts)
|
|
36
|
+
* cancels in-flight Postgres queries via Bun's `Query.cancel()`. Without
|
|
37
|
+
* this, an aborted request leaks its backend connection into
|
|
38
|
+
* `idle in transaction` under pgbouncer transaction-mode pooling.
|
|
39
|
+
*
|
|
40
|
+
* Captures per-request stats (operationName, DataLoader call counts,
|
|
41
|
+
* dbQueryCount) and attaches them to the underlying Request via
|
|
42
|
+
* `__bunsaneStats` so the HTTP router's catch handler + AccessLog
|
|
43
|
+
* middleware can read them after the GraphQL pipeline rejects.
|
|
23
44
|
*/
|
|
24
45
|
export function createRequestContextPlugin(): Plugin {
|
|
25
46
|
return {
|
|
26
|
-
onExecute: ({ args }) => {
|
|
47
|
+
onExecute: ({ args, executeFn, setExecuteFn }) => {
|
|
27
48
|
const cacheManager = CacheManager.getInstance();
|
|
28
|
-
|
|
29
|
-
|
|
49
|
+
const ctx: any = (args as any).contextValue;
|
|
50
|
+
const request: Request | undefined = ctx?.request;
|
|
51
|
+
const signal: AbortSignal | undefined = request?.signal;
|
|
52
|
+
|
|
53
|
+
// GraphQL operation name. Falls back to first named operation in the
|
|
54
|
+
// document, or 'anonymous' if the client supplied an inline query
|
|
55
|
+
// with no name.
|
|
56
|
+
const operationName: string =
|
|
57
|
+
(typeof args.operationName === 'string' && args.operationName)
|
|
58
|
+
|| (args.document?.definitions?.find?.(
|
|
59
|
+
(d: any) => d?.kind === 'OperationDefinition' && d?.name?.value,
|
|
60
|
+
) as any)?.name?.value
|
|
61
|
+
|| 'anonymous';
|
|
62
|
+
|
|
63
|
+
const stats: RequestStats = {
|
|
64
|
+
operationName,
|
|
65
|
+
dataLoaderCalls: { entity: 0, component: 0, relation: 0 },
|
|
66
|
+
dbQueryCount: 0,
|
|
67
|
+
startTime: performance.now(),
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
// Mount loaders at context.loaders to match ArcheType.ts resolver access pattern.
|
|
71
|
+
ctx.loaders = createRequestLoaders(db, cacheManager, signal, stats);
|
|
30
72
|
// Prefer the HTTP-layer request id (from requestId() middleware's
|
|
31
73
|
// AsyncLocalStorage) so access log + GraphQL logs share the same id.
|
|
32
|
-
|
|
33
|
-
|
|
74
|
+
ctx.requestId = getRequestId() ?? crypto.randomUUID();
|
|
75
|
+
ctx.cacheManager = cacheManager;
|
|
76
|
+
ctx.requestStats = stats;
|
|
77
|
+
ctx.signal = signal;
|
|
78
|
+
|
|
79
|
+
// Attach to the raw Request so the HTTP router catch block + access
|
|
80
|
+
// log middleware can read stats after Yoga rejects.
|
|
81
|
+
if (request) {
|
|
82
|
+
(request as any).__bunsaneStats = stats;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Run the whole execution inside an AsyncLocalStorage scope so bare
|
|
86
|
+
// `entity.get(Component)` calls (e.g. inside @ArcheTypeFunction
|
|
87
|
+
// bodies, Unwrap(), service helpers) pick up the request's batching
|
|
88
|
+
// DataLoaders + AbortSignal without explicit context threading.
|
|
89
|
+
const scope = { loaders: ctx.loaders as RequestLoaders, signal, perRequest: stats };
|
|
90
|
+
setExecuteFn(((execArgs: any) =>
|
|
91
|
+
runWithRequestScope(scope, () => (executeFn as any)(execArgs))) as any);
|
|
34
92
|
},
|
|
35
93
|
};
|
|
36
|
-
}
|
|
94
|
+
}
|
package/core/RequestLoaders.ts
CHANGED
|
@@ -2,10 +2,12 @@ import DataLoader from 'dataloader';
|
|
|
2
2
|
import { Entity } from './Entity';
|
|
3
3
|
import db from '../database';
|
|
4
4
|
import { inList } from '../database/sqlHelpers';
|
|
5
|
+
import { timedUnsafe, incrementDataLoaderCall, type PerRequestCounters } from '../database/instrumentedDb';
|
|
5
6
|
import {logger as MainLogger} from './Logger';
|
|
6
7
|
const logger = MainLogger.child({ module: 'RequestLoaders' });
|
|
7
8
|
import { getMetadataStorage } from './metadata';
|
|
8
9
|
import type { CacheManager } from './cache/CacheManager';
|
|
10
|
+
import { COMPONENT_TOMBSTONE } from './cache/CacheManager';
|
|
9
11
|
|
|
10
12
|
export type ComponentData = {
|
|
11
13
|
id: string; // Component ID for updates
|
|
@@ -21,10 +23,17 @@ export type RequestLoaders = {
|
|
|
21
23
|
entityById: DataLoader<string, Entity | null>;
|
|
22
24
|
componentsByEntityType: DataLoader<{ entityId: string; typeId: string }, ComponentData | null>;
|
|
23
25
|
relationsByEntityField: DataLoader<{ entityId: string; relationField: string; relatedType: string; foreignKey?: string }, Entity[]>;
|
|
26
|
+
relationsByComponentFk: DataLoader<{ entityId: string; componentTypeId: string; foreignKeyField: string }, Entity[]>;
|
|
24
27
|
};
|
|
25
28
|
|
|
26
|
-
export function createRequestLoaders(
|
|
29
|
+
export function createRequestLoaders(
|
|
30
|
+
db: any,
|
|
31
|
+
cacheManager?: CacheManager,
|
|
32
|
+
signal?: AbortSignal,
|
|
33
|
+
perRequest?: PerRequestCounters,
|
|
34
|
+
): RequestLoaders {
|
|
27
35
|
const entityById = new DataLoader<string, Entity | null>(async (ids: readonly string[]) => {
|
|
36
|
+
incrementDataLoaderCall('entity', perRequest);
|
|
28
37
|
const startTime = Date.now();
|
|
29
38
|
try {
|
|
30
39
|
// Filter out empty/invalid IDs to prevent PostgreSQL UUID parsing errors
|
|
@@ -44,12 +53,12 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
|
|
|
44
53
|
|
|
45
54
|
if (missingIds.length > 0) {
|
|
46
55
|
const idList = inList(missingIds, 1);
|
|
47
|
-
const rows = await db
|
|
56
|
+
const rows = await timedUnsafe<any[]>(db, `
|
|
48
57
|
SELECT id
|
|
49
58
|
FROM entities
|
|
50
59
|
WHERE id IN ${idList.sql}
|
|
51
60
|
AND deleted_at IS NULL
|
|
52
|
-
`, idList.params);
|
|
61
|
+
`, idList.params, signal, perRequest);
|
|
53
62
|
|
|
54
63
|
const entities = rows.map((row: any) => {
|
|
55
64
|
const entity = new Entity(row.id);
|
|
@@ -87,8 +96,9 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
|
|
|
87
96
|
maxBatchSize: 100 // Prevent extremely large batches
|
|
88
97
|
});
|
|
89
98
|
|
|
90
|
-
const componentsByEntityType = new DataLoader<{ entityId: string; typeId: string }, ComponentData | null>(
|
|
99
|
+
const componentsByEntityType = new DataLoader<{ entityId: string; typeId: string }, ComponentData | null, string>(
|
|
91
100
|
async (keys: readonly { entityId: string; typeId: string }[]) => {
|
|
101
|
+
incrementDataLoaderCall('component', perRequest);
|
|
92
102
|
const startTime = Date.now();
|
|
93
103
|
try {
|
|
94
104
|
// Filter out keys with empty/invalid entity IDs to prevent PostgreSQL UUID parsing errors
|
|
@@ -99,16 +109,20 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
|
|
|
99
109
|
|
|
100
110
|
const results = new Map<string, ComponentData | null>();
|
|
101
111
|
|
|
102
|
-
// Check cache first if cache manager is available
|
|
112
|
+
// Check cache first if cache manager is available. Tombstone hits
|
|
113
|
+
// are recorded as null in `results` so the DB-fetch step skips them.
|
|
103
114
|
let cacheHits = 0;
|
|
104
115
|
let cacheMisses = 0;
|
|
105
116
|
if (cacheManager && cacheManager.getConfig().enabled && cacheManager.getConfig().component?.enabled) {
|
|
106
117
|
try {
|
|
107
118
|
const cachedComponents = await cacheManager.getComponents(validKeys);
|
|
108
|
-
cachedComponents.forEach((
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
results.set(key,
|
|
119
|
+
cachedComponents.forEach((value, index) => {
|
|
120
|
+
const key = `${validKeys[index]!.entityId}-${validKeys[index]!.typeId}`;
|
|
121
|
+
if (value === COMPONENT_TOMBSTONE) {
|
|
122
|
+
results.set(key, null);
|
|
123
|
+
cacheHits++;
|
|
124
|
+
} else if (value) {
|
|
125
|
+
results.set(key, value);
|
|
112
126
|
cacheHits++;
|
|
113
127
|
} else {
|
|
114
128
|
cacheMisses++;
|
|
@@ -122,17 +136,16 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
|
|
|
122
136
|
cacheMisses += validKeys.length;
|
|
123
137
|
}
|
|
124
138
|
|
|
125
|
-
// Log cache hit/miss rates for monitoring
|
|
126
139
|
if (validKeys.length > 0) {
|
|
127
140
|
const hitRate = (cacheHits / validKeys.length) * 100;
|
|
128
|
-
logger.
|
|
129
|
-
scope: 'cache',
|
|
130
|
-
component: 'RequestLoaders',
|
|
131
|
-
msg: 'Component cache statistics',
|
|
132
|
-
total: validKeys.length,
|
|
133
|
-
hits: cacheHits,
|
|
134
|
-
misses: cacheMisses,
|
|
135
|
-
hitRate: `${hitRate.toFixed(1)}
|
|
141
|
+
logger.trace({
|
|
142
|
+
scope: 'cache',
|
|
143
|
+
component: 'RequestLoaders',
|
|
144
|
+
msg: 'Component cache statistics',
|
|
145
|
+
total: validKeys.length,
|
|
146
|
+
hits: cacheHits,
|
|
147
|
+
misses: cacheMisses,
|
|
148
|
+
hitRate: `${hitRate.toFixed(1)}%`,
|
|
136
149
|
});
|
|
137
150
|
}
|
|
138
151
|
|
|
@@ -144,13 +157,13 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
|
|
|
144
157
|
const typeIds = [...new Set(missingKeys.map(k => k.typeId))];
|
|
145
158
|
const entityIdList = inList(entityIds, 1);
|
|
146
159
|
const typeIdList = inList(typeIds, entityIdList.newParamIndex);
|
|
147
|
-
const rows = await db
|
|
160
|
+
const rows = await timedUnsafe<any[]>(db, `
|
|
148
161
|
SELECT id, entity_id, type_id, data, created_at, updated_at, deleted_at
|
|
149
162
|
FROM components
|
|
150
163
|
WHERE entity_id IN ${entityIdList.sql}
|
|
151
164
|
AND type_id IN ${typeIdList.sql}
|
|
152
165
|
AND deleted_at IS NULL
|
|
153
|
-
`, [...entityIdList.params, ...typeIdList.params]);
|
|
166
|
+
`, [...entityIdList.params, ...typeIdList.params], signal, perRequest);
|
|
154
167
|
|
|
155
168
|
const components: ComponentData[] = rows.map((row: any) => ({
|
|
156
169
|
id: row.id,
|
|
@@ -162,10 +175,15 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
|
|
|
162
175
|
deletedAt: row.deleted_at,
|
|
163
176
|
}));
|
|
164
177
|
|
|
165
|
-
// Cache the loaded components
|
|
178
|
+
// Cache the loaded components + tombstone any requested keys whose
|
|
179
|
+
// row was absent (single setMany — see CacheManager.setComponentsWriteThrough).
|
|
166
180
|
if (cacheManager && cacheManager.getConfig().enabled && cacheManager.getConfig().component?.enabled) {
|
|
167
181
|
try {
|
|
168
|
-
await cacheManager.setComponentsWriteThrough(
|
|
182
|
+
await cacheManager.setComponentsWriteThrough(
|
|
183
|
+
components,
|
|
184
|
+
missingKeys,
|
|
185
|
+
cacheManager.getConfig().component!.ttl,
|
|
186
|
+
);
|
|
169
187
|
} catch (error: any) {
|
|
170
188
|
logger.warn({ scope: 'cache', component: 'RequestLoaders', msg: 'Cache write failed for components', error });
|
|
171
189
|
}
|
|
@@ -193,12 +211,17 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
|
|
|
193
211
|
}
|
|
194
212
|
},
|
|
195
213
|
{
|
|
196
|
-
maxBatchSize: 100 // Prevent extremely large batches
|
|
214
|
+
maxBatchSize: 100, // Prevent extremely large batches
|
|
215
|
+
// Object keys default to identity (===) comparison, which never dedups
|
|
216
|
+
// distinct literals — collapse to a stable string so sibling resolvers
|
|
217
|
+
// requesting the same (entity, type) share one load within a request.
|
|
218
|
+
cacheKeyFn: (k: { entityId: string; typeId: string }) => `${k.entityId}\x00${k.typeId}`,
|
|
197
219
|
}
|
|
198
220
|
);
|
|
199
221
|
|
|
200
|
-
const relationsByEntityField = new DataLoader<{ entityId: string; relationField: string; relatedType: string; foreignKey?: string }, Entity[]>(
|
|
222
|
+
const relationsByEntityField = new DataLoader<{ entityId: string; relationField: string; relatedType: string; foreignKey?: string }, Entity[], string>(
|
|
201
223
|
async (keys: readonly { entityId: string; relationField: string; relatedType: string; foreignKey?: string }[]) => {
|
|
224
|
+
incrementDataLoaderCall('relation', perRequest);
|
|
202
225
|
const startTime = Date.now();
|
|
203
226
|
try {
|
|
204
227
|
// Filter valid keys
|
|
@@ -207,9 +230,35 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
|
|
|
207
230
|
return keys.map(() => []);
|
|
208
231
|
}
|
|
209
232
|
|
|
233
|
+
const resultMap = new Map<string, Entity[]>();
|
|
234
|
+
|
|
235
|
+
// Negative-cache lookup: skip DB for keys recorded as empty.
|
|
236
|
+
let keysToQuery = validKeys;
|
|
237
|
+
const relCacheEnabled = !!(cacheManager
|
|
238
|
+
&& cacheManager.getConfig().enabled
|
|
239
|
+
&& cacheManager.getConfig().relation?.negativeCacheEnabled);
|
|
240
|
+
if (relCacheEnabled) {
|
|
241
|
+
try {
|
|
242
|
+
const tombstones = await cacheManager!.getRelationsEmpty(validKeys);
|
|
243
|
+
const remaining: typeof validKeys = [];
|
|
244
|
+
tombstones.forEach((isEmpty, i) => {
|
|
245
|
+
const k = validKeys[i]!;
|
|
246
|
+
if (isEmpty) {
|
|
247
|
+
const mapKey = `${k.entityId}\x00${k.relationField}\x00${k.relatedType}`;
|
|
248
|
+
resultMap.set(mapKey, []);
|
|
249
|
+
} else {
|
|
250
|
+
remaining.push(k);
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
keysToQuery = remaining;
|
|
254
|
+
} catch (error) {
|
|
255
|
+
logger.warn({ scope: 'cache', component: 'RequestLoaders', msg: 'Cache read failed for relation tombstones', error });
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
210
259
|
// Group keys by foreign key for efficient batching
|
|
211
|
-
const keysByForeignKey = new Map<string, typeof
|
|
212
|
-
for (const key of
|
|
260
|
+
const keysByForeignKey = new Map<string, typeof keysToQuery>();
|
|
261
|
+
for (const key of keysToQuery) {
|
|
213
262
|
const fk = key.foreignKey || 'default';
|
|
214
263
|
if (!keysByForeignKey.has(fk)) {
|
|
215
264
|
keysByForeignKey.set(fk, []);
|
|
@@ -217,8 +266,6 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
|
|
|
217
266
|
keysByForeignKey.get(fk)!.push(key);
|
|
218
267
|
}
|
|
219
268
|
|
|
220
|
-
const resultMap = new Map<string, Entity[]>();
|
|
221
|
-
|
|
222
269
|
// OPTIMIZED: Batch query for each foreign key type (instead of N separate queries)
|
|
223
270
|
for (const [foreignKey, groupedKeys] of keysByForeignKey) {
|
|
224
271
|
const entityIds = [...new Set(groupedKeys.map(k => k.entityId))];
|
|
@@ -240,19 +287,19 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
|
|
|
240
287
|
logger.trace(`[RelationLoader] Batched query for ${groupedKeys.length} keys with foreign key ${foreignKey}`);
|
|
241
288
|
|
|
242
289
|
// SINGLE BATCHED QUERY for all entities in this group
|
|
243
|
-
const rows = await db
|
|
244
|
-
SELECT DISTINCT
|
|
245
|
-
c.entity_id,
|
|
246
|
-
c.data,
|
|
290
|
+
const rows = await timedUnsafe<any[]>(db, `
|
|
291
|
+
SELECT DISTINCT
|
|
292
|
+
c.entity_id,
|
|
293
|
+
c.data,
|
|
247
294
|
c.type_id,
|
|
248
295
|
c.data->>'${foreignKeyField}' as fk_value,
|
|
249
296
|
COALESCE(c.data->>'user_id', c.data->>'parent_id') as fallback_fk_value
|
|
250
297
|
FROM components c
|
|
251
298
|
INNER JOIN entities e ON c.entity_id = e.id
|
|
252
|
-
WHERE e.deleted_at IS NULL
|
|
299
|
+
WHERE e.deleted_at IS NULL
|
|
253
300
|
AND c.deleted_at IS NULL
|
|
254
301
|
AND ${whereClause}
|
|
255
|
-
`, [entityIds]);
|
|
302
|
+
`, [entityIds], signal, perRequest);
|
|
256
303
|
|
|
257
304
|
logger.trace(`[RelationLoader] Found ${rows.length} total components for ${entityIds.length} entities`);
|
|
258
305
|
|
|
@@ -281,6 +328,22 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
|
|
|
281
328
|
}
|
|
282
329
|
}
|
|
283
330
|
|
|
331
|
+
// Write tombstones for queried keys whose result was empty.
|
|
332
|
+
if (relCacheEnabled && keysToQuery.length > 0) {
|
|
333
|
+
const emptyKeys = keysToQuery.filter(k => {
|
|
334
|
+
const mapKey = `${k.entityId}\x00${k.relationField}\x00${k.relatedType}`;
|
|
335
|
+
const r = resultMap.get(mapKey);
|
|
336
|
+
return !r || r.length === 0;
|
|
337
|
+
});
|
|
338
|
+
if (emptyKeys.length > 0) {
|
|
339
|
+
try {
|
|
340
|
+
await cacheManager!.setRelationsEmpty(emptyKeys);
|
|
341
|
+
} catch (error) {
|
|
342
|
+
logger.warn({ scope: 'cache', component: 'RequestLoaders', msg: 'Cache write failed for relation tombstones', error });
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
284
347
|
const duration = Date.now() - startTime;
|
|
285
348
|
if (duration > 1000) {
|
|
286
349
|
logger.warn(`Slow relationsByEntityField query: ${duration}ms for ${keys.length} keys`);
|
|
@@ -306,9 +369,97 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
|
|
|
306
369
|
},
|
|
307
370
|
{
|
|
308
371
|
// Add batch size limit to prevent extremely large queries
|
|
309
|
-
maxBatchSize: 50
|
|
372
|
+
maxBatchSize: 50,
|
|
373
|
+
// Stable string key (null-byte separated, matches the result-map key) so
|
|
374
|
+
// identical relation requests dedup within a request instead of being
|
|
375
|
+
// treated as distinct object identities.
|
|
376
|
+
cacheKeyFn: (k: { entityId: string; relationField: string; relatedType: string; foreignKey?: string }) =>
|
|
377
|
+
`${k.entityId}\x00${k.relationField}\x00${k.relatedType}\x00${k.foreignKey ?? ''}`,
|
|
378
|
+
}
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
// Type-scoped foreign-key relation loader. Backs @HasMany/@BelongsToMany
|
|
382
|
+
// array relations that declare a `foreignKey`. Previously those resolved one
|
|
383
|
+
// `new Query().exec()` PER PARENT ROW (a hard N+1). This batches all parents
|
|
384
|
+
// sharing a (componentType, fkField) into a single `data->>'fk' = ANY($2)`
|
|
385
|
+
// query. Unlike relationsByEntityField it pins `type_id`, preserving the
|
|
386
|
+
// exact semantics of the per-parent Query (which filtered by the specific
|
|
387
|
+
// component type) rather than matching any component sharing the field name.
|
|
388
|
+
const relationsByComponentFk = new DataLoader<{ entityId: string; componentTypeId: string; foreignKeyField: string }, Entity[], string>(
|
|
389
|
+
async (keys: readonly { entityId: string; componentTypeId: string; foreignKeyField: string }[]) => {
|
|
390
|
+
incrementDataLoaderCall('relation', perRequest);
|
|
391
|
+
const startTime = Date.now();
|
|
392
|
+
try {
|
|
393
|
+
const validKeys = keys.filter(k => k.entityId && typeof k.entityId === 'string' && k.entityId.trim() !== '');
|
|
394
|
+
if (validKeys.length === 0) return keys.map(() => []);
|
|
395
|
+
|
|
396
|
+
const resultMap = new Map<string, Entity[]>();
|
|
397
|
+
|
|
398
|
+
// Group by (componentTypeId, foreignKeyField) so each distinct relation
|
|
399
|
+
// shape is one batched query.
|
|
400
|
+
const groups = new Map<string, typeof validKeys>();
|
|
401
|
+
for (const key of validKeys) {
|
|
402
|
+
const gk = `${key.componentTypeId}\x00${key.foreignKeyField}`;
|
|
403
|
+
if (!groups.has(gk)) groups.set(gk, []);
|
|
404
|
+
groups.get(gk)!.push(key);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
for (const [gk, groupedKeys] of groups) {
|
|
408
|
+
const sep = gk.indexOf('\x00');
|
|
409
|
+
const componentTypeId = gk.slice(0, sep);
|
|
410
|
+
const foreignKeyField = gk.slice(sep + 1);
|
|
411
|
+
const entityIds = [...new Set(groupedKeys.map(k => k.entityId))];
|
|
412
|
+
if (entityIds.length === 0) continue;
|
|
413
|
+
|
|
414
|
+
// type_id + entity ids are parameterized via inList (the proven
|
|
415
|
+
// pattern — passing a JS array to `= ANY($n)` is serialized as a
|
|
416
|
+
// comma-string by the Bun SQL driver and fails). foreignKeyField
|
|
417
|
+
// comes from trusted relation decorator metadata.
|
|
418
|
+
const entityList = inList(entityIds, 2);
|
|
419
|
+
const rows = await timedUnsafe<any[]>(db, `
|
|
420
|
+
SELECT c.entity_id, c.data->>'${foreignKeyField}' AS fk_value
|
|
421
|
+
FROM components c
|
|
422
|
+
INNER JOIN entities e ON c.entity_id = e.id
|
|
423
|
+
WHERE c.type_id = $1
|
|
424
|
+
AND c.deleted_at IS NULL
|
|
425
|
+
AND e.deleted_at IS NULL
|
|
426
|
+
AND c.data->>'${foreignKeyField}' IN ${entityList.sql}
|
|
427
|
+
`, [componentTypeId, ...entityList.params], signal, perRequest);
|
|
428
|
+
|
|
429
|
+
for (const key of groupedKeys) {
|
|
430
|
+
const relatedIds = [...new Set(
|
|
431
|
+
rows.filter((r: any) => r.fk_value === key.entityId).map((r: any) => r.entity_id)
|
|
432
|
+
)];
|
|
433
|
+
const entities = relatedIds.map(id => {
|
|
434
|
+
const e = new Entity(id as string);
|
|
435
|
+
e.setPersisted(true);
|
|
436
|
+
return e;
|
|
437
|
+
});
|
|
438
|
+
resultMap.set(`${key.entityId}\x00${componentTypeId}\x00${foreignKeyField}`, entities);
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
const duration = Date.now() - startTime;
|
|
443
|
+
if (duration > 1000) {
|
|
444
|
+
logger.warn(`Slow relationsByComponentFk query: ${duration}ms for ${keys.length} keys`);
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
return keys.map(k => {
|
|
448
|
+
if (!k.entityId || typeof k.entityId !== 'string' || k.entityId.trim() === '') return [];
|
|
449
|
+
return resultMap.get(`${k.entityId}\x00${k.componentTypeId}\x00${k.foreignKeyField}`) || [];
|
|
450
|
+
});
|
|
451
|
+
} catch (error) {
|
|
452
|
+
logger.error(`Error in relationsByComponentFk DataLoader:`);
|
|
453
|
+
logger.error(error);
|
|
454
|
+
return keys.map(() => []);
|
|
455
|
+
}
|
|
456
|
+
},
|
|
457
|
+
{
|
|
458
|
+
maxBatchSize: 50,
|
|
459
|
+
cacheKeyFn: (k: { entityId: string; componentTypeId: string; foreignKeyField: string }) =>
|
|
460
|
+
`${k.entityId}\x00${k.componentTypeId}\x00${k.foreignKeyField}`,
|
|
310
461
|
}
|
|
311
462
|
);
|
|
312
463
|
|
|
313
|
-
return { entityById, componentsByEntityType, relationsByEntityField };
|
|
464
|
+
return { entityById, componentsByEntityType, relationsByEntityField, relationsByComponentFk };
|
|
314
465
|
}
|