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/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
  }