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/app/cors.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import type { CorsConfig } from "../App";
2
+ import { setResponseHeaders } from "../middleware/headers";
2
3
 
3
4
  export function assertValidCorsConfig(cors: CorsConfig): void {
4
5
  if (cors.origin === undefined) {
@@ -80,15 +81,5 @@ export function addCorsHeaders(
80
81
  ): Response {
81
82
  const corsHeaders = getCorsHeaders(cors, req);
82
83
  if (Object.keys(corsHeaders).length === 0) return response;
83
-
84
- const newHeaders = new Headers(response.headers);
85
- for (const [key, value] of Object.entries(corsHeaders)) {
86
- newHeaders.set(key, value);
87
- }
88
-
89
- return new Response(response.body, {
90
- status: response.status,
91
- statusText: response.statusText,
92
- headers: newHeaders,
93
- });
84
+ return setResponseHeaders(response, Object.entries(corsHeaders));
94
85
  }
@@ -1,55 +1,15 @@
1
- import { ComponentRegistry } from "../components";
2
1
  import { logger as MainLogger } from "../Logger";
3
- import { preparedStatementCache } from "../../database/PreparedStatementCache";
4
- import db from "../../database";
5
2
 
6
3
  const logger = MainLogger.child({ scope: "App" });
7
4
 
5
+ /**
6
+ * @deprecated No-op. The framework-level prepared statement cache was
7
+ * removed from the query hot path — Bun SQL auto-prepares parameterized
8
+ * statements per connection (prepare:true default), so server-side plan
9
+ * reuse happens at the driver layer and "warming" a placeholder map bought
10
+ * nothing. Kept so bootstrap's call site and the public App surface remain
11
+ * stable.
12
+ */
8
13
  export async function warmUpPreparedStatementCache(_app: any): Promise<void> {
9
- const components = ComponentRegistry.getComponents();
10
-
11
- if (components.length === 0) {
12
- logger.trace("No components registered yet, skipping cache warm-up");
13
- return;
14
- }
15
-
16
- const commonQueries: Array<{ sql: string; key: string }> = [];
17
-
18
- commonQueries.push({
19
- sql: "SELECT COUNT(*) as count FROM (SELECT DISTINCT ec.entity_id as id FROM entity_components ec WHERE ec.deleted_at IS NULL) AS subquery",
20
- key: "count_all_entities",
21
- });
22
-
23
- for (let i = 0; i < Math.min(5, components.length); i++) {
24
- const component = components[i];
25
- if (component) {
26
- const { name } = component;
27
- const typeId = ComponentRegistry.getComponentId(name);
28
- if (typeId) {
29
- commonQueries.push({
30
- sql: `SELECT DISTINCT ec.entity_id as id FROM entity_components ec WHERE ec.type_id = '${typeId}' AND ec.deleted_at IS NULL LIMIT 10`,
31
- key: `find_${name.toLowerCase()}_sample`,
32
- });
33
- }
34
- }
35
- }
36
-
37
- if (components.length >= 2) {
38
- const typeIds = components
39
- .slice(0, 3)
40
- .map((component: { name: string; ctor: any }) =>
41
- ComponentRegistry.getComponentId(component.name)
42
- )
43
- .filter((id: string | undefined) => id)
44
- .join("','");
45
-
46
- if (typeIds) {
47
- commonQueries.push({
48
- sql: `SELECT DISTINCT ec.entity_id as id FROM entity_components ec WHERE ec.type_id IN ('${typeIds}') AND ec.deleted_at IS NULL LIMIT 10`,
49
- key: "find_multi_component_sample",
50
- });
51
- }
52
- }
53
-
54
- await preparedStatementCache.warmUp(commonQueries, db);
14
+ logger.trace("Prepared statement warm-up skipped (driver-level auto-prepare in effect)");
55
15
  }
@@ -1,6 +1,6 @@
1
1
  import * as path from "path";
2
2
  import { logger as MainLogger } from "../Logger";
3
- import { getSerializedMetadataStorage } from "../metadata";
3
+ import { getMetadataScript } from "../metadata";
4
4
  import { addCorsHeaders, getCorsHeaders } from "./cors";
5
5
  import {
6
6
  handleHealth,
@@ -24,6 +24,8 @@ function combineSignals(signals: AbortSignal[]): AbortSignal {
24
24
  controller.abort((s as any).reason);
25
25
  return controller.signal;
26
26
  }
27
+ // { once: true } auto-removes the listener after first fire, so no
28
+ // explicit removeEventListener is needed; GC cleans up the rest.
27
29
  s.addEventListener('abort', () => controller.abort((s as any).reason), { once: true });
28
30
  }
29
31
  return controller.signal;
@@ -58,6 +60,8 @@ export async function handleRequest(app: any, req: Request): Promise<Response> {
58
60
  msg: 'Request timeout',
59
61
  }, `Request timeout: ${method} ${url.pathname}`);
60
62
  }, 30000);
63
+ // Prevent the timer from keeping the Bun event loop alive at high concurrency.
64
+ (timeoutId as any).unref?.();
61
65
  const combinedSignal = combineSignals([req.signal, controller.signal]);
62
66
  req = new Request(req, { signal: combinedSignal });
63
67
 
@@ -172,9 +176,7 @@ export async function handleRequest(app: any, req: Request): Promise<Response> {
172
176
  const studioFile = Bun.file(studioIndexPath);
173
177
  if (await studioFile.exists()) {
174
178
  let html = await studioFile.text();
175
- const metadata = getSerializedMetadataStorage();
176
- const metadataScript = `<script>window.bunsaneMetadata = ${JSON.stringify(metadata)};</script>`;
177
- html = html.replace("</head>", `${metadataScript}</head>`);
179
+ html = html.replace("</head>", `${getMetadataScript()}</head>`);
178
180
  return wrap(new Response(html, {
179
181
  headers: { "Content-Type": "text/html" },
180
182
  }));
@@ -214,11 +216,10 @@ export async function handleRequest(app: any, req: Request): Promise<Response> {
214
216
  let endpoint = app.restEndpointMap.get(endpointKey);
215
217
 
216
218
  if (!endpoint) {
219
+ // Only iterate endpoints that have params (regex precompiled at registration).
217
220
  for (const ep of app.restEndpoints) {
218
- if (ep.method !== method) continue;
219
- const pattern = ep.path.replace(/:[^/]+/g, '[^/]+');
220
- const regex = new RegExp(`^${pattern}$`);
221
- if (regex.test(url.pathname)) {
221
+ if (!ep.regex || ep.method !== method) continue;
222
+ if (ep.regex.test(url.pathname)) {
222
223
  endpoint = ep;
223
224
  break;
224
225
  }
@@ -8,9 +8,17 @@ export function collectRestEndpoints(app: any, services: any[]): void {
8
8
  if (!endpoints) continue;
9
9
 
10
10
  for (const endpoint of endpoints) {
11
+ // Precompile the parameterized regex once so the hot-path router
12
+ // never calls replace + new RegExp per request.
13
+ const hasParams = endpoint.path.includes(':');
14
+ const regex = hasParams
15
+ ? new RegExp(`^${endpoint.path.replace(/:[^/]+/g, '[^/]+')}$`)
16
+ : undefined;
17
+
11
18
  const endpointInfo = {
12
19
  method: endpoint.method,
13
20
  path: endpoint.path,
21
+ regex,
14
22
  handler: endpoint.handler.bind(service),
15
23
  service: service,
16
24
  };
@@ -57,13 +57,22 @@ export function buildFieldResolvers(archetype: any): FieldResolverEntry[] {
57
57
 
58
58
  const isUnwrapped = shouldUnwrapComponent(componentProps, fieldType);
59
59
 
60
+ // Detect whether the unwrapped 'value' prop is a Date so we can
61
+ // normalize Date instances to ISO strings before they reach
62
+ // gqloom's GraphQLString coercion (which would call .valueOf() and
63
+ // emit epoch ms instead).
64
+ const unwrappedValueProp = componentProps.find(p => p.propertyKey === 'value');
65
+ const isUnwrappedDate = isUnwrapped && unwrappedValueProp?.propertyType === Date;
66
+ const normalizeDateValue = (v: any) =>
67
+ isUnwrappedDate && v instanceof Date ? v.toISOString() : v;
68
+
60
69
  if (isUnwrapped) {
61
70
  resolvers.push({
62
71
  typeName: archetypeName,
63
72
  fieldName: field,
64
73
  resolver: async (parent: any, args: any, context: any) => {
65
74
  const entityId = parent?.id;
66
- if (!entityId) return (parent as any)[field];
75
+ if (!entityId) return normalizeDateValue((parent as any)[field]);
67
76
 
68
77
  if (parent instanceof Entity) {
69
78
  if (parent.wasRemoved(componentCtor)) {
@@ -71,7 +80,7 @@ export function buildFieldResolvers(archetype: any): FieldResolverEntry[] {
71
80
  }
72
81
  const inMemoryComp = parent.getInMemory(componentCtor);
73
82
  if (inMemoryComp) {
74
- return (inMemoryComp as any)?.value;
83
+ return normalizeDateValue((inMemoryComp as any)?.value);
75
84
  }
76
85
  }
77
86
 
@@ -82,13 +91,13 @@ export function buildFieldResolvers(archetype: any): FieldResolverEntry[] {
82
91
  typeId: typeIdHex,
83
92
  });
84
93
  if (componentData?.data?.value !== undefined) {
85
- return componentData.data.value;
94
+ return normalizeDateValue(componentData.data.value);
86
95
  }
87
96
  }
88
97
 
89
98
  const entity = await ensureEntity(parent, context);
90
99
  const comp = await entity.get(componentCtor);
91
- return (comp as any)?.value;
100
+ return normalizeDateValue((comp as any)?.value);
92
101
  },
93
102
  });
94
103
  } else {
@@ -129,10 +138,17 @@ export function buildFieldResolvers(archetype: any): FieldResolverEntry[] {
129
138
  const componentTypeName = compNameToFieldName(componentName);
130
139
 
131
140
  for (const prop of componentProps) {
141
+ const isDateProp = prop.propertyType === Date;
132
142
  resolvers.push({
133
143
  typeName: componentTypeName,
134
144
  fieldName: prop.propertyKey,
135
- resolver: (parent: any) => parent[prop.propertyKey],
145
+ resolver: (parent: any) => {
146
+ const v = parent[prop.propertyKey];
147
+ if (isDateProp && v instanceof Date) {
148
+ return v.toISOString();
149
+ }
150
+ return v;
151
+ },
136
152
  });
137
153
  }
138
154
  }
@@ -313,6 +329,54 @@ export function buildFieldResolvers(archetype: any): FieldResolverEntry[] {
313
329
  },
314
330
  });
315
331
  } else if (isArray) {
332
+ // Resolve the FK-bearing component + field ONCE (lazily, then
333
+ // memoized) rather than re-instantiating the related archetype and
334
+ // walking its component metadata on every parent row. The result is
335
+ // captured in the resolver closure.
336
+ let fkResolution:
337
+ | { componentCtor: any; componentTypeId: string; foreignKeyField: string }
338
+ | null
339
+ | undefined;
340
+ const resolveFk = () => {
341
+ if (fkResolution !== undefined) return fkResolution;
342
+ fkResolution = null;
343
+ if (!relationOptions?.foreignKey) return fkResolution;
344
+
345
+ let relatedArchetypeInstance: any = null;
346
+ if (typeof relatedArcheType === "function") {
347
+ relatedArchetypeInstance = new (relatedArcheType as any)();
348
+ } else if (typeof relatedArcheType === "string") {
349
+ const meta = storage.archetypes.find((a) => a.name === relatedArcheType);
350
+ if (meta) relatedArchetypeInstance = new (meta.target as any)();
351
+ }
352
+ if (!relatedArchetypeInstance) return fkResolution;
353
+
354
+ let componentCtor: any = null;
355
+ let foreignKeyField: string = relationOptions.foreignKey;
356
+ if (relationOptions.foreignKey.includes('.')) {
357
+ const [fieldName, propName] = relationOptions.foreignKey.split('.');
358
+ componentCtor = relatedArchetypeInstance.componentMap[fieldName!];
359
+ foreignKeyField = propName!;
360
+ } else {
361
+ for (const comp of Object.values(relatedArchetypeInstance.componentMap) as any[]) {
362
+ const typeId = storage.getComponentId(comp.name);
363
+ const props = storage.getComponentProperties(typeId);
364
+ if (props.some(p => p.propertyKey === relationOptions.foreignKey)) {
365
+ componentCtor = comp;
366
+ break;
367
+ }
368
+ }
369
+ }
370
+ if (componentCtor) {
371
+ fkResolution = {
372
+ componentCtor,
373
+ componentTypeId: storage.getComponentId(componentCtor.name),
374
+ foreignKeyField,
375
+ };
376
+ }
377
+ return fkResolution;
378
+ };
379
+
316
380
  resolvers.push({
317
381
  typeName: archetypeName,
318
382
  fieldName: field,
@@ -321,44 +385,25 @@ export function buildFieldResolvers(archetype: any): FieldResolverEntry[] {
321
385
  if (!entityId) return [];
322
386
 
323
387
  if (relationOptions?.foreignKey) {
324
- let componentCtor: any = null;
325
- let foreignKeyField: string = relationOptions.foreignKey;
326
- let relatedArchetypeInstance: any = null;
327
-
328
- if (typeof relatedArcheType === "function") {
329
- relatedArchetypeInstance = new (relatedArcheType as any)();
330
- } else if (typeof relatedArcheType === "string") {
331
- const relatedArchetypeMetadata = storage.archetypes.find((a) => a.name === relatedArcheType);
332
- if (relatedArchetypeMetadata) {
333
- relatedArchetypeInstance = new (relatedArchetypeMetadata.target as any)();
334
- }
335
- }
336
-
337
- if (relatedArchetypeInstance) {
338
- if (relationOptions.foreignKey.includes('.')) {
339
- const [fieldName, propName] = relationOptions.foreignKey.split('.');
340
- componentCtor = relatedArchetypeInstance.componentMap[fieldName!];
341
- foreignKeyField = propName!;
342
- } else {
343
- for (const comp of Object.values(relatedArchetypeInstance.componentMap) as any[]) {
344
- const typeId = storage.getComponentId(comp.name);
345
- const props = storage.getComponentProperties(typeId);
346
- if (props.some(p => p.propertyKey === relationOptions.foreignKey)) {
347
- componentCtor = comp;
348
- break;
349
- }
350
- }
351
- }
352
- }
353
-
354
- if (componentCtor) {
355
- const query = new Query();
356
- query.with(componentCtor, Query.filters(Query.filter(foreignKeyField, Query.filterOp.EQ, entityId)));
357
- return await query.exec();
358
- } else {
388
+ const r = resolveFk();
389
+ if (!r) {
359
390
  console.warn(`No component found with foreign key ${relationOptions.foreignKey} in ${relatedTypeName}`);
360
391
  return [];
361
392
  }
393
+ // Batched path: dedups across sibling parents in the
394
+ // same request via the type-scoped FK loader (was N+1).
395
+ if (context?.loaders?.relationsByComponentFk) {
396
+ return await context.loaders.relationsByComponentFk.load({
397
+ entityId,
398
+ componentTypeId: r.componentTypeId,
399
+ foreignKeyField: r.foreignKeyField,
400
+ });
401
+ }
402
+ // Fallback for non-request contexts (direct service
403
+ // calls with no loaders mounted): single query.
404
+ const query = new Query();
405
+ query.with(r.componentCtor, Query.filters(Query.filter(r.foreignKeyField, Query.filterOp.EQ, entityId)));
406
+ return await query.exec();
362
407
  } else {
363
408
  if (context?.loaders?.relationsByEntityField) {
364
409
  return context.loaders.relationsByEntityField.load({
@@ -1,118 +1,161 @@
1
1
  import { Entity } from "../Entity";
2
2
  import { getMetadataStorage } from "../metadata";
3
3
  import { Query } from "../../query";
4
+ import { getRequestScope } from "../requestScope";
4
5
 
5
6
  /**
6
7
  * Populate relation fields on an entity according to the archetype's relationMap.
7
8
  * Extracted from BaseArcheType.populateRelations().
9
+ *
10
+ * When called inside a request scope (GraphQL execution), relation loads go
11
+ * through the request's DataLoaders so sibling entities resolved in the same
12
+ * tick batch into single queries (previously: one `new Query()` per relation
13
+ * per entity — a hard N+1). Relation fields of one entity are resolved
14
+ * concurrently for the same reason.
8
15
  */
9
16
  export async function populateRelations(archetype: any, entity: Entity): Promise<void> {
10
17
  const storage = getMetadataStorage();
11
18
 
19
+ const fieldPromises: Promise<void>[] = [];
12
20
  for (const [fieldName, relatedArchetype] of Object.entries(archetype.relationMap)) {
13
21
  const relationType = archetype.relationTypes[fieldName];
14
22
  const relationOptions = archetype.relationOptions[fieldName];
15
23
 
16
24
  if (relationType === "belongsTo") {
17
- const foreignKey = relationOptions?.foreignKey;
18
- if (foreignKey) {
19
- let foreignId: string | undefined;
20
-
21
- if (foreignKey.includes('.')) {
22
- const [innerField, propName] = foreignKey.split('.');
23
- const compCtor = archetype.componentMap[innerField!];
24
- if (compCtor) {
25
- const componentInstance = await entity.get(compCtor as any);
26
- if (componentInstance && (componentInstance as any)[propName!] !== undefined) {
27
- foreignId = (componentInstance as any)[propName!];
28
- }
29
- }
30
- } else {
31
- const candidateComponents: Array<{ compCtor: any }> = [];
32
- for (const compCtor of Object.values(archetype.componentMap)) {
33
- const compCtorAny = compCtor as any;
34
- const typeId = storage.getComponentId(compCtorAny.name);
35
- const componentProps = storage.getComponentProperties(typeId);
36
- const hasForeignKey = componentProps.some(prop => prop.propertyKey === foreignKey);
37
- if (hasForeignKey) {
38
- candidateComponents.push({ compCtor: compCtorAny });
39
- }
40
- }
41
-
42
- if (candidateComponents.length > 0) {
43
- const componentInstances = await Promise.all(
44
- candidateComponents.map(({ compCtor }) => entity.get(compCtor as any))
45
- );
46
-
47
- for (const componentInstance of componentInstances) {
48
- if (componentInstance && (componentInstance as any)[foreignKey] !== undefined) {
49
- foreignId = (componentInstance as any)[foreignKey];
50
- break;
51
- }
52
- }
53
- }
54
- }
25
+ fieldPromises.push(populateBelongsTo(archetype, entity, fieldName, relatedArchetype, relationOptions, storage));
26
+ } else if (relationType === "hasMany") {
27
+ fieldPromises.push(populateHasMany(entity, fieldName, relatedArchetype, relationOptions, storage));
28
+ }
29
+ }
30
+ await Promise.all(fieldPromises);
31
+ }
55
32
 
56
- if (!foreignId && foreignKey === 'id') {
57
- foreignId = entity.id;
58
- }
33
+ function resolveRelatedArchetypeInstance(relatedArchetype: any, storage: any): any | null {
34
+ if (typeof relatedArchetype === "function") {
35
+ return new (relatedArchetype as any)();
36
+ }
37
+ const meta = storage.archetypes.find((a: any) => a.name === relatedArchetype);
38
+ return meta ? new (meta.target as any)() : null;
39
+ }
59
40
 
60
- if (foreignId) {
61
- let relatedArchetypeInstance: any;
62
- if (typeof relatedArchetype === "function") {
63
- relatedArchetypeInstance = new (relatedArchetype as any)();
64
- } else {
65
- const relatedArchetypeMetadata = storage.archetypes.find((a) => a.name === relatedArchetype);
66
- if (relatedArchetypeMetadata) {
67
- relatedArchetypeInstance = new (relatedArchetypeMetadata.target as any)();
68
- } else {
69
- continue;
70
- }
71
- }
72
-
73
- const relatedEntity = await relatedArchetypeInstance.getEntityWithID(foreignId);
74
- if (relatedEntity) {
75
- (entity as any)[fieldName] = relatedEntity;
76
- }
77
- }
78
- }
79
- } else if (relationType === "hasMany") {
80
- const foreignKey = relationOptions?.foreignKey;
81
- if (foreignKey) {
82
- let relatedArchetypeInstance: any;
83
- if (typeof relatedArchetype === "function") {
84
- relatedArchetypeInstance = new (relatedArchetype as any)();
85
- } else {
86
- const relatedArchetypeMetadata = storage.archetypes.find((a) => a.name === relatedArchetype);
87
- if (relatedArchetypeMetadata) {
88
- relatedArchetypeInstance = new (relatedArchetypeMetadata.target as any)();
89
- } else {
90
- continue;
91
- }
92
- }
41
+ async function populateBelongsTo(
42
+ archetype: any,
43
+ entity: Entity,
44
+ fieldName: string,
45
+ relatedArchetype: any,
46
+ relationOptions: any,
47
+ storage: any,
48
+ ): Promise<void> {
49
+ const foreignKey = relationOptions?.foreignKey;
50
+ if (!foreignKey) return;
93
51
 
94
- let foreignKeyComponent: any = null;
95
- for (const compCtor of Object.values(relatedArchetypeInstance.componentMap)) {
96
- const compCtorAny = compCtor as any;
97
- const typeId = storage.getComponentId(compCtorAny.name);
98
- const componentProps = storage.getComponentProperties(typeId);
99
- const hasForeignKey = componentProps.some(prop => prop.propertyKey === foreignKey);
100
- if (hasForeignKey) {
101
- foreignKeyComponent = compCtorAny;
102
- break;
103
- }
104
- }
52
+ let foreignId: string | undefined;
53
+
54
+ if (foreignKey.includes('.')) {
55
+ const [innerField, propName] = foreignKey.split('.');
56
+ const compCtor = archetype.componentMap[innerField!];
57
+ if (compCtor) {
58
+ // entity.get batches via the ambient request scope when present
59
+ const componentInstance = await entity.get(compCtor as any);
60
+ if (componentInstance && (componentInstance as any)[propName!] !== undefined) {
61
+ foreignId = (componentInstance as any)[propName!];
62
+ }
63
+ }
64
+ } else {
65
+ const candidateComponents: Array<{ compCtor: any }> = [];
66
+ for (const compCtor of Object.values(archetype.componentMap)) {
67
+ const compCtorAny = compCtor as any;
68
+ const typeId = storage.getComponentId(compCtorAny.name);
69
+ const componentProps = storage.getComponentProperties(typeId);
70
+ const hasForeignKey = componentProps.some((prop: any) => prop.propertyKey === foreignKey);
71
+ if (hasForeignKey) {
72
+ candidateComponents.push({ compCtor: compCtorAny });
73
+ }
74
+ }
105
75
 
106
- if (foreignKeyComponent) {
107
- const matchingEntities = await new Query()
108
- .with(foreignKeyComponent, {
109
- filters: [{ field: foreignKey, operator: '=', value: entity.id }]
110
- })
111
- .exec();
76
+ if (candidateComponents.length > 0) {
77
+ const componentInstances = await Promise.all(
78
+ candidateComponents.map(({ compCtor }) => entity.get(compCtor as any))
79
+ );
112
80
 
113
- (entity as any)[fieldName] = matchingEntities;
81
+ for (const componentInstance of componentInstances) {
82
+ if (componentInstance && (componentInstance as any)[foreignKey] !== undefined) {
83
+ foreignId = (componentInstance as any)[foreignKey];
84
+ break;
114
85
  }
115
86
  }
116
87
  }
117
88
  }
89
+
90
+ if (!foreignId && foreignKey === 'id') {
91
+ foreignId = entity.id;
92
+ }
93
+ if (!foreignId) return;
94
+
95
+ // Batched path: the request-scoped entityById loader dedups/batches
96
+ // sibling lookups. The returned shell entity lazy-loads components
97
+ // through the same scope's component loader.
98
+ const scope = getRequestScope();
99
+ if (scope?.loaders?.entityById) {
100
+ const relatedEntity = await scope.loaders.entityById.load(foreignId);
101
+ if (relatedEntity) {
102
+ (entity as any)[fieldName] = relatedEntity;
103
+ }
104
+ return;
105
+ }
106
+
107
+ const relatedArchetypeInstance = resolveRelatedArchetypeInstance(relatedArchetype, storage);
108
+ if (!relatedArchetypeInstance) return;
109
+ const relatedEntity = await relatedArchetypeInstance.getEntityWithID(foreignId);
110
+ if (relatedEntity) {
111
+ (entity as any)[fieldName] = relatedEntity;
112
+ }
113
+ }
114
+
115
+ async function populateHasMany(
116
+ entity: Entity,
117
+ fieldName: string,
118
+ relatedArchetype: any,
119
+ relationOptions: any,
120
+ storage: any,
121
+ ): Promise<void> {
122
+ const foreignKey = relationOptions?.foreignKey;
123
+ if (!foreignKey) return;
124
+
125
+ const relatedArchetypeInstance = resolveRelatedArchetypeInstance(relatedArchetype, storage);
126
+ if (!relatedArchetypeInstance) return;
127
+
128
+ let foreignKeyComponent: any = null;
129
+ for (const compCtor of Object.values(relatedArchetypeInstance.componentMap)) {
130
+ const compCtorAny = compCtor as any;
131
+ const typeId = storage.getComponentId(compCtorAny.name);
132
+ const componentProps = storage.getComponentProperties(typeId);
133
+ const hasForeignKey = componentProps.some((prop: any) => prop.propertyKey === foreignKey);
134
+ if (hasForeignKey) {
135
+ foreignKeyComponent = compCtorAny;
136
+ break;
137
+ }
138
+ }
139
+ if (!foreignKeyComponent) return;
140
+
141
+ // Batched path: type-scoped FK loader collapses sibling parents sharing
142
+ // the same (componentType, fkField) into one query.
143
+ const scope = getRequestScope();
144
+ if (scope?.loaders?.relationsByComponentFk) {
145
+ const componentTypeId = storage.getComponentId(foreignKeyComponent.name);
146
+ (entity as any)[fieldName] = await scope.loaders.relationsByComponentFk.load({
147
+ entityId: entity.id,
148
+ componentTypeId,
149
+ foreignKeyField: foreignKey,
150
+ });
151
+ return;
152
+ }
153
+
154
+ const matchingEntities = await new Query()
155
+ .with(foreignKeyComponent, {
156
+ filters: [{ field: foreignKey, operator: '=', value: entity.id }]
157
+ })
158
+ .exec();
159
+
160
+ (entity as any)[fieldName] = matchingEntities;
118
161
  }