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.
@@ -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();
@@ -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
 
@@ -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 if it's a Zod type (check for '_def' property)
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
- const zodSchema = (input && typeof input === 'object' && '_def' in input) ? input as any : null;
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bunsane",
3
- "version": "0.2.10",
3
+ "version": "0.3.0",
4
4
  "author": {
5
5
  "name": "yaaruu"
6
6
  },
@@ -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->>'${sortOrder.property}')::numeric`
339
- : `sort_c.data->>'${sortOrder.property}'`;
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->>'${sortOrder.property}')::numeric`
459
- : `c.data->>'${sortOrder.property}'`;
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->>'${sortOrder.property}')::numeric`
513
- : `sort_c.data->>'${sortOrder.property}'`;
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)