bunsane 0.3.2 → 0.5.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 +471 -370
- package/core/BatchLoader.ts +56 -32
- package/core/Entity.ts +93 -1020
- package/core/EntityHookManager.ts +52 -754
- package/core/Logger.ts +10 -0
- package/core/RequestContext.ts +94 -85
- package/core/RequestLoaders.ts +98 -5
- package/core/SchedulerManager.ts +28 -600
- package/core/app/cors.ts +2 -11
- package/core/app/preparedStatementWarmup.ts +9 -49
- package/core/app/requestRouter.ts +9 -8
- package/core/app/restRegistry.ts +8 -0
- package/core/archetype/fieldResolvers.ts +85 -40
- package/core/archetype/relationLoader.ts +135 -92
- package/core/cache/CacheManager.ts +91 -302
- package/core/cache/CompressionUtils.ts +34 -3
- package/core/cache/MemoryCache.ts +40 -37
- package/core/cache/RedisCache.ts +8 -7
- 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 +25 -10
- 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 +383 -0
- package/core/entity/finders.ts +202 -0
- package/core/entity/getCacheManager.ts +10 -0
- package/core/entity/pendingOps.ts +72 -0
- package/core/entity/saveEntity.ts +375 -0
- package/core/health.ts +93 -4
- 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/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/StreamConsumer.ts +535 -535
- 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/core/validateEnv.ts +10 -0
- package/database/DatabaseHelper.ts +128 -101
- package/database/IndexingStrategy.ts +72 -2
- package/database/PreparedStatementCache.ts +8 -2
- package/database/cancellable.ts +35 -22
- package/database/index.ts +29 -3
- package/database/instrumentedDb.ts +141 -141
- package/database/sqlHelpers.ts +3 -1
- package/endpoints/archetypes.ts +2 -8
- package/endpoints/tables.ts +6 -1
- package/gql/index.ts +1 -1
- package/gql/schema/index.ts +15 -4
- package/gql/visitors/ResolverGeneratorVisitor.ts +25 -4
- package/package.json +22 -1
- package/query/CTENode.ts +5 -3
- package/query/ComponentInclusionNode.ts +245 -14
- package/query/OrNode.ts +8 -19
- package/query/Query.ts +208 -79
- 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/scheduled_tasks.lock +0 -1
- 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/RFC_APP_REFACTOR.md +0 -248
- package/docs/RFC_REFACTOR_TARGETS.md +0 -251
- 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/loaders/RequestLoaders.abort.test.ts +0 -82
- package/tests/integration/query/Query.abort.test.ts +0 -66
- 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 -498
- package/tests/unit/cache/MemoryCache.test.ts +0 -260
- package/tests/unit/cache/RedisCache.test.ts +0 -411
- package/tests/unit/database/cancellable.test.ts +0 -81
- package/tests/unit/database/instrumentedDb.test.ts +0 -160
- 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
|
@@ -1,85 +1,94 @@
|
|
|
1
|
-
import type { Plugin } from 'graphql-yoga';
|
|
2
|
-
import { createRequestLoaders } from './RequestLoaders';
|
|
3
|
-
import type { RequestLoaders } from './RequestLoaders';
|
|
4
|
-
import db from '../database';
|
|
5
|
-
import { CacheManager } from './cache/CacheManager';
|
|
6
|
-
import { getRequestId } from './middleware/RequestId';
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
*
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
* the
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
42
|
-
*
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
const
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
ctx.
|
|
75
|
-
ctx.
|
|
76
|
-
ctx.
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
1
|
+
import type { Plugin } from 'graphql-yoga';
|
|
2
|
+
import { createRequestLoaders } from './RequestLoaders';
|
|
3
|
+
import type { RequestLoaders } from './RequestLoaders';
|
|
4
|
+
import db from '../database';
|
|
5
|
+
import { CacheManager } from './cache/CacheManager';
|
|
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
|
+
}
|
|
15
|
+
|
|
16
|
+
declare module 'graphql-yoga' {
|
|
17
|
+
interface Context {
|
|
18
|
+
// Loaders mounted at top-level context for ArcheType resolver access
|
|
19
|
+
loaders: RequestLoaders;
|
|
20
|
+
requestId: string;
|
|
21
|
+
cacheManager: CacheManager;
|
|
22
|
+
requestStats: RequestStats;
|
|
23
|
+
signal?: AbortSignal;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* GraphQL Yoga plugin that creates per-request DataLoaders for batching.
|
|
29
|
+
*
|
|
30
|
+
* IMPORTANT: Loaders are mounted at context.loaders (NOT context.locals.loaders)
|
|
31
|
+
* to match what ArcheType.ts resolvers expect. This enables DataLoader batching
|
|
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.
|
|
44
|
+
*/
|
|
45
|
+
export function createRequestContextPlugin(): Plugin {
|
|
46
|
+
return {
|
|
47
|
+
onExecute: ({ args, executeFn, setExecuteFn }) => {
|
|
48
|
+
const cacheManager = CacheManager.getInstance();
|
|
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);
|
|
72
|
+
// Prefer the HTTP-layer request id (from requestId() middleware's
|
|
73
|
+
// AsyncLocalStorage) so access log + GraphQL logs share the same id.
|
|
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);
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|
package/core/RequestLoaders.ts
CHANGED
|
@@ -23,6 +23,7 @@ export type RequestLoaders = {
|
|
|
23
23
|
entityById: DataLoader<string, Entity | null>;
|
|
24
24
|
componentsByEntityType: DataLoader<{ entityId: string; typeId: string }, ComponentData | null>;
|
|
25
25
|
relationsByEntityField: DataLoader<{ entityId: string; relationField: string; relatedType: string; foreignKey?: string }, Entity[]>;
|
|
26
|
+
relationsByComponentFk: DataLoader<{ entityId: string; componentTypeId: string; foreignKeyField: string }, Entity[]>;
|
|
26
27
|
};
|
|
27
28
|
|
|
28
29
|
export function createRequestLoaders(
|
|
@@ -95,7 +96,7 @@ export function createRequestLoaders(
|
|
|
95
96
|
maxBatchSize: 100 // Prevent extremely large batches
|
|
96
97
|
});
|
|
97
98
|
|
|
98
|
-
const componentsByEntityType = new DataLoader<{ entityId: string; typeId: string }, ComponentData | null>(
|
|
99
|
+
const componentsByEntityType = new DataLoader<{ entityId: string; typeId: string }, ComponentData | null, string>(
|
|
99
100
|
async (keys: readonly { entityId: string; typeId: string }[]) => {
|
|
100
101
|
incrementDataLoaderCall('component', perRequest);
|
|
101
102
|
const startTime = Date.now();
|
|
@@ -210,11 +211,15 @@ export function createRequestLoaders(
|
|
|
210
211
|
}
|
|
211
212
|
},
|
|
212
213
|
{
|
|
213
|
-
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}`,
|
|
214
219
|
}
|
|
215
220
|
);
|
|
216
221
|
|
|
217
|
-
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>(
|
|
218
223
|
async (keys: readonly { entityId: string; relationField: string; relatedType: string; foreignKey?: string }[]) => {
|
|
219
224
|
incrementDataLoaderCall('relation', perRequest);
|
|
220
225
|
const startTime = Date.now();
|
|
@@ -364,9 +369,97 @@ export function createRequestLoaders(
|
|
|
364
369
|
},
|
|
365
370
|
{
|
|
366
371
|
// Add batch size limit to prevent extremely large queries
|
|
367
|
-
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 ?? ''}`,
|
|
368
378
|
}
|
|
369
379
|
);
|
|
370
380
|
|
|
371
|
-
|
|
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}`,
|
|
461
|
+
}
|
|
462
|
+
);
|
|
463
|
+
|
|
464
|
+
return { entityById, componentsByEntityType, relationsByEntityField, relationsByComponentFk };
|
|
372
465
|
}
|