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.
- package/CHANGELOG.md +318 -0
- package/CLAUDE.md +20 -0
- package/config/cache.config.ts +12 -2
- package/core/App.ts +300 -69
- package/core/ApplicationLifecycle.ts +68 -4
- package/core/Entity.ts +525 -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 +105 -22
- package/core/cache/CacheFactory.ts +3 -1
- package/core/cache/CacheManager.ts +72 -17
- package/core/cache/RedisCache.ts +38 -3
- package/core/components/BaseComponent.ts +12 -2
- 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/database/PreparedStatementCache.ts +5 -13
- 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 +18 -11
- package/query/OrNode.ts +2 -4
- package/query/Query.ts +42 -31
- package/query/SqlIdentifier.ts +105 -0
- package/query/builders/FullTextSearchBuilder.ts +19 -6
- package/service/ServiceRegistry.ts +28 -9
- package/service/index.ts +4 -2
- 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/cache/CacheManager.test.ts +20 -0
- package/tests/unit/entity/Entity.components.test.ts +73 -0
- package/tests/unit/entity/Entity.drainSideEffects.test.ts +51 -0
- package/tests/unit/entity/Entity.reload.test.ts +63 -0
- package/tests/unit/entity/Entity.requireComponents.test.ts +72 -0
- package/tests/unit/query/Query.emptyString.test.ts +69 -0
- package/tests/unit/query/Query.test.ts +6 -4
- package/tests/unit/scheduler/SchedulerManager.timeBased.test.ts +95 -0
- package/tests/unit/storage/S3StorageProvider.test.ts +6 -10
- package/upload/FileValidator.ts +9 -6
package/core/cache/RedisCache.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 (
|
|
59
|
-
|
|
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
|
|
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();
|
|
@@ -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
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
|
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
|
|