bunsane 0.3.1 → 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 (224) hide show
  1. package/CHANGELOG.md +445 -318
  2. package/config/cache.config.ts +35 -1
  3. package/core/App.ts +24 -1064
  4. package/core/ArcheType.ts +78 -2110
  5. package/core/BatchLoader.ts +56 -32
  6. package/core/Entity.ts +85 -1043
  7. package/core/EntityHookManager.ts +52 -754
  8. package/core/Logger.ts +10 -0
  9. package/core/RequestContext.ts +64 -6
  10. package/core/RequestLoaders.ts +187 -36
  11. package/core/SchedulerManager.ts +28 -600
  12. package/core/app/bootstrap.ts +133 -0
  13. package/core/app/cors.ts +85 -0
  14. package/core/app/graphqlSetup.ts +56 -0
  15. package/core/app/healthEndpoints.ts +31 -0
  16. package/core/app/metricsCollector.ts +27 -0
  17. package/core/app/preparedStatementWarmup.ts +15 -0
  18. package/core/app/processHandlers.ts +43 -0
  19. package/core/app/requestRouter.ts +310 -0
  20. package/core/app/restRegistry.ts +80 -0
  21. package/core/app/shutdown.ts +97 -0
  22. package/core/app/studioRouter.ts +83 -0
  23. package/core/archetype/customTypes.ts +100 -0
  24. package/core/archetype/decorators.ts +171 -0
  25. package/core/archetype/fieldResolvers.ts +666 -0
  26. package/core/archetype/helpers.ts +29 -0
  27. package/core/archetype/relationLoader.ts +161 -0
  28. package/core/archetype/schemaBuilder.ts +141 -0
  29. package/core/archetype/weaver.ts +218 -0
  30. package/core/archetype/zodSchemaBuilder.ts +527 -0
  31. package/core/cache/CacheManager.ts +173 -267
  32. package/core/cache/CompressionUtils.ts +34 -3
  33. package/core/cache/MemoryCache.ts +40 -37
  34. package/core/cache/RedisCache.ts +4 -4
  35. package/core/cache/health.ts +30 -0
  36. package/core/cache/invalidation.ts +96 -0
  37. package/core/cache/strategies/writeInvalidate.ts +111 -0
  38. package/core/cache/strategies/writeThrough.ts +233 -0
  39. package/core/components/BaseComponent.ts +16 -8
  40. package/core/components/ComponentRegistry.ts +28 -0
  41. package/core/decorators/IndexedField.ts +1 -1
  42. package/core/entity/cacheStrategies.ts +97 -0
  43. package/core/entity/componentAccess.ts +364 -0
  44. package/core/entity/finders.ts +202 -0
  45. package/core/entity/pendingOps.ts +72 -0
  46. package/core/entity/saveEntity.ts +377 -0
  47. package/core/hooks/dispatcher.ts +439 -0
  48. package/core/hooks/guards.ts +155 -0
  49. package/core/hooks/registry.ts +247 -0
  50. package/core/metadata/definitions/Component.ts +1 -1
  51. package/core/metadata/index.ts +15 -4
  52. package/core/middleware/AccessLog.ts +8 -1
  53. package/core/middleware/RateLimit.ts +102 -105
  54. package/core/middleware/RequestId.ts +2 -9
  55. package/core/middleware/SecurityHeaders.ts +2 -11
  56. package/core/middleware/headers.ts +28 -0
  57. package/core/remote/OutboxWorker.ts +213 -183
  58. package/core/remote/RemoteManager.ts +401 -400
  59. package/core/remote/types.ts +153 -151
  60. package/core/requestScope.ts +34 -0
  61. package/core/scheduler/cronEvaluator.ts +174 -0
  62. package/core/scheduler/lifecycleHooks.ts +21 -0
  63. package/core/scheduler/lockCoordinator.ts +27 -0
  64. package/core/scheduler/metrics.ts +14 -0
  65. package/core/scheduler/taskRunner.ts +420 -0
  66. package/database/DatabaseHelper.ts +128 -101
  67. package/database/IndexingStrategy.ts +72 -2
  68. package/database/PreparedStatementCache.ts +20 -5
  69. package/database/cancellable.ts +35 -0
  70. package/database/index.ts +15 -3
  71. package/database/instrumentedDb.ts +141 -0
  72. package/endpoints/archetypes.ts +2 -8
  73. package/endpoints/tables.ts +6 -1
  74. package/gql/index.ts +1 -1
  75. package/gql/visitors/ResolverGeneratorVisitor.ts +25 -4
  76. package/package.json +22 -1
  77. package/query/CTENode.ts +5 -3
  78. package/query/ComponentInclusionNode.ts +240 -13
  79. package/query/OrNode.ts +6 -5
  80. package/query/Query.ts +203 -59
  81. package/query/QueryContext.ts +6 -0
  82. package/query/QueryDAG.ts +7 -2
  83. package/query/membershipSource.ts +66 -0
  84. package/storage/LocalStorageProvider.ts +8 -3
  85. package/studio/dist/assets/index-BMZ67Npg.js +254 -0
  86. package/studio/dist/assets/index-BpbuYz9g.css +1 -0
  87. package/studio/{index.html → dist/index.html} +3 -2
  88. package/swagger/generator.ts +11 -1
  89. package/upload/UploadManager.ts +8 -6
  90. package/utils/uuid.ts +40 -10
  91. package/.claude/settings.local.json +0 -47
  92. package/.prettierrc +0 -4
  93. package/.serena/memories/architectural-decision-no-dependency-injection.md +0 -76
  94. package/.serena/memories/architecture.md +0 -154
  95. package/.serena/memories/cache-interface-refactoring-2026-01-24.md +0 -165
  96. package/.serena/memories/code_style_and_conventions.md +0 -76
  97. package/.serena/memories/project_overview.md +0 -43
  98. package/.serena/memories/schema-dsl-plan.md +0 -107
  99. package/.serena/memories/suggested_commands.md +0 -80
  100. package/.serena/memories/typescript-compilation-status.md +0 -54
  101. package/.serena/project.yml +0 -114
  102. package/BunSane.jpg +0 -0
  103. package/CLAUDE.md +0 -198
  104. package/TODO.md +0 -2
  105. package/bun.lock +0 -302
  106. package/bunfig.toml +0 -10
  107. package/docs/SCALABILITY_PLAN.md +0 -175
  108. package/studio/bun.lock +0 -482
  109. package/studio/package.json +0 -39
  110. package/studio/postcss.config.js +0 -6
  111. package/studio/src/components/DataTable.tsx +0 -211
  112. package/studio/src/components/Layout.tsx +0 -13
  113. package/studio/src/components/PageContainer.tsx +0 -9
  114. package/studio/src/components/PageHeader.tsx +0 -13
  115. package/studio/src/components/SearchBar.tsx +0 -57
  116. package/studio/src/components/Sidebar.tsx +0 -294
  117. package/studio/src/components/ui/button.tsx +0 -56
  118. package/studio/src/components/ui/checkbox.tsx +0 -26
  119. package/studio/src/components/ui/input.tsx +0 -25
  120. package/studio/src/hooks/useDataTable.ts +0 -131
  121. package/studio/src/index.css +0 -36
  122. package/studio/src/lib/api.ts +0 -186
  123. package/studio/src/lib/utils.ts +0 -13
  124. package/studio/src/main.tsx +0 -17
  125. package/studio/src/pages/ArcheType.tsx +0 -239
  126. package/studio/src/pages/Components.tsx +0 -124
  127. package/studio/src/pages/EntityInspector.tsx +0 -302
  128. package/studio/src/pages/QueryRunner.tsx +0 -246
  129. package/studio/src/pages/Table.tsx +0 -94
  130. package/studio/src/pages/Welcome.tsx +0 -241
  131. package/studio/src/routes.tsx +0 -45
  132. package/studio/src/store/archeTypeSettings.ts +0 -30
  133. package/studio/src/store/studio.ts +0 -65
  134. package/studio/src/utils/columnHelpers.tsx +0 -114
  135. package/studio/studio-instructions.md +0 -81
  136. package/studio/tailwind.config.js +0 -77
  137. package/studio/utils.ts +0 -54
  138. package/studio/vite.config.js +0 -19
  139. package/tests/benchmark/BENCHMARK_DATABASES_PLAN.md +0 -338
  140. package/tests/benchmark/bunfig.toml +0 -9
  141. package/tests/benchmark/fixtures/EcommerceComponents.ts +0 -283
  142. package/tests/benchmark/fixtures/EcommerceDataGenerators.ts +0 -301
  143. package/tests/benchmark/fixtures/RelationTracker.ts +0 -159
  144. package/tests/benchmark/fixtures/index.ts +0 -6
  145. package/tests/benchmark/index.ts +0 -22
  146. package/tests/benchmark/noop-preload.ts +0 -3
  147. package/tests/benchmark/query-lateral-benchmark.test.ts +0 -372
  148. package/tests/benchmark/runners/BenchmarkLoader.ts +0 -132
  149. package/tests/benchmark/runners/index.ts +0 -4
  150. package/tests/benchmark/scenarios/query-benchmarks.test.ts +0 -465
  151. package/tests/benchmark/scripts/generate-db.ts +0 -344
  152. package/tests/benchmark/scripts/run-benchmarks.ts +0 -97
  153. package/tests/e2e/http.test.ts +0 -130
  154. package/tests/fixtures/archetypes/TestUserArchetype.ts +0 -21
  155. package/tests/fixtures/components/TestOrder.ts +0 -23
  156. package/tests/fixtures/components/TestProduct.ts +0 -23
  157. package/tests/fixtures/components/TestUser.ts +0 -20
  158. package/tests/fixtures/components/index.ts +0 -6
  159. package/tests/graphql/SchemaGeneration.test.ts +0 -90
  160. package/tests/graphql/builders/ResolverBuilder.test.ts +0 -223
  161. package/tests/graphql/builders/TypeDefBuilder.test.ts +0 -153
  162. package/tests/helpers/MockRedisClient.ts +0 -113
  163. package/tests/helpers/MockRedisStreamServer.ts +0 -448
  164. package/tests/integration/archetype/ArcheType.persistence.test.ts +0 -241
  165. package/tests/integration/cache/CacheInvalidation.test.ts +0 -259
  166. package/tests/integration/entity/Entity.persistence.test.ts +0 -333
  167. package/tests/integration/entity/Entity.saveTimeout.test.ts +0 -110
  168. package/tests/integration/query/Query.complexAnalysis.test.ts +0 -557
  169. package/tests/integration/query/Query.edgeCases.test.ts +0 -595
  170. package/tests/integration/query/Query.exec.test.ts +0 -576
  171. package/tests/integration/query/Query.explainAnalyze.test.ts +0 -233
  172. package/tests/integration/query/Query.jsonbArray.test.ts +0 -214
  173. package/tests/integration/remote/dlq.test.ts +0 -175
  174. package/tests/integration/remote/event-dispatch.test.ts +0 -114
  175. package/tests/integration/remote/outbox.test.ts +0 -130
  176. package/tests/integration/remote/rpc.test.ts +0 -177
  177. package/tests/pglite-setup.ts +0 -62
  178. package/tests/setup.ts +0 -164
  179. package/tests/stress/BenchmarkRunner.ts +0 -203
  180. package/tests/stress/DataSeeder.ts +0 -190
  181. package/tests/stress/StressTestReporter.ts +0 -229
  182. package/tests/stress/cursor-perf-test.ts +0 -171
  183. package/tests/stress/fixtures/RealisticComponents.ts +0 -235
  184. package/tests/stress/fixtures/StressTestComponents.ts +0 -58
  185. package/tests/stress/index.ts +0 -7
  186. package/tests/stress/scenarios/query-benchmarks.test.ts +0 -285
  187. package/tests/stress/scenarios/realistic-scenarios.test.ts +0 -1081
  188. package/tests/stress/scenarios/timeout-investigation.test.ts +0 -522
  189. package/tests/unit/BatchLoader.test.ts +0 -196
  190. package/tests/unit/archetype/ArcheType.test.ts +0 -107
  191. package/tests/unit/cache/CacheManager.test.ts +0 -367
  192. package/tests/unit/cache/MemoryCache.test.ts +0 -260
  193. package/tests/unit/cache/RedisCache.test.ts +0 -411
  194. package/tests/unit/entity/Entity.components.test.ts +0 -317
  195. package/tests/unit/entity/Entity.drainSideEffects.test.ts +0 -51
  196. package/tests/unit/entity/Entity.reload.test.ts +0 -63
  197. package/tests/unit/entity/Entity.requireComponents.test.ts +0 -72
  198. package/tests/unit/entity/Entity.test.ts +0 -345
  199. package/tests/unit/gql/depthLimit.test.ts +0 -203
  200. package/tests/unit/gql/operationMiddleware.test.ts +0 -293
  201. package/tests/unit/health/Health.test.ts +0 -129
  202. package/tests/unit/middleware/AccessLog.test.ts +0 -37
  203. package/tests/unit/middleware/Middleware.test.ts +0 -98
  204. package/tests/unit/middleware/RequestId.test.ts +0 -54
  205. package/tests/unit/middleware/SecurityHeaders.test.ts +0 -66
  206. package/tests/unit/query/FilterBuilder.test.ts +0 -111
  207. package/tests/unit/query/JsonbArrayBuilder.test.ts +0 -178
  208. package/tests/unit/query/Query.emptyString.test.ts +0 -69
  209. package/tests/unit/query/Query.test.ts +0 -310
  210. package/tests/unit/remote/CircuitBreaker.test.ts +0 -159
  211. package/tests/unit/remote/RemoteError.test.ts +0 -55
  212. package/tests/unit/remote/decorators.test.ts +0 -195
  213. package/tests/unit/remote/metrics.test.ts +0 -115
  214. package/tests/unit/remote/mockRedisStreamServer.test.ts +0 -104
  215. package/tests/unit/scheduler/DistributedLock.test.ts +0 -274
  216. package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +0 -95
  217. package/tests/unit/schema/schema-integration.test.ts +0 -426
  218. package/tests/unit/schema/schema.test.ts +0 -580
  219. package/tests/unit/storage/S3StorageProvider.test.ts +0 -567
  220. package/tests/unit/upload/RestUpload.test.ts +0 -267
  221. package/tests/unit/validateEnv.test.ts +0 -82
  222. package/tests/utils/entity-tracker.ts +0 -57
  223. package/tests/utils/index.ts +0 -13
  224. 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
+ }
@@ -4,6 +4,14 @@ import type { RequestLoaders } from './RequestLoaders';
4
4
  import db from '../database';
5
5
  import { CacheManager } from './cache/CacheManager';
6
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
+ }
7
15
 
8
16
  declare module 'graphql-yoga' {
9
17
  interface Context {
@@ -11,6 +19,8 @@ declare module 'graphql-yoga' {
11
19
  loaders: RequestLoaders;
12
20
  requestId: string;
13
21
  cacheManager: CacheManager;
22
+ requestStats: RequestStats;
23
+ signal?: AbortSignal;
14
24
  }
15
25
  }
16
26
 
@@ -20,17 +30,65 @@ declare module 'graphql-yoga' {
20
30
  * IMPORTANT: Loaders are mounted at context.loaders (NOT context.locals.loaders)
21
31
  * to match what ArcheType.ts resolvers expect. This enables DataLoader batching
22
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.
23
44
  */
24
45
  export function createRequestContextPlugin(): Plugin {
25
46
  return {
26
- onExecute: ({ args }) => {
47
+ onExecute: ({ args, executeFn, setExecuteFn }) => {
27
48
  const cacheManager = CacheManager.getInstance();
28
- // Mount loaders at context.loaders to match ArcheType.ts resolver access pattern
29
- (args as any).contextValue.loaders = createRequestLoaders(db, cacheManager);
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);
30
72
  // Prefer the HTTP-layer request id (from requestId() middleware's
31
73
  // AsyncLocalStorage) so access log + GraphQL logs share the same id.
32
- (args as any).contextValue.requestId = getRequestId() ?? crypto.randomUUID();
33
- (args as any).contextValue.cacheManager = cacheManager;
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);
34
92
  },
35
93
  };
36
- }
94
+ }
@@ -2,10 +2,12 @@ import DataLoader from 'dataloader';
2
2
  import { Entity } from './Entity';
3
3
  import db from '../database';
4
4
  import { inList } from '../database/sqlHelpers';
5
+ import { timedUnsafe, incrementDataLoaderCall, type PerRequestCounters } from '../database/instrumentedDb';
5
6
  import {logger as MainLogger} from './Logger';
6
7
  const logger = MainLogger.child({ module: 'RequestLoaders' });
7
8
  import { getMetadataStorage } from './metadata';
8
9
  import type { CacheManager } from './cache/CacheManager';
10
+ import { COMPONENT_TOMBSTONE } from './cache/CacheManager';
9
11
 
10
12
  export type ComponentData = {
11
13
  id: string; // Component ID for updates
@@ -21,10 +23,17 @@ export type RequestLoaders = {
21
23
  entityById: DataLoader<string, Entity | null>;
22
24
  componentsByEntityType: DataLoader<{ entityId: string; typeId: string }, ComponentData | null>;
23
25
  relationsByEntityField: DataLoader<{ entityId: string; relationField: string; relatedType: string; foreignKey?: string }, Entity[]>;
26
+ relationsByComponentFk: DataLoader<{ entityId: string; componentTypeId: string; foreignKeyField: string }, Entity[]>;
24
27
  };
25
28
 
26
- export function createRequestLoaders(db: any, cacheManager?: CacheManager): RequestLoaders {
29
+ export function createRequestLoaders(
30
+ db: any,
31
+ cacheManager?: CacheManager,
32
+ signal?: AbortSignal,
33
+ perRequest?: PerRequestCounters,
34
+ ): RequestLoaders {
27
35
  const entityById = new DataLoader<string, Entity | null>(async (ids: readonly string[]) => {
36
+ incrementDataLoaderCall('entity', perRequest);
28
37
  const startTime = Date.now();
29
38
  try {
30
39
  // Filter out empty/invalid IDs to prevent PostgreSQL UUID parsing errors
@@ -44,12 +53,12 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
44
53
 
45
54
  if (missingIds.length > 0) {
46
55
  const idList = inList(missingIds, 1);
47
- const rows = await db.unsafe(`
56
+ const rows = await timedUnsafe<any[]>(db, `
48
57
  SELECT id
49
58
  FROM entities
50
59
  WHERE id IN ${idList.sql}
51
60
  AND deleted_at IS NULL
52
- `, idList.params);
61
+ `, idList.params, signal, perRequest);
53
62
 
54
63
  const entities = rows.map((row: any) => {
55
64
  const entity = new Entity(row.id);
@@ -87,8 +96,9 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
87
96
  maxBatchSize: 100 // Prevent extremely large batches
88
97
  });
89
98
 
90
- const componentsByEntityType = new DataLoader<{ entityId: string; typeId: string }, ComponentData | null>(
99
+ const componentsByEntityType = new DataLoader<{ entityId: string; typeId: string }, ComponentData | null, string>(
91
100
  async (keys: readonly { entityId: string; typeId: string }[]) => {
101
+ incrementDataLoaderCall('component', perRequest);
92
102
  const startTime = Date.now();
93
103
  try {
94
104
  // Filter out keys with empty/invalid entity IDs to prevent PostgreSQL UUID parsing errors
@@ -99,16 +109,20 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
99
109
 
100
110
  const results = new Map<string, ComponentData | null>();
101
111
 
102
- // Check cache first if cache manager is available
112
+ // Check cache first if cache manager is available. Tombstone hits
113
+ // are recorded as null in `results` so the DB-fetch step skips them.
103
114
  let cacheHits = 0;
104
115
  let cacheMisses = 0;
105
116
  if (cacheManager && cacheManager.getConfig().enabled && cacheManager.getConfig().component?.enabled) {
106
117
  try {
107
118
  const cachedComponents = await cacheManager.getComponents(validKeys);
108
- cachedComponents.forEach((component, index) => {
109
- if (component) {
110
- const key = `${validKeys[index]!.entityId}-${validKeys[index]!.typeId}`;
111
- results.set(key, component);
119
+ cachedComponents.forEach((value, index) => {
120
+ const key = `${validKeys[index]!.entityId}-${validKeys[index]!.typeId}`;
121
+ if (value === COMPONENT_TOMBSTONE) {
122
+ results.set(key, null);
123
+ cacheHits++;
124
+ } else if (value) {
125
+ results.set(key, value);
112
126
  cacheHits++;
113
127
  } else {
114
128
  cacheMisses++;
@@ -122,17 +136,16 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
122
136
  cacheMisses += validKeys.length;
123
137
  }
124
138
 
125
- // Log cache hit/miss rates for monitoring
126
139
  if (validKeys.length > 0) {
127
140
  const hitRate = (cacheHits / validKeys.length) * 100;
128
- logger.debug({
129
- scope: 'cache',
130
- component: 'RequestLoaders',
131
- msg: 'Component cache statistics',
132
- total: validKeys.length,
133
- hits: cacheHits,
134
- misses: cacheMisses,
135
- hitRate: `${hitRate.toFixed(1)}%`
141
+ logger.trace({
142
+ scope: 'cache',
143
+ component: 'RequestLoaders',
144
+ msg: 'Component cache statistics',
145
+ total: validKeys.length,
146
+ hits: cacheHits,
147
+ misses: cacheMisses,
148
+ hitRate: `${hitRate.toFixed(1)}%`,
136
149
  });
137
150
  }
138
151
 
@@ -144,13 +157,13 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
144
157
  const typeIds = [...new Set(missingKeys.map(k => k.typeId))];
145
158
  const entityIdList = inList(entityIds, 1);
146
159
  const typeIdList = inList(typeIds, entityIdList.newParamIndex);
147
- const rows = await db.unsafe(`
160
+ const rows = await timedUnsafe<any[]>(db, `
148
161
  SELECT id, entity_id, type_id, data, created_at, updated_at, deleted_at
149
162
  FROM components
150
163
  WHERE entity_id IN ${entityIdList.sql}
151
164
  AND type_id IN ${typeIdList.sql}
152
165
  AND deleted_at IS NULL
153
- `, [...entityIdList.params, ...typeIdList.params]);
166
+ `, [...entityIdList.params, ...typeIdList.params], signal, perRequest);
154
167
 
155
168
  const components: ComponentData[] = rows.map((row: any) => ({
156
169
  id: row.id,
@@ -162,10 +175,15 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
162
175
  deletedAt: row.deleted_at,
163
176
  }));
164
177
 
165
- // Cache the loaded components if cache is enabled
178
+ // Cache the loaded components + tombstone any requested keys whose
179
+ // row was absent (single setMany — see CacheManager.setComponentsWriteThrough).
166
180
  if (cacheManager && cacheManager.getConfig().enabled && cacheManager.getConfig().component?.enabled) {
167
181
  try {
168
- await cacheManager.setComponentsWriteThrough(components, cacheManager.getConfig().component!.ttl);
182
+ await cacheManager.setComponentsWriteThrough(
183
+ components,
184
+ missingKeys,
185
+ cacheManager.getConfig().component!.ttl,
186
+ );
169
187
  } catch (error: any) {
170
188
  logger.warn({ scope: 'cache', component: 'RequestLoaders', msg: 'Cache write failed for components', error });
171
189
  }
@@ -193,12 +211,17 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
193
211
  }
194
212
  },
195
213
  {
196
- 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}`,
197
219
  }
198
220
  );
199
221
 
200
- 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>(
201
223
  async (keys: readonly { entityId: string; relationField: string; relatedType: string; foreignKey?: string }[]) => {
224
+ incrementDataLoaderCall('relation', perRequest);
202
225
  const startTime = Date.now();
203
226
  try {
204
227
  // Filter valid keys
@@ -207,9 +230,35 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
207
230
  return keys.map(() => []);
208
231
  }
209
232
 
233
+ const resultMap = new Map<string, Entity[]>();
234
+
235
+ // Negative-cache lookup: skip DB for keys recorded as empty.
236
+ let keysToQuery = validKeys;
237
+ const relCacheEnabled = !!(cacheManager
238
+ && cacheManager.getConfig().enabled
239
+ && cacheManager.getConfig().relation?.negativeCacheEnabled);
240
+ if (relCacheEnabled) {
241
+ try {
242
+ const tombstones = await cacheManager!.getRelationsEmpty(validKeys);
243
+ const remaining: typeof validKeys = [];
244
+ tombstones.forEach((isEmpty, i) => {
245
+ const k = validKeys[i]!;
246
+ if (isEmpty) {
247
+ const mapKey = `${k.entityId}\x00${k.relationField}\x00${k.relatedType}`;
248
+ resultMap.set(mapKey, []);
249
+ } else {
250
+ remaining.push(k);
251
+ }
252
+ });
253
+ keysToQuery = remaining;
254
+ } catch (error) {
255
+ logger.warn({ scope: 'cache', component: 'RequestLoaders', msg: 'Cache read failed for relation tombstones', error });
256
+ }
257
+ }
258
+
210
259
  // Group keys by foreign key for efficient batching
211
- const keysByForeignKey = new Map<string, typeof validKeys>();
212
- for (const key of validKeys) {
260
+ const keysByForeignKey = new Map<string, typeof keysToQuery>();
261
+ for (const key of keysToQuery) {
213
262
  const fk = key.foreignKey || 'default';
214
263
  if (!keysByForeignKey.has(fk)) {
215
264
  keysByForeignKey.set(fk, []);
@@ -217,8 +266,6 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
217
266
  keysByForeignKey.get(fk)!.push(key);
218
267
  }
219
268
 
220
- const resultMap = new Map<string, Entity[]>();
221
-
222
269
  // OPTIMIZED: Batch query for each foreign key type (instead of N separate queries)
223
270
  for (const [foreignKey, groupedKeys] of keysByForeignKey) {
224
271
  const entityIds = [...new Set(groupedKeys.map(k => k.entityId))];
@@ -240,19 +287,19 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
240
287
  logger.trace(`[RelationLoader] Batched query for ${groupedKeys.length} keys with foreign key ${foreignKey}`);
241
288
 
242
289
  // SINGLE BATCHED QUERY for all entities in this group
243
- const rows = await db.unsafe(`
244
- SELECT DISTINCT
245
- c.entity_id,
246
- c.data,
290
+ const rows = await timedUnsafe<any[]>(db, `
291
+ SELECT DISTINCT
292
+ c.entity_id,
293
+ c.data,
247
294
  c.type_id,
248
295
  c.data->>'${foreignKeyField}' as fk_value,
249
296
  COALESCE(c.data->>'user_id', c.data->>'parent_id') as fallback_fk_value
250
297
  FROM components c
251
298
  INNER JOIN entities e ON c.entity_id = e.id
252
- WHERE e.deleted_at IS NULL
299
+ WHERE e.deleted_at IS NULL
253
300
  AND c.deleted_at IS NULL
254
301
  AND ${whereClause}
255
- `, [entityIds]);
302
+ `, [entityIds], signal, perRequest);
256
303
 
257
304
  logger.trace(`[RelationLoader] Found ${rows.length} total components for ${entityIds.length} entities`);
258
305
 
@@ -281,6 +328,22 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
281
328
  }
282
329
  }
283
330
 
331
+ // Write tombstones for queried keys whose result was empty.
332
+ if (relCacheEnabled && keysToQuery.length > 0) {
333
+ const emptyKeys = keysToQuery.filter(k => {
334
+ const mapKey = `${k.entityId}\x00${k.relationField}\x00${k.relatedType}`;
335
+ const r = resultMap.get(mapKey);
336
+ return !r || r.length === 0;
337
+ });
338
+ if (emptyKeys.length > 0) {
339
+ try {
340
+ await cacheManager!.setRelationsEmpty(emptyKeys);
341
+ } catch (error) {
342
+ logger.warn({ scope: 'cache', component: 'RequestLoaders', msg: 'Cache write failed for relation tombstones', error });
343
+ }
344
+ }
345
+ }
346
+
284
347
  const duration = Date.now() - startTime;
285
348
  if (duration > 1000) {
286
349
  logger.warn(`Slow relationsByEntityField query: ${duration}ms for ${keys.length} keys`);
@@ -306,9 +369,97 @@ export function createRequestLoaders(db: any, cacheManager?: CacheManager): Requ
306
369
  },
307
370
  {
308
371
  // Add batch size limit to prevent extremely large queries
309
- 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 ?? ''}`,
378
+ }
379
+ );
380
+
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}`,
310
461
  }
311
462
  );
312
463
 
313
- return { entityById, componentsByEntityType, relationsByEntityField };
464
+ return { entityById, componentsByEntityType, relationsByEntityField, relationsByComponentFk };
314
465
  }