bunsane 0.3.2 → 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.
Files changed (214) hide show
  1. package/CHANGELOG.md +445 -370
  2. package/core/BatchLoader.ts +56 -32
  3. package/core/Entity.ts +85 -1020
  4. package/core/EntityHookManager.ts +52 -754
  5. package/core/Logger.ts +10 -0
  6. package/core/RequestContext.ts +94 -85
  7. package/core/RequestLoaders.ts +98 -5
  8. package/core/SchedulerManager.ts +28 -600
  9. package/core/app/cors.ts +2 -11
  10. package/core/app/preparedStatementWarmup.ts +9 -49
  11. package/core/app/requestRouter.ts +9 -8
  12. package/core/app/restRegistry.ts +8 -0
  13. package/core/archetype/fieldResolvers.ts +85 -40
  14. package/core/archetype/relationLoader.ts +135 -92
  15. package/core/cache/CacheManager.ts +91 -302
  16. package/core/cache/CompressionUtils.ts +34 -3
  17. package/core/cache/MemoryCache.ts +40 -37
  18. package/core/cache/RedisCache.ts +4 -4
  19. package/core/cache/health.ts +30 -0
  20. package/core/cache/invalidation.ts +96 -0
  21. package/core/cache/strategies/writeInvalidate.ts +111 -0
  22. package/core/cache/strategies/writeThrough.ts +233 -0
  23. package/core/components/BaseComponent.ts +16 -8
  24. package/core/components/ComponentRegistry.ts +28 -0
  25. package/core/decorators/IndexedField.ts +1 -1
  26. package/core/entity/cacheStrategies.ts +97 -0
  27. package/core/entity/componentAccess.ts +364 -0
  28. package/core/entity/finders.ts +202 -0
  29. package/core/entity/pendingOps.ts +72 -0
  30. package/core/entity/saveEntity.ts +377 -0
  31. package/core/hooks/dispatcher.ts +439 -0
  32. package/core/hooks/guards.ts +155 -0
  33. package/core/hooks/registry.ts +247 -0
  34. package/core/metadata/definitions/Component.ts +1 -1
  35. package/core/metadata/index.ts +15 -4
  36. package/core/middleware/RateLimit.ts +102 -105
  37. package/core/middleware/RequestId.ts +2 -9
  38. package/core/middleware/SecurityHeaders.ts +2 -11
  39. package/core/middleware/headers.ts +28 -0
  40. package/core/remote/OutboxWorker.ts +213 -183
  41. package/core/remote/RemoteManager.ts +401 -400
  42. package/core/remote/types.ts +153 -151
  43. package/core/requestScope.ts +34 -0
  44. package/core/scheduler/cronEvaluator.ts +174 -0
  45. package/core/scheduler/lifecycleHooks.ts +21 -0
  46. package/core/scheduler/lockCoordinator.ts +27 -0
  47. package/core/scheduler/metrics.ts +14 -0
  48. package/core/scheduler/taskRunner.ts +420 -0
  49. package/database/DatabaseHelper.ts +128 -101
  50. package/database/IndexingStrategy.ts +72 -2
  51. package/database/PreparedStatementCache.ts +8 -2
  52. package/database/cancellable.ts +35 -22
  53. package/database/index.ts +15 -3
  54. package/database/instrumentedDb.ts +141 -141
  55. package/endpoints/archetypes.ts +2 -8
  56. package/endpoints/tables.ts +6 -1
  57. package/gql/index.ts +1 -1
  58. package/gql/visitors/ResolverGeneratorVisitor.ts +25 -4
  59. package/package.json +22 -1
  60. package/query/CTENode.ts +5 -3
  61. package/query/ComponentInclusionNode.ts +240 -13
  62. package/query/OrNode.ts +6 -5
  63. package/query/Query.ts +157 -46
  64. package/query/QueryContext.ts +6 -0
  65. package/query/QueryDAG.ts +7 -2
  66. package/query/membershipSource.ts +66 -0
  67. package/storage/LocalStorageProvider.ts +8 -3
  68. package/studio/dist/assets/index-BMZ67Npg.js +254 -0
  69. package/studio/dist/assets/index-BpbuYz9g.css +1 -0
  70. package/studio/{index.html → dist/index.html} +3 -2
  71. package/swagger/generator.ts +11 -1
  72. package/upload/UploadManager.ts +8 -6
  73. package/utils/uuid.ts +40 -10
  74. package/.claude/scheduled_tasks.lock +0 -1
  75. package/.claude/settings.local.json +0 -47
  76. package/.prettierrc +0 -4
  77. package/.serena/memories/architectural-decision-no-dependency-injection.md +0 -76
  78. package/.serena/memories/architecture.md +0 -154
  79. package/.serena/memories/cache-interface-refactoring-2026-01-24.md +0 -165
  80. package/.serena/memories/code_style_and_conventions.md +0 -76
  81. package/.serena/memories/project_overview.md +0 -43
  82. package/.serena/memories/schema-dsl-plan.md +0 -107
  83. package/.serena/memories/suggested_commands.md +0 -80
  84. package/.serena/memories/typescript-compilation-status.md +0 -54
  85. package/.serena/project.yml +0 -114
  86. package/BunSane.jpg +0 -0
  87. package/CLAUDE.md +0 -198
  88. package/TODO.md +0 -2
  89. package/bun.lock +0 -302
  90. package/bunfig.toml +0 -10
  91. package/docs/RFC_APP_REFACTOR.md +0 -248
  92. package/docs/RFC_REFACTOR_TARGETS.md +0 -251
  93. package/docs/SCALABILITY_PLAN.md +0 -175
  94. package/studio/bun.lock +0 -482
  95. package/studio/package.json +0 -39
  96. package/studio/postcss.config.js +0 -6
  97. package/studio/src/components/DataTable.tsx +0 -211
  98. package/studio/src/components/Layout.tsx +0 -13
  99. package/studio/src/components/PageContainer.tsx +0 -9
  100. package/studio/src/components/PageHeader.tsx +0 -13
  101. package/studio/src/components/SearchBar.tsx +0 -57
  102. package/studio/src/components/Sidebar.tsx +0 -294
  103. package/studio/src/components/ui/button.tsx +0 -56
  104. package/studio/src/components/ui/checkbox.tsx +0 -26
  105. package/studio/src/components/ui/input.tsx +0 -25
  106. package/studio/src/hooks/useDataTable.ts +0 -131
  107. package/studio/src/index.css +0 -36
  108. package/studio/src/lib/api.ts +0 -186
  109. package/studio/src/lib/utils.ts +0 -13
  110. package/studio/src/main.tsx +0 -17
  111. package/studio/src/pages/ArcheType.tsx +0 -239
  112. package/studio/src/pages/Components.tsx +0 -124
  113. package/studio/src/pages/EntityInspector.tsx +0 -302
  114. package/studio/src/pages/QueryRunner.tsx +0 -246
  115. package/studio/src/pages/Table.tsx +0 -94
  116. package/studio/src/pages/Welcome.tsx +0 -241
  117. package/studio/src/routes.tsx +0 -45
  118. package/studio/src/store/archeTypeSettings.ts +0 -30
  119. package/studio/src/store/studio.ts +0 -65
  120. package/studio/src/utils/columnHelpers.tsx +0 -114
  121. package/studio/studio-instructions.md +0 -81
  122. package/studio/tailwind.config.js +0 -77
  123. package/studio/utils.ts +0 -54
  124. package/studio/vite.config.js +0 -19
  125. package/tests/benchmark/BENCHMARK_DATABASES_PLAN.md +0 -338
  126. package/tests/benchmark/bunfig.toml +0 -9
  127. package/tests/benchmark/fixtures/EcommerceComponents.ts +0 -283
  128. package/tests/benchmark/fixtures/EcommerceDataGenerators.ts +0 -301
  129. package/tests/benchmark/fixtures/RelationTracker.ts +0 -159
  130. package/tests/benchmark/fixtures/index.ts +0 -6
  131. package/tests/benchmark/index.ts +0 -22
  132. package/tests/benchmark/noop-preload.ts +0 -3
  133. package/tests/benchmark/query-lateral-benchmark.test.ts +0 -372
  134. package/tests/benchmark/runners/BenchmarkLoader.ts +0 -132
  135. package/tests/benchmark/runners/index.ts +0 -4
  136. package/tests/benchmark/scenarios/query-benchmarks.test.ts +0 -465
  137. package/tests/benchmark/scripts/generate-db.ts +0 -344
  138. package/tests/benchmark/scripts/run-benchmarks.ts +0 -97
  139. package/tests/e2e/http.test.ts +0 -130
  140. package/tests/fixtures/archetypes/TestUserArchetype.ts +0 -21
  141. package/tests/fixtures/components/TestOrder.ts +0 -23
  142. package/tests/fixtures/components/TestProduct.ts +0 -23
  143. package/tests/fixtures/components/TestUser.ts +0 -20
  144. package/tests/fixtures/components/index.ts +0 -6
  145. package/tests/graphql/SchemaGeneration.test.ts +0 -90
  146. package/tests/graphql/builders/ResolverBuilder.test.ts +0 -223
  147. package/tests/graphql/builders/TypeDefBuilder.test.ts +0 -153
  148. package/tests/helpers/MockRedisClient.ts +0 -113
  149. package/tests/helpers/MockRedisStreamServer.ts +0 -448
  150. package/tests/integration/archetype/ArcheType.persistence.test.ts +0 -241
  151. package/tests/integration/cache/CacheInvalidation.test.ts +0 -259
  152. package/tests/integration/entity/Entity.persistence.test.ts +0 -333
  153. package/tests/integration/entity/Entity.saveTimeout.test.ts +0 -110
  154. package/tests/integration/loaders/RequestLoaders.abort.test.ts +0 -82
  155. package/tests/integration/query/Query.abort.test.ts +0 -66
  156. package/tests/integration/query/Query.complexAnalysis.test.ts +0 -557
  157. package/tests/integration/query/Query.edgeCases.test.ts +0 -595
  158. package/tests/integration/query/Query.exec.test.ts +0 -576
  159. package/tests/integration/query/Query.explainAnalyze.test.ts +0 -233
  160. package/tests/integration/query/Query.jsonbArray.test.ts +0 -214
  161. package/tests/integration/remote/dlq.test.ts +0 -175
  162. package/tests/integration/remote/event-dispatch.test.ts +0 -114
  163. package/tests/integration/remote/outbox.test.ts +0 -130
  164. package/tests/integration/remote/rpc.test.ts +0 -177
  165. package/tests/pglite-setup.ts +0 -62
  166. package/tests/setup.ts +0 -164
  167. package/tests/stress/BenchmarkRunner.ts +0 -203
  168. package/tests/stress/DataSeeder.ts +0 -190
  169. package/tests/stress/StressTestReporter.ts +0 -229
  170. package/tests/stress/cursor-perf-test.ts +0 -171
  171. package/tests/stress/fixtures/RealisticComponents.ts +0 -235
  172. package/tests/stress/fixtures/StressTestComponents.ts +0 -58
  173. package/tests/stress/index.ts +0 -7
  174. package/tests/stress/scenarios/query-benchmarks.test.ts +0 -285
  175. package/tests/stress/scenarios/realistic-scenarios.test.ts +0 -1081
  176. package/tests/stress/scenarios/timeout-investigation.test.ts +0 -522
  177. package/tests/unit/BatchLoader.test.ts +0 -196
  178. package/tests/unit/archetype/ArcheType.test.ts +0 -107
  179. package/tests/unit/cache/CacheManager.test.ts +0 -498
  180. package/tests/unit/cache/MemoryCache.test.ts +0 -260
  181. package/tests/unit/cache/RedisCache.test.ts +0 -411
  182. package/tests/unit/database/cancellable.test.ts +0 -81
  183. package/tests/unit/database/instrumentedDb.test.ts +0 -160
  184. package/tests/unit/entity/Entity.components.test.ts +0 -317
  185. package/tests/unit/entity/Entity.drainSideEffects.test.ts +0 -51
  186. package/tests/unit/entity/Entity.reload.test.ts +0 -63
  187. package/tests/unit/entity/Entity.requireComponents.test.ts +0 -72
  188. package/tests/unit/entity/Entity.test.ts +0 -345
  189. package/tests/unit/gql/depthLimit.test.ts +0 -203
  190. package/tests/unit/gql/operationMiddleware.test.ts +0 -293
  191. package/tests/unit/health/Health.test.ts +0 -129
  192. package/tests/unit/middleware/AccessLog.test.ts +0 -37
  193. package/tests/unit/middleware/Middleware.test.ts +0 -98
  194. package/tests/unit/middleware/RequestId.test.ts +0 -54
  195. package/tests/unit/middleware/SecurityHeaders.test.ts +0 -66
  196. package/tests/unit/query/FilterBuilder.test.ts +0 -111
  197. package/tests/unit/query/JsonbArrayBuilder.test.ts +0 -178
  198. package/tests/unit/query/Query.emptyString.test.ts +0 -69
  199. package/tests/unit/query/Query.test.ts +0 -310
  200. package/tests/unit/remote/CircuitBreaker.test.ts +0 -159
  201. package/tests/unit/remote/RemoteError.test.ts +0 -55
  202. package/tests/unit/remote/decorators.test.ts +0 -195
  203. package/tests/unit/remote/metrics.test.ts +0 -115
  204. package/tests/unit/remote/mockRedisStreamServer.test.ts +0 -104
  205. package/tests/unit/scheduler/DistributedLock.test.ts +0 -274
  206. package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +0 -95
  207. package/tests/unit/schema/schema-integration.test.ts +0 -426
  208. package/tests/unit/schema/schema.test.ts +0 -580
  209. package/tests/unit/storage/S3StorageProvider.test.ts +0 -567
  210. package/tests/unit/upload/RestUpload.test.ts +0 -267
  211. package/tests/unit/validateEnv.test.ts +0 -82
  212. package/tests/utils/entity-tracker.ts +0 -57
  213. package/tests/utils/index.ts +0 -13
  214. 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
+ }
@@ -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
- export interface RequestStats {
9
- operationName: string;
10
- dataLoaderCalls: { entity: number; component: number; relation: number };
11
- dbQueryCount: number;
12
- startTime: number;
13
- }
14
-
15
- declare module 'graphql-yoga' {
16
- interface Context {
17
- // Loaders mounted at top-level context for ArcheType resolver access
18
- loaders: RequestLoaders;
19
- requestId: string;
20
- cacheManager: CacheManager;
21
- requestStats: RequestStats;
22
- signal?: AbortSignal;
23
- }
24
- }
25
-
26
- /**
27
- * GraphQL Yoga plugin that creates per-request DataLoaders for batching.
28
- *
29
- * IMPORTANT: Loaders are mounted at context.loaders (NOT context.locals.loaders)
30
- * to match what ArcheType.ts resolvers expect. This enables DataLoader batching
31
- * for BelongsTo/HasMany relations, preventing N+1 queries.
32
- *
33
- * Also threads the request `AbortSignal` into Query/DataLoader DB calls so
34
- * the framework's wall-clock timeout (handled in core/app/requestRouter.ts)
35
- * cancels in-flight Postgres queries via Bun's `Query.cancel()`. Without
36
- * this, an aborted request leaks its backend connection into
37
- * `idle in transaction` under pgbouncer transaction-mode pooling.
38
- *
39
- * Captures per-request stats (operationName, DataLoader call counts,
40
- * dbQueryCount) and attaches them to the underlying Request via
41
- * `__bunsaneStats` so the HTTP router's catch handler + AccessLog
42
- * middleware can read them after the GraphQL pipeline rejects.
43
- */
44
- export function createRequestContextPlugin(): Plugin {
45
- return {
46
- onExecute: ({ args }) => {
47
- const cacheManager = CacheManager.getInstance();
48
- const ctx: any = (args as any).contextValue;
49
- const request: Request | undefined = ctx?.request;
50
- const signal: AbortSignal | undefined = request?.signal;
51
-
52
- // GraphQL operation name. Falls back to first named operation in the
53
- // document, or 'anonymous' if the client supplied an inline query
54
- // with no name.
55
- const operationName: string =
56
- (typeof args.operationName === 'string' && args.operationName)
57
- || (args.document?.definitions?.find?.(
58
- (d: any) => d?.kind === 'OperationDefinition' && d?.name?.value,
59
- ) as any)?.name?.value
60
- || 'anonymous';
61
-
62
- const stats: RequestStats = {
63
- operationName,
64
- dataLoaderCalls: { entity: 0, component: 0, relation: 0 },
65
- dbQueryCount: 0,
66
- startTime: performance.now(),
67
- };
68
-
69
- // Mount loaders at context.loaders to match ArcheType.ts resolver access pattern.
70
- ctx.loaders = createRequestLoaders(db, cacheManager, signal, stats);
71
- // Prefer the HTTP-layer request id (from requestId() middleware's
72
- // AsyncLocalStorage) so access log + GraphQL logs share the same id.
73
- ctx.requestId = getRequestId() ?? crypto.randomUUID();
74
- ctx.cacheManager = cacheManager;
75
- ctx.requestStats = stats;
76
- ctx.signal = signal;
77
-
78
- // Attach to the raw Request so the HTTP router catch block + access
79
- // log middleware can read stats after Yoga rejects.
80
- if (request) {
81
- (request as any).__bunsaneStats = stats;
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
+ }
@@ -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
- return { entityById, componentsByEntityType, relationsByEntityField };
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
  }