bunsane 0.2.10 → 0.3.1

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 (47) hide show
  1. package/CHANGELOG.md +318 -0
  2. package/CLAUDE.md +20 -0
  3. package/config/cache.config.ts +12 -2
  4. package/core/App.ts +300 -69
  5. package/core/ApplicationLifecycle.ts +68 -4
  6. package/core/Entity.ts +525 -256
  7. package/core/EntityHookManager.ts +88 -21
  8. package/core/EntityManager.ts +12 -3
  9. package/core/Logger.ts +4 -0
  10. package/core/RequestContext.ts +4 -1
  11. package/core/SchedulerManager.ts +105 -22
  12. package/core/cache/CacheFactory.ts +3 -1
  13. package/core/cache/CacheManager.ts +72 -17
  14. package/core/cache/RedisCache.ts +38 -3
  15. package/core/components/BaseComponent.ts +12 -2
  16. package/core/decorators/EntityHooks.ts +24 -12
  17. package/core/middleware/RateLimit.ts +105 -0
  18. package/core/middleware/index.ts +1 -0
  19. package/core/remote/OutboxWorker.ts +42 -35
  20. package/core/scheduler/DistributedLock.ts +22 -7
  21. package/database/PreparedStatementCache.ts +5 -13
  22. package/gql/builders/ResolverBuilder.ts +4 -4
  23. package/gql/complexityLimit.ts +95 -0
  24. package/gql/index.ts +15 -3
  25. package/gql/visitors/ResolverGeneratorVisitor.ts +16 -2
  26. package/package.json +1 -1
  27. package/query/ComponentInclusionNode.ts +18 -11
  28. package/query/OrNode.ts +2 -4
  29. package/query/Query.ts +42 -31
  30. package/query/SqlIdentifier.ts +105 -0
  31. package/query/builders/FullTextSearchBuilder.ts +19 -6
  32. package/service/ServiceRegistry.ts +28 -9
  33. package/service/index.ts +4 -2
  34. package/storage/LocalStorageProvider.ts +12 -3
  35. package/storage/S3StorageProvider.ts +6 -6
  36. package/tests/e2e/http.test.ts +6 -2
  37. package/tests/integration/entity/Entity.saveTimeout.test.ts +110 -0
  38. package/tests/unit/cache/CacheManager.test.ts +20 -0
  39. package/tests/unit/entity/Entity.components.test.ts +73 -0
  40. package/tests/unit/entity/Entity.drainSideEffects.test.ts +51 -0
  41. package/tests/unit/entity/Entity.reload.test.ts +63 -0
  42. package/tests/unit/entity/Entity.requireComponents.test.ts +72 -0
  43. package/tests/unit/query/Query.emptyString.test.ts +69 -0
  44. package/tests/unit/query/Query.test.ts +6 -4
  45. package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +95 -0
  46. package/tests/unit/storage/S3StorageProvider.test.ts +6 -10
  47. package/upload/FileValidator.ts +9 -6
@@ -35,12 +35,25 @@ export interface RedisCacheConfig {
35
35
  password?: string;
36
36
  db?: number;
37
37
  keyPrefix?: string;
38
- retryStrategy?: (times: number) => number | void;
38
+ retryStrategy?: (times: number) => number | null | void;
39
39
  maxRetriesPerRequest?: number;
40
40
  lazyConnect?: boolean;
41
41
  enableReadyCheck?: boolean;
42
42
  connectTimeout?: number;
43
43
  commandTimeout?: number;
44
+ /**
45
+ * When true (default false), ioredis queues commands while offline. This
46
+ * can grow unboundedly during a Redis outage and exhaust heap. Keep false
47
+ * so cache operations fail fast and the caller's try/catch treats it as
48
+ * a cache miss instead of a hang.
49
+ */
50
+ enableOfflineQueue?: boolean;
51
+ /**
52
+ * Maximum reconnect attempts before the retry strategy returns null and
53
+ * ioredis gives up. Prevents infinite reconnect storms when Redis is
54
+ * permanently unreachable. Default 20 attempts (~ 40s at 2s cap).
55
+ */
56
+ maxReconnectAttempts?: number;
44
57
  }
45
58
 
46
59
  /**
@@ -65,18 +78,40 @@ export class RedisCache implements CacheProvider {
65
78
  this.config = config;
66
79
  this.keyPrefix = config.keyPrefix || 'bunsane:';
67
80
 
81
+ const maxReconnectAttempts = config.maxReconnectAttempts ?? 20;
82
+ const userRetryStrategy = config.retryStrategy;
83
+
84
+ // Wrap caller's retry strategy (or default) with a hard attempt cap so
85
+ // a permanently unreachable Redis cannot spin forever (C03).
86
+ const retryStrategy = (times: number): number | null => {
87
+ if (times > maxReconnectAttempts) {
88
+ logger.error({ scope: 'cache', provider: 'redis', attempts: times, msg: 'Redis retry cap reached — giving up reconnect attempts' });
89
+ return null;
90
+ }
91
+ if (userRetryStrategy) {
92
+ const result = userRetryStrategy(times);
93
+ if (result === null || result === undefined) return null;
94
+ return result as number;
95
+ }
96
+ return Math.min(times * 200, 2000);
97
+ };
98
+
68
99
  const redisOptions: RedisOptions = {
69
100
  host: config.host,
70
101
  port: config.port,
71
102
  password: config.password,
72
103
  db: config.db || 0,
73
- retryStrategy: config.retryStrategy,
104
+ retryStrategy,
74
105
  maxRetriesPerRequest: config.maxRetriesPerRequest || 3,
75
106
  lazyConnect: config.lazyConnect || false,
76
107
  enableReadyCheck: config.enableReadyCheck || false,
77
108
  connectTimeout: config.connectTimeout ?? 5000,
78
109
  commandTimeout: config.commandTimeout ?? 3000,
79
- enableOfflineQueue: true,
110
+ // Fail-fast when Redis is down. Unbounded offline queue → heap
111
+ // exhaustion under sustained load during outage (C02). Callers
112
+ // already wrap cache ops in try/catch and treat failures as
113
+ // cache miss; bounded failure is better than buildup.
114
+ enableOfflineQueue: config.enableOfflineQueue ?? false,
80
115
  };
81
116
 
82
117
  this.client = new Redis(redisOptions);
@@ -55,8 +55,18 @@ export class BaseComponent {
55
55
  this.properties().forEach((prop: string) => {
56
56
  let value = (this as any)[prop];
57
57
  const propMeta = props?.find(p => p.propertyKey === prop);
58
- if (propMeta?.propertyType === Date && value instanceof Date) {
59
- value = value.toISOString();
58
+ if (value !== null && value !== undefined) {
59
+ if (propMeta?.propertyType === Date) {
60
+ if (!(value instanceof Date)) {
61
+ throw new Error(`Type mismatch for property '${prop}' on component '${this._comp_name}': expected Date, got ${typeof value}`);
62
+ }
63
+ if (Number.isNaN(value.getTime())) {
64
+ throw new Error(`Invalid Date for property '${prop}' on component '${this._comp_name}'`);
65
+ }
66
+ value = value.toISOString();
67
+ } else if (propMeta?.propertyType === Number && typeof value === 'number' && !Number.isFinite(value)) {
68
+ throw new Error(`Invalid number for property '${prop}' on component '${this._comp_name}': ${value}`);
69
+ }
60
70
  }
61
71
  data[prop] = value;
62
72
  });
@@ -114,6 +114,10 @@ export function ComponentTargetHook(
114
114
  };
115
115
  }
116
116
 
117
+ /** Per-instance registry of hook IDs created by registerDecoratedHooks.
118
+ * Used by unregisterDecoratedHooks to undo registration (H-HOOK-3). */
119
+ const REGISTERED_IDS = new WeakMap<object, string[]>();
120
+
117
121
  /**
118
122
  * Register all decorated hooks for a service class
119
123
  * Call this method after instantiating a service to register its decorated hooks
@@ -121,17 +125,18 @@ export function ComponentTargetHook(
121
125
  */
122
126
  export function registerDecoratedHooks(serviceInstance: any): void {
123
127
  const constructor = serviceInstance.constructor;
128
+ const ids: string[] = REGISTERED_IDS.get(serviceInstance) ?? [];
124
129
 
125
130
  // Register entity hooks
126
131
  if (constructor.__entityHooks) {
127
132
  for (const hookInfo of constructor.__entityHooks) {
128
133
  const hookMethod = serviceInstance[hookInfo.methodName].bind(serviceInstance);
129
134
 
130
- EntityHookManager.registerEntityHook(
135
+ ids.push(EntityHookManager.registerEntityHook(
131
136
  hookInfo.eventType,
132
137
  hookMethod,
133
138
  hookInfo.options
134
- );
139
+ ));
135
140
  }
136
141
  }
137
142
 
@@ -140,11 +145,11 @@ export function registerDecoratedHooks(serviceInstance: any): void {
140
145
  for (const hookInfo of constructor.__componentHooks) {
141
146
  const hookMethod = serviceInstance[hookInfo.methodName].bind(serviceInstance);
142
147
 
143
- EntityHookManager.registerComponentHook(
148
+ ids.push(EntityHookManager.registerComponentHook(
144
149
  hookInfo.eventType,
145
150
  hookMethod,
146
151
  hookInfo.options
147
- );
152
+ ));
148
153
  }
149
154
  }
150
155
 
@@ -153,14 +158,14 @@ export function registerDecoratedHooks(serviceInstance: any): void {
153
158
  for (const hookInfo of constructor.__componentTargetHooks) {
154
159
  const hookMethod = serviceInstance[hookInfo.methodName].bind(serviceInstance);
155
160
 
156
- EntityHookManager.registerEntityHook(
161
+ ids.push(EntityHookManager.registerEntityHook(
157
162
  hookInfo.eventType,
158
163
  hookMethod,
159
164
  {
160
165
  ...hookInfo.options,
161
166
  componentTarget: hookInfo.componentTarget
162
167
  }
163
- );
168
+ ));
164
169
  }
165
170
  }
166
171
 
@@ -169,19 +174,26 @@ export function registerDecoratedHooks(serviceInstance: any): void {
169
174
  for (const hookInfo of constructor.__lifecycleHooks) {
170
175
  const hookMethod = serviceInstance[hookInfo.methodName].bind(serviceInstance);
171
176
 
172
- EntityHookManager.registerLifecycleHook(
177
+ ids.push(EntityHookManager.registerLifecycleHook(
173
178
  hookMethod,
174
179
  hookInfo.options
175
- );
180
+ ));
176
181
  }
177
182
  }
183
+
184
+ REGISTERED_IDS.set(serviceInstance, ids);
178
185
  }
179
186
 
180
187
  /**
181
- * Unregister all decorated hooks for a service class
182
- * Call this method before destroying a service to clean up its hooks
183
- * @param serviceInstance The service instance to unregister hooks for
188
+ * Unregister all decorated hooks for a service instance.
189
+ * Call during teardown (service destruction, test isolation) to prevent
190
+ * hook leaks across repeated instantiations (H-HOOK-3).
184
191
  */
185
192
  export function unregisterDecoratedHooks(serviceInstance: any): void {
186
- console.warn('unregisterDecoratedHooks is not fully implemented. Use EntityHookManager.removeHook() for individual hook removal.');
193
+ const ids = REGISTERED_IDS.get(serviceInstance);
194
+ if (!ids) return;
195
+ for (const id of ids) {
196
+ EntityHookManager.removeHook(id);
197
+ }
198
+ REGISTERED_IDS.delete(serviceInstance);
187
199
  }
@@ -0,0 +1,105 @@
1
+ import type { Middleware } from '../Middleware';
2
+ import { logger as MainLogger } from '../Logger';
3
+
4
+ const logger = MainLogger.child({ scope: 'RateLimit' });
5
+
6
+ export type RateLimitOptions = {
7
+ /** Maximum requests in the window. Default: 100 */
8
+ max?: number;
9
+ /** Window length in milliseconds. Default: 60_000 (1 min) */
10
+ windowMs?: number;
11
+ /** Only apply to paths matching this prefix list. Default: all */
12
+ pathPrefixes?: string[];
13
+ /** Extract client key (override default: X-Forwarded-For → remote). */
14
+ keyExtractor?: (req: Request) => string;
15
+ /** Response status for rejection. Default: 429 */
16
+ status?: number;
17
+ /** Trust X-Forwarded-For header. Default: false */
18
+ trustProxy?: boolean;
19
+ };
20
+
21
+ type Bucket = {
22
+ count: number;
23
+ resetAt: number;
24
+ };
25
+
26
+ /**
27
+ * In-memory token-bucket rate limiter. Per-instance only — for multi-instance
28
+ * deployments use a shared Redis-backed limiter. Sweeps expired buckets on
29
+ * each increment to keep memory bounded.
30
+ */
31
+ export function rateLimit(options: RateLimitOptions = {}): Middleware {
32
+ const max = options.max ?? 100;
33
+ const windowMs = options.windowMs ?? 60_000;
34
+ const pathPrefixes = options.pathPrefixes;
35
+ const status = options.status ?? 429;
36
+ const trustProxy = options.trustProxy ?? false;
37
+ const keyExtractor = options.keyExtractor ?? ((req: Request) => {
38
+ if (trustProxy) {
39
+ const xff = req.headers.get('x-forwarded-for');
40
+ if (xff) return xff.split(',')[0]!.trim();
41
+ }
42
+ const realIp = req.headers.get('x-real-ip');
43
+ if (realIp) return realIp;
44
+ return 'anonymous';
45
+ });
46
+
47
+ const buckets = new Map<string, Bucket>();
48
+ let lastSweep = Date.now();
49
+
50
+ return async (req, next) => {
51
+ if (pathPrefixes && pathPrefixes.length > 0) {
52
+ const url = new URL(req.url);
53
+ const match = pathPrefixes.some((p) => url.pathname.startsWith(p));
54
+ if (!match) return next();
55
+ }
56
+
57
+ const now = Date.now();
58
+ const key = keyExtractor(req);
59
+
60
+ if (now - lastSweep > windowMs) {
61
+ for (const [k, v] of buckets) {
62
+ if (v.resetAt <= now) buckets.delete(k);
63
+ }
64
+ lastSweep = now;
65
+ }
66
+
67
+ let bucket = buckets.get(key);
68
+ if (!bucket || bucket.resetAt <= now) {
69
+ bucket = { count: 0, resetAt: now + windowMs };
70
+ buckets.set(key, bucket);
71
+ }
72
+
73
+ bucket.count++;
74
+ const remaining = Math.max(0, max - bucket.count);
75
+ const retryAfterSec = Math.ceil((bucket.resetAt - now) / 1000);
76
+
77
+ if (bucket.count > max) {
78
+ logger.warn({ key, path: new URL(req.url).pathname, count: bucket.count, max }, 'rate limit exceeded');
79
+ return new Response(
80
+ JSON.stringify({ error: 'Too many requests', retryAfter: retryAfterSec }),
81
+ {
82
+ status,
83
+ headers: {
84
+ 'Content-Type': 'application/json',
85
+ 'Retry-After': String(retryAfterSec),
86
+ 'X-RateLimit-Limit': String(max),
87
+ 'X-RateLimit-Remaining': '0',
88
+ 'X-RateLimit-Reset': String(Math.floor(bucket.resetAt / 1000)),
89
+ },
90
+ },
91
+ );
92
+ }
93
+
94
+ const response = await next();
95
+ const newHeaders = new Headers(response.headers);
96
+ newHeaders.set('X-RateLimit-Limit', String(max));
97
+ newHeaders.set('X-RateLimit-Remaining', String(remaining));
98
+ newHeaders.set('X-RateLimit-Reset', String(Math.floor(bucket.resetAt / 1000)));
99
+ return new Response(response.body, {
100
+ status: response.status,
101
+ statusText: response.statusText,
102
+ headers: newHeaders,
103
+ });
104
+ };
105
+ }
@@ -1,3 +1,4 @@
1
1
  export { securityHeaders, type SecurityHeadersOptions } from './SecurityHeaders';
2
2
  export { requestId, getRequestId, requestStore } from './RequestId';
3
3
  export { accessLog, type AccessLogOptions } from './AccessLog';
4
+ export { rateLimit, type RateLimitOptions } from './RateLimit';
@@ -13,7 +13,7 @@
13
13
  */
14
14
 
15
15
  import type Redis from "ioredis";
16
- import type { SQL } from "bun";
16
+ import { sql as sqlHelper, type SQL } from "bun";
17
17
  import { logger } from "../Logger";
18
18
  import type { RemoteMetrics } from "./metrics";
19
19
 
@@ -126,49 +126,56 @@ export class OutboxWorker {
126
126
  loggerInstance.debug(`Claimed ${rows.length} outbox rows`);
127
127
  }
128
128
 
129
- const successIds: string[] = [];
129
+ // Publish concurrently rather than serially. Each xadd is bounded
130
+ // by the publisher client's `commandTimeout`; with serial awaits a
131
+ // batch of N slow rows would hold PG row locks for N × timeout.
132
+ // Parallel keeps worst-case lock hold ≈ single-xadd timeout.
133
+ // (H-DB-1 partial — full fix requires a claim-via-column design
134
+ // so Redis latency no longer sits inside a PG transaction at all.)
135
+ const publishResults = await Promise.allSettled(
136
+ rows.map((row) => {
137
+ const stream = `${this.config.streamPrefix}${row.target}`;
138
+ const envelope = JSON.stringify({
139
+ kind: "event",
140
+ sourceApp: this.config.sourceApp,
141
+ event: row.event,
142
+ data: row.data,
143
+ emittedAt: row.created_at.getTime(),
144
+ });
145
+ return this.publisher.xadd(stream, "*", "data", envelope);
146
+ })
147
+ );
130
148
 
131
- for (const row of rows) {
132
- const stream = `${this.config.streamPrefix}${row.target}`;
133
- const envelope = JSON.stringify({
134
- kind: "event",
135
- sourceApp: this.config.sourceApp,
136
- event: row.event,
137
- data: row.data,
138
- emittedAt: row.created_at.getTime(),
139
- });
140
- try {
141
- await this.publisher.xadd(
142
- stream,
143
- "*",
144
- "data",
145
- envelope
146
- );
149
+ const successIds: string[] = [];
150
+ for (let i = 0; i < publishResults.length; i++) {
151
+ const r = publishResults[i];
152
+ const row = rows[i]!;
153
+ if (r!.status === "fulfilled") {
147
154
  successIds.push(row.id);
148
- } catch (err: any) {
155
+ } else {
149
156
  this.metrics?.outboxPublishFailed();
150
- loggerInstance.error(
151
- {
152
- err,
153
- outboxId: row.id,
154
- target: row.target,
155
- event: row.event,
156
- msg: "Outbox XADD failed — row will retry next tick",
157
- }
158
- );
157
+ loggerInstance.error({
158
+ err: r!.reason,
159
+ outboxId: row.id,
160
+ target: row.target,
161
+ event: row.event,
162
+ msg: "Outbox XADD failed — row will retry next tick",
163
+ });
159
164
  // Leave row unpublished; SKIP LOCKED releases on tx end
160
165
  // so next tick (or another instance) picks it up.
161
166
  }
162
167
  }
163
168
 
164
169
  if (successIds.length > 0) {
165
- for (const id of successIds) {
166
- await trx`
167
- UPDATE remote_outbox
168
- SET published_at = NOW()
169
- WHERE id = ${id}::uuid
170
- `;
171
- }
170
+ // Single bulk UPDATE instead of N round-trips holding row
171
+ // locks (H-DB-3). Previously each success fired its own
172
+ // UPDATE statement serially. Uses Bun SQL's `sql(...)` helper
173
+ // for the IN-list so ids are parameterised individually.
174
+ await trx`
175
+ UPDATE remote_outbox
176
+ SET published_at = NOW()
177
+ WHERE id IN ${sqlHelper(successIds)}
178
+ `;
172
179
  this.metrics?.outboxPublished(successIds.length);
173
180
  }
174
181
  });
@@ -83,11 +83,20 @@ export class DistributedLock {
83
83
  private async ensureReserved(): Promise<ReservedSQL> {
84
84
  if (this.reservedConn) return this.reservedConn;
85
85
  if (!this.reservePromise) {
86
- this.reservePromise = db.reserve().then((conn) => {
87
- this.reservedConn = conn;
88
- this.reservePromise = null;
89
- return conn;
90
- });
86
+ // On reject (pool exhausted, shutdown mid-reserve), null the
87
+ // promise so subsequent callers retry a fresh reserve instead of
88
+ // receiving the same rejected promise forever (H-DB-2).
89
+ this.reservePromise = db.reserve().then(
90
+ (conn) => {
91
+ this.reservedConn = conn;
92
+ this.reservePromise = null;
93
+ return conn;
94
+ },
95
+ (err) => {
96
+ this.reservePromise = null;
97
+ throw err;
98
+ }
99
+ );
91
100
  }
92
101
  return this.reservePromise;
93
102
  }
@@ -122,12 +131,18 @@ export class DistributedLock {
122
131
  const lockKey = this.generateLockKey(taskId);
123
132
 
124
133
  if (this.heldLocks.has(taskId)) {
134
+ // Defense in depth: if this instance already holds the lock for
135
+ // taskId, a second concurrent acquirer would mean overlapping
136
+ // execution (retry firing while previous run is still in the
137
+ // finally → release step, for example). Return acquired:false so
138
+ // the second caller skips, even if caller-side guards missed it.
139
+ // (H-SCHED-4).
125
140
  if (this.config.enableLogging) {
126
141
  loggerInstance.debug(
127
- `Lock for ${taskId} already held locally, returning acquired`
142
+ `Lock for ${taskId} already held locally reporting overlap (acquired:false)`
128
143
  );
129
144
  }
130
- return { acquired: true, lockKey, taskId };
145
+ return { acquired: false, lockKey, taskId };
131
146
  }
132
147
 
133
148
  const startTime = Date.now();
@@ -111,19 +111,11 @@ export class PreparedStatementCache {
111
111
  * Execute a prepared statement with parameters
112
112
  */
113
113
  public async execute(statement: any, params: any[], db: any): Promise<any[]> {
114
- // Validate params to catch empty strings that would cause UUID parsing errors
115
- for (let i = 0; i < params.length; i++) {
116
- const param = params[i];
117
- if (param === '' || (typeof param === 'string' && param.trim() === '')) {
118
- logger.error(`[PreparedStatementCache] Empty string parameter at position ${i + 1}`);
119
- logger.error(`[PreparedStatementCache] SQL: ${statement.sql}`);
120
- logger.error(`[PreparedStatementCache] All params: ${JSON.stringify(params)}`);
121
- throw new Error(`PreparedStatementCache.execute: Parameter $${i + 1} is an empty string. SQL: ${statement.sql.substring(0, 100)}...`);
122
- }
123
- }
124
-
125
- // For Bun's SQL, we still use db.unsafe() but with the prepared statement concept
126
- // In a real implementation, this might use a prepared statement pool
114
+ // Empty-string params are legitimate for text-field filters
115
+ // (`c.data->>'field' = ''`). UUID-typed params never reach this
116
+ // point empty — callers (Query.findById etc.) guard at entry. PG
117
+ // emits a clear error at execution time if a UUID cast meets an
118
+ // empty string.
127
119
  return await db.unsafe(statement.sql, params);
128
120
  }
129
121
 
@@ -87,7 +87,7 @@ export class ResolverBuilder {
87
87
  throw new GraphQLError(`Internal error`, {
88
88
  extensions: {
89
89
  code: "INTERNAL_ERROR",
90
- originalError: process.env.NODE_ENV === 'development' ? error : undefined
90
+ originalError: process.env.NODE_ENV !== 'production' ? error : undefined
91
91
  }
92
92
  });
93
93
  }
@@ -111,7 +111,7 @@ export class ResolverBuilder {
111
111
  throw new GraphQLError(`Internal error`, {
112
112
  extensions: {
113
113
  code: "INTERNAL_ERROR",
114
- originalError: process.env.NODE_ENV === 'development' ? error : undefined
114
+ originalError: process.env.NODE_ENV !== 'production' ? error : undefined
115
115
  }
116
116
  });
117
117
  }
@@ -151,7 +151,7 @@ export class ResolverBuilder {
151
151
  throw new GraphQLError(`Internal error in subscription`, {
152
152
  extensions: {
153
153
  code: "INTERNAL_ERROR",
154
- originalError: process.env.NODE_ENV === 'development' ? error : undefined
154
+ originalError: process.env.NODE_ENV !== 'production' ? error : undefined
155
155
  }
156
156
  });
157
157
  }
@@ -177,7 +177,7 @@ export class ResolverBuilder {
177
177
  throw new GraphQLError(`Internal error in subscription`, {
178
178
  extensions: {
179
179
  code: "INTERNAL_ERROR",
180
- originalError: process.env.NODE_ENV === 'development' ? error : undefined
180
+ originalError: process.env.NODE_ENV !== 'production' ? error : undefined
181
181
  }
182
182
  });
183
183
  }
@@ -0,0 +1,95 @@
1
+ import {
2
+ type ASTVisitor,
3
+ type DocumentNode,
4
+ type FragmentDefinitionNode,
5
+ type ValidationContext,
6
+ GraphQLError,
7
+ Kind,
8
+ } from "graphql";
9
+
10
+ /**
11
+ * Lightweight GraphQL query complexity validator. Assigns each selected field
12
+ * a base cost of 1, multiplied by the value of a `first` / `limit` / `take`
13
+ * argument when present. Nested selections contribute their (multiplied) cost.
14
+ * Fragments are followed and deduped. Introspection queries are exempt.
15
+ *
16
+ * Not as expressive as `graphql-query-complexity` (no per-field estimators,
17
+ * no custom weights). Enough to block obviously abusive nested archetype
18
+ * relations without a heavyweight dep.
19
+ */
20
+ export function complexityLimitRule(maxComplexity: number) {
21
+ return function ComplexityLimitValidationRule(
22
+ context: ValidationContext,
23
+ ): ASTVisitor {
24
+ const document: DocumentNode = context.getDocument();
25
+
26
+ const fragments = new Map<string, FragmentDefinitionNode>();
27
+ for (const def of document.definitions) {
28
+ if (def.kind === Kind.FRAGMENT_DEFINITION) {
29
+ fragments.set(def.name.value, def);
30
+ }
31
+ }
32
+
33
+ const MULTIPLIER_ARGS = new Set(["first", "limit", "take"]);
34
+
35
+ function readMultiplier(args: readonly any[] | undefined): number {
36
+ if (!args) return 1;
37
+ for (const arg of args) {
38
+ if (!MULTIPLIER_ARGS.has(arg.name.value)) continue;
39
+ const v = arg.value;
40
+ if (v.kind === Kind.INT) {
41
+ const n = parseInt(v.value, 10);
42
+ if (Number.isFinite(n) && n > 0) return n;
43
+ }
44
+ }
45
+ return 1;
46
+ }
47
+
48
+ function cost(
49
+ node: { selectionSet?: { selections: readonly any[] } },
50
+ visited: Set<string>,
51
+ ): number {
52
+ if (!node.selectionSet) return 0;
53
+
54
+ let total = 0;
55
+ for (const selection of node.selectionSet.selections) {
56
+ if (selection.kind === Kind.FIELD) {
57
+ const multiplier = readMultiplier(selection.arguments);
58
+ const childCost = cost(selection, visited);
59
+ total += multiplier * (1 + childCost);
60
+ } else if (selection.kind === Kind.INLINE_FRAGMENT) {
61
+ total += cost(selection, visited);
62
+ } else if (selection.kind === Kind.FRAGMENT_SPREAD) {
63
+ const name = selection.name.value;
64
+ if (visited.has(name)) continue;
65
+ visited.add(name);
66
+ const fragment = fragments.get(name);
67
+ if (fragment) total += cost(fragment, visited);
68
+ }
69
+ }
70
+ return total;
71
+ }
72
+
73
+ return {
74
+ OperationDefinition(node) {
75
+ if (node.selectionSet) {
76
+ const isIntrospection = node.selectionSet.selections.every(
77
+ (sel) =>
78
+ sel.kind === Kind.FIELD &&
79
+ sel.name.value.startsWith("__"),
80
+ );
81
+ if (isIntrospection) return;
82
+ }
83
+
84
+ const total = cost(node, new Set());
85
+ if (total > maxComplexity) {
86
+ context.reportError(
87
+ new GraphQLError(
88
+ `Query complexity ${total} exceeds maximum allowed complexity of ${maxComplexity}`,
89
+ ),
90
+ );
91
+ }
92
+ },
93
+ };
94
+ };
95
+ }
package/gql/index.ts CHANGED
@@ -2,6 +2,7 @@ import {createSchema, createYoga, type Plugin} from 'graphql-yoga';
2
2
  import { useValidationRule } from '@envelop/core';
3
3
  import { GraphQLSchema, GraphQLError } from 'graphql';
4
4
  import { depthLimitRule } from './depthLimit';
5
+ import { complexityLimitRule } from './complexityLimit';
5
6
  import { GraphQLObjectType, GraphQLField, GraphQLOperation, GraphQLScalarType, GraphQLSubscription } from './Generator';
6
7
  import {GraphQLFieldTypes} from "./types"
7
8
  import {logger as MainLogger} from "../core/Logger"
@@ -144,6 +145,8 @@ export interface YogaInstanceOptions {
144
145
  methods?: string[];
145
146
  };
146
147
  maxDepth?: number;
148
+ /** Maximum query complexity (default: 1000). 0 disables. */
149
+ maxComplexity?: number;
147
150
  }
148
151
 
149
152
  export function createYogaInstance(
@@ -152,10 +155,19 @@ export function createYogaInstance(
152
155
  contextFactory?: (context: any) => any,
153
156
  options?: YogaInstanceOptions
154
157
  ) {
155
- // Prepend depth limit plugin if configured
158
+ // Prepend depth limit plugin. Enforce a hard minimum so maxDepth: 0 or
159
+ // undefined cannot silently disable the guard (C06). If a deployment
160
+ // explicitly needs a higher bound, raise it — but we never allow it off.
161
+ const HARD_MIN_DEPTH = 15;
162
+ const effectiveDepth = Math.max(options?.maxDepth ?? HARD_MIN_DEPTH, HARD_MIN_DEPTH);
156
163
  const allPlugins: Plugin[] = [];
157
- if (options?.maxDepth) {
158
- allPlugins.push(useValidationRule(depthLimitRule(options.maxDepth)) as Plugin);
164
+ allPlugins.push(useValidationRule(depthLimitRule(effectiveDepth)) as Plugin);
165
+
166
+ // Complexity budget: count per-field cost with `first`/`limit`/`take`
167
+ // multipliers. 0 disables, undefined defaults to 1000.
168
+ const complexityBudget = options?.maxComplexity ?? 1000;
169
+ if (complexityBudget > 0) {
170
+ allPlugins.push(useValidationRule(complexityLimitRule(complexityBudget)) as Plugin);
159
171
  }
160
172
  allPlugins.push(...plugins);
161
173