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.
Files changed (220) hide show
  1. package/CHANGELOG.md +471 -370
  2. package/core/BatchLoader.ts +56 -32
  3. package/core/Entity.ts +93 -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 +8 -7
  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 +25 -10
  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 +383 -0
  28. package/core/entity/finders.ts +202 -0
  29. package/core/entity/getCacheManager.ts +10 -0
  30. package/core/entity/pendingOps.ts +72 -0
  31. package/core/entity/saveEntity.ts +375 -0
  32. package/core/health.ts +93 -4
  33. package/core/hooks/dispatcher.ts +439 -0
  34. package/core/hooks/guards.ts +155 -0
  35. package/core/hooks/registry.ts +247 -0
  36. package/core/metadata/definitions/Component.ts +1 -1
  37. package/core/metadata/index.ts +15 -4
  38. package/core/middleware/RateLimit.ts +102 -105
  39. package/core/middleware/RequestId.ts +2 -9
  40. package/core/middleware/SecurityHeaders.ts +2 -11
  41. package/core/middleware/headers.ts +28 -0
  42. package/core/remote/OutboxWorker.ts +213 -183
  43. package/core/remote/RemoteManager.ts +401 -400
  44. package/core/remote/StreamConsumer.ts +535 -535
  45. package/core/remote/types.ts +153 -151
  46. package/core/requestScope.ts +34 -0
  47. package/core/scheduler/cronEvaluator.ts +174 -0
  48. package/core/scheduler/lifecycleHooks.ts +21 -0
  49. package/core/scheduler/lockCoordinator.ts +27 -0
  50. package/core/scheduler/metrics.ts +14 -0
  51. package/core/scheduler/taskRunner.ts +420 -0
  52. package/core/validateEnv.ts +10 -0
  53. package/database/DatabaseHelper.ts +128 -101
  54. package/database/IndexingStrategy.ts +72 -2
  55. package/database/PreparedStatementCache.ts +8 -2
  56. package/database/cancellable.ts +35 -22
  57. package/database/index.ts +29 -3
  58. package/database/instrumentedDb.ts +141 -141
  59. package/database/sqlHelpers.ts +3 -1
  60. package/endpoints/archetypes.ts +2 -8
  61. package/endpoints/tables.ts +6 -1
  62. package/gql/index.ts +1 -1
  63. package/gql/schema/index.ts +15 -4
  64. package/gql/visitors/ResolverGeneratorVisitor.ts +25 -4
  65. package/package.json +22 -1
  66. package/query/CTENode.ts +5 -3
  67. package/query/ComponentInclusionNode.ts +245 -14
  68. package/query/OrNode.ts +8 -19
  69. package/query/Query.ts +208 -79
  70. package/query/QueryContext.ts +6 -0
  71. package/query/QueryDAG.ts +7 -2
  72. package/query/membershipSource.ts +66 -0
  73. package/storage/LocalStorageProvider.ts +8 -3
  74. package/studio/dist/assets/index-BMZ67Npg.js +254 -0
  75. package/studio/dist/assets/index-BpbuYz9g.css +1 -0
  76. package/studio/{index.html → dist/index.html} +3 -2
  77. package/swagger/generator.ts +11 -1
  78. package/upload/UploadManager.ts +8 -6
  79. package/utils/uuid.ts +40 -10
  80. package/.claude/scheduled_tasks.lock +0 -1
  81. package/.claude/settings.local.json +0 -47
  82. package/.prettierrc +0 -4
  83. package/.serena/memories/architectural-decision-no-dependency-injection.md +0 -76
  84. package/.serena/memories/architecture.md +0 -154
  85. package/.serena/memories/cache-interface-refactoring-2026-01-24.md +0 -165
  86. package/.serena/memories/code_style_and_conventions.md +0 -76
  87. package/.serena/memories/project_overview.md +0 -43
  88. package/.serena/memories/schema-dsl-plan.md +0 -107
  89. package/.serena/memories/suggested_commands.md +0 -80
  90. package/.serena/memories/typescript-compilation-status.md +0 -54
  91. package/.serena/project.yml +0 -114
  92. package/BunSane.jpg +0 -0
  93. package/CLAUDE.md +0 -198
  94. package/TODO.md +0 -2
  95. package/bun.lock +0 -302
  96. package/bunfig.toml +0 -10
  97. package/docs/RFC_APP_REFACTOR.md +0 -248
  98. package/docs/RFC_REFACTOR_TARGETS.md +0 -251
  99. package/docs/SCALABILITY_PLAN.md +0 -175
  100. package/studio/bun.lock +0 -482
  101. package/studio/package.json +0 -39
  102. package/studio/postcss.config.js +0 -6
  103. package/studio/src/components/DataTable.tsx +0 -211
  104. package/studio/src/components/Layout.tsx +0 -13
  105. package/studio/src/components/PageContainer.tsx +0 -9
  106. package/studio/src/components/PageHeader.tsx +0 -13
  107. package/studio/src/components/SearchBar.tsx +0 -57
  108. package/studio/src/components/Sidebar.tsx +0 -294
  109. package/studio/src/components/ui/button.tsx +0 -56
  110. package/studio/src/components/ui/checkbox.tsx +0 -26
  111. package/studio/src/components/ui/input.tsx +0 -25
  112. package/studio/src/hooks/useDataTable.ts +0 -131
  113. package/studio/src/index.css +0 -36
  114. package/studio/src/lib/api.ts +0 -186
  115. package/studio/src/lib/utils.ts +0 -13
  116. package/studio/src/main.tsx +0 -17
  117. package/studio/src/pages/ArcheType.tsx +0 -239
  118. package/studio/src/pages/Components.tsx +0 -124
  119. package/studio/src/pages/EntityInspector.tsx +0 -302
  120. package/studio/src/pages/QueryRunner.tsx +0 -246
  121. package/studio/src/pages/Table.tsx +0 -94
  122. package/studio/src/pages/Welcome.tsx +0 -241
  123. package/studio/src/routes.tsx +0 -45
  124. package/studio/src/store/archeTypeSettings.ts +0 -30
  125. package/studio/src/store/studio.ts +0 -65
  126. package/studio/src/utils/columnHelpers.tsx +0 -114
  127. package/studio/studio-instructions.md +0 -81
  128. package/studio/tailwind.config.js +0 -77
  129. package/studio/utils.ts +0 -54
  130. package/studio/vite.config.js +0 -19
  131. package/tests/benchmark/BENCHMARK_DATABASES_PLAN.md +0 -338
  132. package/tests/benchmark/bunfig.toml +0 -9
  133. package/tests/benchmark/fixtures/EcommerceComponents.ts +0 -283
  134. package/tests/benchmark/fixtures/EcommerceDataGenerators.ts +0 -301
  135. package/tests/benchmark/fixtures/RelationTracker.ts +0 -159
  136. package/tests/benchmark/fixtures/index.ts +0 -6
  137. package/tests/benchmark/index.ts +0 -22
  138. package/tests/benchmark/noop-preload.ts +0 -3
  139. package/tests/benchmark/query-lateral-benchmark.test.ts +0 -372
  140. package/tests/benchmark/runners/BenchmarkLoader.ts +0 -132
  141. package/tests/benchmark/runners/index.ts +0 -4
  142. package/tests/benchmark/scenarios/query-benchmarks.test.ts +0 -465
  143. package/tests/benchmark/scripts/generate-db.ts +0 -344
  144. package/tests/benchmark/scripts/run-benchmarks.ts +0 -97
  145. package/tests/e2e/http.test.ts +0 -130
  146. package/tests/fixtures/archetypes/TestUserArchetype.ts +0 -21
  147. package/tests/fixtures/components/TestOrder.ts +0 -23
  148. package/tests/fixtures/components/TestProduct.ts +0 -23
  149. package/tests/fixtures/components/TestUser.ts +0 -20
  150. package/tests/fixtures/components/index.ts +0 -6
  151. package/tests/graphql/SchemaGeneration.test.ts +0 -90
  152. package/tests/graphql/builders/ResolverBuilder.test.ts +0 -223
  153. package/tests/graphql/builders/TypeDefBuilder.test.ts +0 -153
  154. package/tests/helpers/MockRedisClient.ts +0 -113
  155. package/tests/helpers/MockRedisStreamServer.ts +0 -448
  156. package/tests/integration/archetype/ArcheType.persistence.test.ts +0 -241
  157. package/tests/integration/cache/CacheInvalidation.test.ts +0 -259
  158. package/tests/integration/entity/Entity.persistence.test.ts +0 -333
  159. package/tests/integration/entity/Entity.saveTimeout.test.ts +0 -110
  160. package/tests/integration/loaders/RequestLoaders.abort.test.ts +0 -82
  161. package/tests/integration/query/Query.abort.test.ts +0 -66
  162. package/tests/integration/query/Query.complexAnalysis.test.ts +0 -557
  163. package/tests/integration/query/Query.edgeCases.test.ts +0 -595
  164. package/tests/integration/query/Query.exec.test.ts +0 -576
  165. package/tests/integration/query/Query.explainAnalyze.test.ts +0 -233
  166. package/tests/integration/query/Query.jsonbArray.test.ts +0 -214
  167. package/tests/integration/remote/dlq.test.ts +0 -175
  168. package/tests/integration/remote/event-dispatch.test.ts +0 -114
  169. package/tests/integration/remote/outbox.test.ts +0 -130
  170. package/tests/integration/remote/rpc.test.ts +0 -177
  171. package/tests/pglite-setup.ts +0 -62
  172. package/tests/setup.ts +0 -164
  173. package/tests/stress/BenchmarkRunner.ts +0 -203
  174. package/tests/stress/DataSeeder.ts +0 -190
  175. package/tests/stress/StressTestReporter.ts +0 -229
  176. package/tests/stress/cursor-perf-test.ts +0 -171
  177. package/tests/stress/fixtures/RealisticComponents.ts +0 -235
  178. package/tests/stress/fixtures/StressTestComponents.ts +0 -58
  179. package/tests/stress/index.ts +0 -7
  180. package/tests/stress/scenarios/query-benchmarks.test.ts +0 -285
  181. package/tests/stress/scenarios/realistic-scenarios.test.ts +0 -1081
  182. package/tests/stress/scenarios/timeout-investigation.test.ts +0 -522
  183. package/tests/unit/BatchLoader.test.ts +0 -196
  184. package/tests/unit/archetype/ArcheType.test.ts +0 -107
  185. package/tests/unit/cache/CacheManager.test.ts +0 -498
  186. package/tests/unit/cache/MemoryCache.test.ts +0 -260
  187. package/tests/unit/cache/RedisCache.test.ts +0 -411
  188. package/tests/unit/database/cancellable.test.ts +0 -81
  189. package/tests/unit/database/instrumentedDb.test.ts +0 -160
  190. package/tests/unit/entity/Entity.components.test.ts +0 -317
  191. package/tests/unit/entity/Entity.drainSideEffects.test.ts +0 -51
  192. package/tests/unit/entity/Entity.reload.test.ts +0 -63
  193. package/tests/unit/entity/Entity.requireComponents.test.ts +0 -72
  194. package/tests/unit/entity/Entity.test.ts +0 -345
  195. package/tests/unit/gql/depthLimit.test.ts +0 -203
  196. package/tests/unit/gql/operationMiddleware.test.ts +0 -293
  197. package/tests/unit/health/Health.test.ts +0 -129
  198. package/tests/unit/middleware/AccessLog.test.ts +0 -37
  199. package/tests/unit/middleware/Middleware.test.ts +0 -98
  200. package/tests/unit/middleware/RequestId.test.ts +0 -54
  201. package/tests/unit/middleware/SecurityHeaders.test.ts +0 -66
  202. package/tests/unit/query/FilterBuilder.test.ts +0 -111
  203. package/tests/unit/query/JsonbArrayBuilder.test.ts +0 -178
  204. package/tests/unit/query/Query.emptyString.test.ts +0 -69
  205. package/tests/unit/query/Query.test.ts +0 -310
  206. package/tests/unit/remote/CircuitBreaker.test.ts +0 -159
  207. package/tests/unit/remote/RemoteError.test.ts +0 -55
  208. package/tests/unit/remote/decorators.test.ts +0 -195
  209. package/tests/unit/remote/metrics.test.ts +0 -115
  210. package/tests/unit/remote/mockRedisStreamServer.test.ts +0 -104
  211. package/tests/unit/scheduler/DistributedLock.test.ts +0 -274
  212. package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +0 -95
  213. package/tests/unit/schema/schema-integration.test.ts +0 -426
  214. package/tests/unit/schema/schema.test.ts +0 -580
  215. package/tests/unit/storage/S3StorageProvider.test.ts +0 -567
  216. package/tests/unit/upload/RestUpload.test.ts +0 -267
  217. package/tests/unit/validateEnv.test.ts +0 -82
  218. package/tests/utils/entity-tracker.ts +0 -57
  219. package/tests/utils/index.ts +0 -13
  220. 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
  }