bunsane 0.2.10 → 0.3.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.
- package/CHANGELOG.md +266 -0
- package/config/cache.config.ts +12 -2
- package/core/App.ts +296 -69
- package/core/ApplicationLifecycle.ts +68 -4
- package/core/Entity.ts +407 -256
- package/core/EntityHookManager.ts +88 -21
- package/core/EntityManager.ts +12 -3
- package/core/Logger.ts +4 -0
- package/core/RequestContext.ts +4 -1
- package/core/SchedulerManager.ts +92 -9
- package/core/cache/CacheFactory.ts +3 -1
- package/core/cache/CacheManager.ts +54 -17
- package/core/cache/RedisCache.ts +38 -3
- package/core/decorators/EntityHooks.ts +24 -12
- package/core/middleware/RateLimit.ts +105 -0
- package/core/middleware/index.ts +1 -0
- package/core/remote/OutboxWorker.ts +42 -35
- package/core/scheduler/DistributedLock.ts +22 -7
- package/gql/builders/ResolverBuilder.ts +4 -4
- package/gql/complexityLimit.ts +95 -0
- package/gql/index.ts +15 -3
- package/gql/visitors/ResolverGeneratorVisitor.ts +16 -2
- package/package.json +1 -1
- package/query/ComponentInclusionNode.ts +13 -6
- package/query/OrNode.ts +2 -4
- package/query/Query.ts +30 -3
- package/query/SqlIdentifier.ts +105 -0
- package/query/builders/FullTextSearchBuilder.ts +19 -6
- package/service/ServiceRegistry.ts +21 -8
- package/storage/LocalStorageProvider.ts +12 -3
- package/storage/S3StorageProvider.ts +6 -6
- package/tests/e2e/http.test.ts +6 -2
- package/tests/integration/entity/Entity.saveTimeout.test.ts +110 -0
- package/tests/unit/storage/S3StorageProvider.test.ts +6 -10
- package/upload/FileValidator.ts +9 -6
|
@@ -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
|
|
182
|
-
* Call
|
|
183
|
-
*
|
|
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
|
-
|
|
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
|
+
}
|
package/core/middleware/index.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
132
|
-
|
|
133
|
-
const
|
|
134
|
-
|
|
135
|
-
|
|
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
|
-
}
|
|
155
|
+
} else {
|
|
149
156
|
this.metrics?.outboxPublishFailed();
|
|
150
|
-
loggerInstance.error(
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
|
142
|
+
`Lock for ${taskId} already held locally — reporting overlap (acquired:false)`
|
|
128
143
|
);
|
|
129
144
|
}
|
|
130
|
-
return { acquired:
|
|
145
|
+
return { acquired: false, lockKey, taskId };
|
|
131
146
|
}
|
|
132
147
|
|
|
133
148
|
const startTime = Date.now();
|
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
158
|
-
|
|
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
|
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { GraphVisitor } from "./GraphVisitor";
|
|
2
2
|
import { TypeNode, OperationNode, FieldNode, InputNode, ScalarNode } from "../graph/GraphNode";
|
|
3
3
|
import { ResolverBuilder } from "../builders/ResolverBuilder";
|
|
4
|
+
import { isSchemaInput } from "../schema";
|
|
5
|
+
import * as z from "zod";
|
|
4
6
|
import { logger } from "../../core/Logger";
|
|
5
7
|
|
|
6
8
|
/**
|
|
@@ -54,9 +56,21 @@ export class ResolverGeneratorVisitor extends GraphVisitor {
|
|
|
54
56
|
|
|
55
57
|
const type = node.operationType.charAt(0).toUpperCase() + node.operationType.slice(1).toLowerCase() as "Query" | "Mutation" | "Subscription";
|
|
56
58
|
|
|
57
|
-
// Extract Zod schema from input
|
|
59
|
+
// Extract Zod schema from input. If it's already a Zod type (`_def`),
|
|
60
|
+
// use directly. If it's a Schema DSL input (`t.` API), convert each
|
|
61
|
+
// field `.toZod()` into a Zod object so the resolver validates it
|
|
62
|
+
// instead of passing raw args through (H-GQL-4).
|
|
58
63
|
const input = node.metadata.input;
|
|
59
|
-
|
|
64
|
+
let zodSchema: any = null;
|
|
65
|
+
if (input && typeof input === 'object' && '_def' in input) {
|
|
66
|
+
zodSchema = input;
|
|
67
|
+
} else if (isSchemaInput(input)) {
|
|
68
|
+
const zodShape: Record<string, z.ZodType> = {};
|
|
69
|
+
for (const [key, field] of Object.entries(input)) {
|
|
70
|
+
zodShape[key] = (field as any).toZod();
|
|
71
|
+
}
|
|
72
|
+
zodSchema = z.object(zodShape);
|
|
73
|
+
}
|
|
60
74
|
|
|
61
75
|
// Create resolver definitions for operations
|
|
62
76
|
const resolverDef = {
|
package/package.json
CHANGED
|
@@ -5,6 +5,7 @@ import { shouldUseLateralJoins, shouldUseDirectPartition } from "../core/Config"
|
|
|
5
5
|
import { FilterBuilderRegistry } from "./FilterBuilderRegistry";
|
|
6
6
|
import { ComponentRegistry } from "../core/components";
|
|
7
7
|
import { getMetadataStorage } from "../core/metadata";
|
|
8
|
+
import { assertIdentifier } from "./SqlIdentifier";
|
|
8
9
|
|
|
9
10
|
/**
|
|
10
11
|
* Check if a component property is numeric based on metadata
|
|
@@ -331,12 +332,16 @@ export class ComponentInclusionNode extends QueryNode {
|
|
|
331
332
|
const sortComponentTableName = this.getComponentTableName(typeId);
|
|
332
333
|
const nullsClause = sortOrder.nullsFirst ? 'NULLS FIRST' : 'NULLS LAST';
|
|
333
334
|
const isNumeric = isNumericProperty(sortOrder.component, sortOrder.property);
|
|
335
|
+
// Validate property name before interpolating into JSON path.
|
|
336
|
+
// Without this, a malicious or malformed sortOrder.property could
|
|
337
|
+
// inject SQL through the template (C08).
|
|
338
|
+
const safeProperty = assertIdentifier(sortOrder.property, 'sortOrder.property');
|
|
334
339
|
|
|
335
340
|
// Build scalar subquery to get sort value for each entity
|
|
336
341
|
// This avoids nested loop join by forcing row-by-row evaluation
|
|
337
342
|
const sortExpr = isNumeric
|
|
338
|
-
? `(sort_c.data->>'${
|
|
339
|
-
: `sort_c.data->>'${
|
|
343
|
+
? `(sort_c.data->>'${safeProperty}')::numeric`
|
|
344
|
+
: `sort_c.data->>'${safeProperty}'`;
|
|
340
345
|
|
|
341
346
|
const subquery = `(
|
|
342
347
|
SELECT ${sortExpr}
|
|
@@ -454,9 +459,10 @@ export class ComponentInclusionNode extends QueryNode {
|
|
|
454
459
|
|
|
455
460
|
const nullsClause = sortOrder.nullsFirst ? 'NULLS FIRST' : 'NULLS LAST';
|
|
456
461
|
const isNumeric = isNumericProperty(sortOrder.component, sortOrder.property);
|
|
462
|
+
const safeProperty = assertIdentifier(sortOrder.property, 'sortOrder.property');
|
|
457
463
|
const sortExpr = isNumeric
|
|
458
|
-
? `(c.data->>'${
|
|
459
|
-
: `c.data->>'${
|
|
464
|
+
? `(c.data->>'${safeProperty}')::numeric`
|
|
465
|
+
: `c.data->>'${safeProperty}'`;
|
|
460
466
|
|
|
461
467
|
let sql: string;
|
|
462
468
|
if (useDirectPartition) {
|
|
@@ -508,9 +514,10 @@ export class ComponentInclusionNode extends QueryNode {
|
|
|
508
514
|
|
|
509
515
|
const nullsClause = sortOrder.nullsFirst ? 'NULLS FIRST' : 'NULLS LAST';
|
|
510
516
|
const isNumeric = isNumericProperty(sortOrder.component, sortOrder.property);
|
|
517
|
+
const safeProperty = assertIdentifier(sortOrder.property, 'sortOrder.property');
|
|
511
518
|
const sortExpr = isNumeric
|
|
512
|
-
? `(sort_c.data->>'${
|
|
513
|
-
: `sort_c.data->>'${
|
|
519
|
+
? `(sort_c.data->>'${safeProperty}')::numeric`
|
|
520
|
+
: `sort_c.data->>'${safeProperty}'`;
|
|
514
521
|
|
|
515
522
|
// Use scalar subquery to avoid cartesian product explosion
|
|
516
523
|
// This forces PostgreSQL to evaluate sort value per-entity, preventing
|
package/query/OrNode.ts
CHANGED
|
@@ -305,15 +305,13 @@ export class OrNode extends QueryNode {
|
|
|
305
305
|
public execute(context: QueryContext): QueryResult {
|
|
306
306
|
// Try optimized UNION ALL path for direct partition access
|
|
307
307
|
// This avoids the slow multi-partition scanning by querying each partition directly
|
|
308
|
+
// (Verbose console.log debug traces removed — H-QUERY-2. Re-enable via
|
|
309
|
+
// a framework logger at debug level if needed.)
|
|
308
310
|
const canUseOptimized = this.canUseUnionAllOptimization() && this.dependencies.length === 0;
|
|
309
|
-
console.log(`OrNode: Using optimized path: ${canUseOptimized}, dependencies: ${this.dependencies.length}, direct partition: ${require("../core/Config").shouldUseDirectPartition()}`);
|
|
310
|
-
console.log(`OrNode: Component types:`, Array.from(this.orQuery.getComponentTypes()));
|
|
311
311
|
|
|
312
312
|
if (canUseOptimized) {
|
|
313
|
-
console.log("OrNode: Using optimized UNION path");
|
|
314
313
|
return this.executeUnionAllOptimized(context);
|
|
315
314
|
}
|
|
316
|
-
console.log("OrNode: Using fallback path");
|
|
317
315
|
|
|
318
316
|
// Fall back to original implementation for:
|
|
319
317
|
// - HASH partitioning (no direct partition access)
|