@graphoria/server 0.2.3 → 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.
Files changed (143) hide show
  1. package/README.md +25 -20
  2. package/dist/src/ai/tools/core.d.ts +6 -1
  3. package/dist/src/ai/tools/core.d.ts.map +1 -1
  4. package/dist/src/ai/tools/core.test.d.ts +2 -0
  5. package/dist/src/ai/tools/core.test.d.ts.map +1 -0
  6. package/dist/src/analyzeQuery/costLimit.d.ts +30 -0
  7. package/dist/src/analyzeQuery/costLimit.d.ts.map +1 -0
  8. package/dist/src/analyzeQuery/costLimit.test.d.ts +2 -0
  9. package/dist/src/analyzeQuery/costLimit.test.d.ts.map +1 -0
  10. package/dist/src/analyzeQuery/depthLimit.d.ts.map +1 -1
  11. package/dist/src/analyzeQuery/fragments.d.ts +6 -0
  12. package/dist/src/analyzeQuery/fragments.d.ts.map +1 -0
  13. package/dist/src/config/types/auth.d.ts +8 -0
  14. package/dist/src/config/types/auth.d.ts.map +1 -1
  15. package/dist/src/config/types/db.d.ts +2 -0
  16. package/dist/src/config/types/db.d.ts.map +1 -1
  17. package/dist/src/config/types/operation.d.ts +4 -0
  18. package/dist/src/config/types/operation.d.ts.map +1 -1
  19. package/dist/src/config/types/operation.test.d.ts +2 -0
  20. package/dist/src/config/types/operation.test.d.ts.map +1 -0
  21. package/dist/src/configuration/crossReferences.d.ts +27 -0
  22. package/dist/src/configuration/crossReferences.d.ts.map +1 -0
  23. package/dist/src/configuration/crossReferences.test.d.ts +2 -0
  24. package/dist/src/configuration/crossReferences.test.d.ts.map +1 -0
  25. package/dist/src/configuration/getSchemas/index.d.ts +70 -4
  26. package/dist/src/configuration/getSchemas/index.d.ts.map +1 -1
  27. package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts +3 -0
  28. package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts.map +1 -1
  29. package/dist/src/configuration/gql/handleGraphQLRequestFactory.d.ts +32 -2
  30. package/dist/src/configuration/gql/handleGraphQLRequestFactory.d.ts.map +1 -1
  31. package/dist/src/configuration/gql/handleGraphQLSubscriptionFactory.d.ts.map +1 -1
  32. package/dist/src/configuration/index.d.ts +41 -2
  33. package/dist/src/configuration/index.d.ts.map +1 -1
  34. package/dist/src/configuration/rest/handleRESTRequestFactory.d.ts.map +1 -1
  35. package/dist/src/configuration/rest/index.d.ts.map +1 -1
  36. package/dist/src/console/api.d.ts +7 -5
  37. package/dist/src/console/api.d.ts.map +1 -1
  38. package/dist/src/console/session.d.ts +21 -0
  39. package/dist/src/console/session.d.ts.map +1 -0
  40. package/dist/src/console/session.test.d.ts +2 -0
  41. package/dist/src/console/session.test.d.ts.map +1 -0
  42. package/dist/src/databases/common.d.ts +12 -4
  43. package/dist/src/databases/common.d.ts.map +1 -1
  44. package/dist/src/databases/core/executor.d.ts +6 -2
  45. package/dist/src/databases/core/executor.d.ts.map +1 -1
  46. package/dist/src/databases/core/function-mapping.d.ts +4 -3
  47. package/dist/src/databases/core/function-mapping.d.ts.map +1 -1
  48. package/dist/src/databases/core/query-builder.d.ts +7 -1
  49. package/dist/src/databases/core/query-builder.d.ts.map +1 -1
  50. package/dist/src/databases/engines/mssql/connection.d.ts +29 -6
  51. package/dist/src/databases/engines/mssql/connection.d.ts.map +1 -1
  52. package/dist/src/databases/engines/mssql/connection.test.d.ts +2 -0
  53. package/dist/src/databases/engines/mssql/connection.test.d.ts.map +1 -0
  54. package/dist/src/databases/engines/mssql/format.d.ts +2 -1
  55. package/dist/src/databases/engines/mssql/format.d.ts.map +1 -1
  56. package/dist/src/databases/engines/mssql/query/index.d.ts +3 -2
  57. package/dist/src/databases/engines/mssql/query/index.d.ts.map +1 -1
  58. package/dist/src/databases/engines/mysql/connection.d.ts +16 -0
  59. package/dist/src/databases/engines/mysql/connection.d.ts.map +1 -1
  60. package/dist/src/databases/engines/mysql/connection.test.d.ts +2 -0
  61. package/dist/src/databases/engines/mysql/connection.test.d.ts.map +1 -0
  62. package/dist/src/databases/engines/mysql/format.d.ts +2 -1
  63. package/dist/src/databases/engines/mysql/format.d.ts.map +1 -1
  64. package/dist/src/databases/engines/mysql/query/index.d.ts +3 -2
  65. package/dist/src/databases/engines/mysql/query/index.d.ts.map +1 -1
  66. package/dist/src/databases/engines/postgresql/connection.d.ts +25 -6
  67. package/dist/src/databases/engines/postgresql/connection.d.ts.map +1 -1
  68. package/dist/src/databases/engines/postgresql/connection.test.d.ts +2 -0
  69. package/dist/src/databases/engines/postgresql/connection.test.d.ts.map +1 -0
  70. package/dist/src/databases/engines/postgresql/format.d.ts +2 -1
  71. package/dist/src/databases/engines/postgresql/format.d.ts.map +1 -1
  72. package/dist/src/databases/engines/postgresql/query/index.d.ts +3 -2
  73. package/dist/src/databases/engines/postgresql/query/index.d.ts.map +1 -1
  74. package/dist/src/databases/high-level-operations.d.ts +2 -0
  75. package/dist/src/databases/high-level-operations.d.ts.map +1 -1
  76. package/dist/src/databases/transformers/data-transformers.d.ts +2 -0
  77. package/dist/src/databases/transformers/data-transformers.d.ts.map +1 -1
  78. package/dist/src/index.d.ts.map +1 -1
  79. package/dist/src/singletons/env.d.ts +19 -0
  80. package/dist/src/singletons/env.d.ts.map +1 -1
  81. package/dist/src/singletons/queryTimeout.d.ts +3 -0
  82. package/dist/src/singletons/queryTimeout.d.ts.map +1 -0
  83. package/dist/src/subscriptions/strategies/database.d.ts.map +1 -1
  84. package/dist/src/subscriptions/strategies/database.test.d.ts +2 -0
  85. package/dist/src/subscriptions/strategies/database.test.d.ts.map +1 -0
  86. package/dist/src/subscriptions/types.d.ts +4 -1
  87. package/dist/src/subscriptions/types.d.ts.map +1 -1
  88. package/dist/src/subscriptions/utils/polling.d.ts.map +1 -1
  89. package/dist/src/types/env.d.ts +73 -0
  90. package/dist/src/types/env.d.ts.map +1 -1
  91. package/dist/src/types/zod/auth.d.ts +12 -0
  92. package/dist/src/types/zod/auth.d.ts.map +1 -1
  93. package/dist/src/types/zod/configuration.d.ts +28 -0
  94. package/dist/src/types/zod/configuration.d.ts.map +1 -1
  95. package/dist/src/utils/rateLimit.d.ts +65 -0
  96. package/dist/src/utils/rateLimit.d.ts.map +1 -0
  97. package/dist/src/utils/rateLimit.test.d.ts +2 -0
  98. package/dist/src/utils/rateLimit.test.d.ts.map +1 -0
  99. package/dist/src/utils/responses.d.ts +3 -0
  100. package/dist/src/utils/responses.d.ts.map +1 -1
  101. package/dist/tsconfig.tsbuildinfo +1 -1
  102. package/package.json +1 -1
  103. package/playgrounds/console/index.html +6 -6
  104. package/playgrounds/graphiql/index.html +136 -136
  105. package/src/ai/tools/core.ts +30 -6
  106. package/src/analyzeQuery/costLimit.ts +236 -0
  107. package/src/analyzeQuery/depthLimit.ts +4 -23
  108. package/src/analyzeQuery/fragments.ts +18 -0
  109. package/src/config/types/auth.ts +7 -0
  110. package/src/config/types/db.ts +7 -0
  111. package/src/config/types/operation.ts +4 -0
  112. package/src/configuration/crossReferences.ts +340 -0
  113. package/src/configuration/getSchemas/type-definition-generator/index.ts +1 -1
  114. package/src/configuration/gql/buildExecute.ts +1 -1
  115. package/src/configuration/gql/handleGraphQLRequestFactory.ts +80 -10
  116. package/src/configuration/gql/handleGraphQLSubscriptionFactory.ts +22 -2
  117. package/src/configuration/index.ts +24 -6
  118. package/src/configuration/rest/handleRESTRequestFactory.ts +6 -4
  119. package/src/configuration/rest/index.ts +11 -2
  120. package/src/console/api.ts +51 -10
  121. package/src/console/session.ts +125 -0
  122. package/src/databases/common.ts +55 -10
  123. package/src/databases/core/executor.ts +12 -2
  124. package/src/databases/core/function-mapping.ts +5 -0
  125. package/src/databases/core/query-builder.ts +7 -0
  126. package/src/databases/engines/mssql/connection.ts +66 -14
  127. package/src/databases/engines/mssql/format.ts +3 -1
  128. package/src/databases/engines/mssql/query/index.ts +12 -2
  129. package/src/databases/engines/mysql/connection.ts +30 -13
  130. package/src/databases/engines/mysql/format.ts +3 -1
  131. package/src/databases/engines/mysql/query/index.ts +25 -4
  132. package/src/databases/engines/postgresql/connection.ts +76 -15
  133. package/src/databases/engines/postgresql/format.ts +3 -1
  134. package/src/databases/engines/postgresql/query/index.ts +12 -3
  135. package/src/databases/high-level-operations.ts +13 -2
  136. package/src/index.ts +82 -13
  137. package/src/singletons/queryTimeout.ts +20 -0
  138. package/src/subscriptions/strategies/database.ts +19 -3
  139. package/src/subscriptions/types.ts +4 -1
  140. package/src/subscriptions/utils/polling.ts +6 -3
  141. package/src/types/env.ts +21 -1
  142. package/src/utils/rateLimit.ts +209 -0
  143. package/src/utils/responses.ts +10 -0
@@ -5,27 +5,44 @@ import type { VariableDefinition } from "../../../analyzeQuery/types";
5
5
  import type { Database } from "../../../types/configuration";
6
6
  import type { ProcedureResolver } from "../../../types/db";
7
7
 
8
+ import { BunSQLConnectionOptionsZod } from "../../../config/types/db";
8
9
  import { databasesConnections } from "../../../singletons/databases";
9
10
  import { toMySQLPlaceholders } from "./placeholders";
10
11
 
11
- export const getPool = async (db: Database) => {
12
- const opts = db.connectionOptions as BunSQLConnectionOptions | undefined;
13
- const pool = new SQL({
12
+ // Every pool bound comes from the schema, which is the only place they are
13
+ // declared and documented. Repeating them here as `?? n` fallbacks let the
14
+ // documented default and the applied one drift apart, differently per engine.
15
+ //
16
+ // Defaults are overlaid rather than re-parsed: `connectionOptions` is an
17
+ // undiscriminated union of the two engine shapes, so an object can arrive
18
+ // already validated against the other one, and a strict re-parse would reject
19
+ // it at boot.
20
+ export const poolOptions = (db: Database) => {
21
+ const opts = {
22
+ ...BunSQLConnectionOptionsZod.parse({}),
23
+ ...(db.connectionOptions as BunSQLConnectionOptions | undefined),
24
+ };
25
+
26
+ return {
14
27
  host: db.connection.host,
15
28
  port: db.connection.port,
16
29
  username: db.connection.user,
17
30
  password: db.connection.password,
18
31
  database: db.connection.database,
19
- adapter: "mysql",
20
- max: opts?.max ?? 50,
21
- idleTimeout: opts?.idleTimeout ?? 30,
22
- connectionTimeout: opts?.connectionTimeout,
23
- maxLifetime: opts?.maxLifetime,
24
- tls: opts?.tls,
25
- allowPublicKeyRetrieval: opts?.allowPublicKeyRetrieval,
26
- prepare: opts?.prepare,
27
- bigint: opts?.bigint,
28
- });
32
+ adapter: "mysql" as const,
33
+ max: opts.max,
34
+ idleTimeout: opts.idleTimeout,
35
+ connectionTimeout: opts.connectionTimeout,
36
+ maxLifetime: opts.maxLifetime,
37
+ tls: opts.tls,
38
+ allowPublicKeyRetrieval: opts.allowPublicKeyRetrieval,
39
+ prepare: opts.prepare,
40
+ bigint: opts.bigint,
41
+ };
42
+ };
43
+
44
+ export const getPool = async (db: Database) => {
45
+ const pool = new SQL(poolOptions(db));
29
46
 
30
47
  await pool.connect(); // Connect to the database
31
48
 
@@ -2,6 +2,7 @@ import { format as sqlFormatter } from "sql-formatter";
2
2
 
3
3
  import type { AnalysisResult } from "../../../analyzeQuery/types";
4
4
  import type { MergedEntities } from "../../../configuration/getSchemas/mergeEntities";
5
+ import type { PageLimits } from "../../common";
5
6
 
6
7
  import { generateSQL } from "../../core/query-builder";
7
8
 
@@ -16,8 +17,9 @@ export const genSql = (
16
17
  query: AnalysisResult,
17
18
  variables: Record<string, unknown> = {},
18
19
  hash = false,
20
+ pageLimits: PageLimits | null = null,
19
21
  ) => {
20
- const [[, sql]] = generateSQL(entitites, query, variables, hash);
22
+ const [[, sql]] = generateSQL(entitites, query, variables, hash, pageLimits);
21
23
 
22
24
  return format(sql);
23
25
  };
@@ -4,7 +4,7 @@ import type {
4
4
  VariableDefinition,
5
5
  } from "../../../../analyzeQuery/types";
6
6
  import type { MergedEntities } from "../../../../configuration/getSchemas/mergeEntities";
7
- import type { GroupByInfo } from "../../../common";
7
+ import type { GroupByInfo, PageLimits } from "../../../common";
8
8
 
9
9
  import {
10
10
  buildOrderByClauseMySQL,
@@ -21,6 +21,7 @@ import {
21
21
  } from "../../../common";
22
22
 
23
23
  import { applyDirectives } from "../../../directives";
24
+ import { getQueryTimeoutMs } from "../../../../singletons/queryTimeout";
24
25
 
25
26
  // Generate CTE for aggregations
26
27
  const buildAggregationCTE = (
@@ -157,9 +158,21 @@ export const generateSQL = (
157
158
  operation: OperationAnalysis,
158
159
  variables: Record<string, unknown> = {},
159
160
  forHashMethod: boolean = false,
161
+ pageLimits: PageLimits | null = null,
162
+ timeoutMs?: number,
160
163
  ): string => {
164
+ // Bun's MySQL adapter ignores the `connection` bag and exposes no
165
+ // per-connection init hook, so there is no pool-level route for
166
+ // max_execution_time: the bound has to ride in the SQL text. MySQL honours the
167
+ // hint only on the outermost SELECT — one placed inside a CTE is silently
168
+ // discarded — which is why it is emitted here, at each return, rather than
169
+ // spliced into the finished string by the executor.
170
+ const effectiveTimeoutMs = timeoutMs ?? getQueryTimeoutMs();
171
+ const timeoutHint =
172
+ effectiveTimeoutMs > 0 ? ` /*+ MAX_EXECUTION_TIME(${effectiveTimeoutMs}) */` : "";
173
+
161
174
  if (forHashMethod) {
162
- return `SELECT MD5((${buildSQLForField(entities, operation.variables ?? [], variables, operation.fields[0], null, null, 1, {})})) AS ResultHash`;
175
+ return `SELECT${timeoutHint} MD5((${buildSQLForField(entities, operation.variables ?? [], variables, operation.fields[0], null, null, 1, {}, pageLimits)})) AS ResultHash`;
163
176
  }
164
177
 
165
178
  const variablesWithDefault = {
@@ -220,6 +233,7 @@ export const generateSQL = (
220
233
  null,
221
234
  index + 1,
222
235
  {},
236
+ pageLimits,
223
237
  );
224
238
 
225
239
  fieldQueries.push(`'${field.alias || field.name}', ${fieldSQL}`);
@@ -228,7 +242,7 @@ export const generateSQL = (
228
242
  const cteClause = ctes.length > 0 ? `WITH\n${ctes.join(",\n")}\n` : "";
229
243
 
230
244
  return `
231
- ${cteClause}SELECT JSON_OBJECT(
245
+ ${cteClause}SELECT${timeoutHint} JSON_OBJECT(
232
246
  ${fieldQueries.join(",\n")}
233
247
  ) as json_result`;
234
248
  };
@@ -242,6 +256,7 @@ export const buildSQLForField = (
242
256
  parentTableAlias: string | null,
243
257
  level: number,
244
258
  aliasMap: { [alias: string]: string },
259
+ pageLimits: PageLimits | null,
245
260
  ): string => {
246
261
  const tableAlias = generateTableAlias(level);
247
262
 
@@ -306,6 +321,7 @@ export const buildSQLForField = (
306
321
  tableAlias,
307
322
  level,
308
323
  aliasMap,
324
+ pageLimits,
309
325
  ),
310
326
  ([name, selector]) => `'${name}', ${selector}`,
311
327
  );
@@ -313,7 +329,12 @@ export const buildSQLForField = (
313
329
  const fromClause = `FROM ${dottedQuotedName} ${tableAlias}`;
314
330
 
315
331
  const orderByClause = buildOrderByClauseMySQL(entities, field, tableAlias);
316
- const paginationClause = buildPaginationClauseMySQL(field, variablesDefinition);
332
+ const paginationClause = buildPaginationClauseMySQL(
333
+ field,
334
+ variablesDefinition,
335
+ variables,
336
+ pageLimits,
337
+ );
317
338
 
318
339
  const isArraySelection = !!field.isArray && !withoutArrayWrapper;
319
340
 
@@ -5,24 +5,51 @@ import type { VariableDefinition } from "../../../analyzeQuery/types";
5
5
  import type { Database } from "../../../types/configuration";
6
6
  import type { ProcedureResolver } from "../../../types/db";
7
7
 
8
+ import { BunSQLConnectionOptionsZod } from "../../../config/types/db";
8
9
  import { databasesConnections } from "../../../singletons/databases";
10
+ import { getQueryTimeoutMs } from "../../../singletons/queryTimeout";
11
+
12
+ // Every pool bound comes from the schema, which is the only place they are
13
+ // declared and documented. Repeating them here as `?? n` fallbacks let the
14
+ // documented default and the applied one drift apart, differently per engine.
15
+ //
16
+ // Defaults are overlaid rather than re-parsed: `connectionOptions` is an
17
+ // undiscriminated union of the two engine shapes, so an object can arrive
18
+ // already validated against the other one, and a strict re-parse would reject
19
+ // it at boot.
20
+ export const poolOptions = (db: Database, timeoutMs: number = getQueryTimeoutMs()) => {
21
+ const opts = {
22
+ ...BunSQLConnectionOptionsZod.parse({}),
23
+ ...(db.connectionOptions as BunSQLConnectionOptions | undefined),
24
+ };
9
25
 
10
- export const getPool = async (db: Database) => {
11
- const opts = db.connectionOptions as BunSQLConnectionOptions | undefined;
12
- const pool = new SQL({
26
+ return {
13
27
  host: db.connection.host,
14
28
  port: db.connection.port,
15
29
  user: db.connection.user,
16
30
  password: db.connection.password,
17
31
  database: db.connection.database,
18
- max: opts?.max ?? 5,
19
- idleTimeout: opts?.idleTimeout ?? 30,
20
- connectionTimeout: opts?.connectionTimeout,
21
- maxLifetime: opts?.maxLifetime,
22
- tls: opts?.tls,
23
- prepare: opts?.prepare,
24
- bigint: opts?.bigint,
25
- });
32
+ max: opts.max,
33
+ idleTimeout: opts.idleTimeout,
34
+ connectionTimeout: opts.connectionTimeout,
35
+ maxLifetime: opts.maxLifetime,
36
+ tls: opts.tls,
37
+ prepare: opts.prepare,
38
+ bigint: opts.bigint,
39
+ // Applied once per pooled connection rather than per query, so it bounds
40
+ // everything that runs on the connection — auth and introspection included
41
+ // — at no per-query cost. Omitted at 0 rather than sent as "0", which
42
+ // Postgres reads as unlimited but still costs a round trip to set.
43
+ ...(timeoutMs > 0 ? { connection: { statement_timeout: String(timeoutMs) } } : {}),
44
+ };
45
+ };
46
+
47
+ /** The bound the pool already applies, so the executor can tell an override from the default. */
48
+ export const poolStatementTimeoutMs = (db: Database) =>
49
+ Number(poolOptions(db).connection?.statement_timeout ?? 0);
50
+
51
+ export const getPool = async (db: Database) => {
52
+ const pool = new SQL(poolOptions(db));
26
53
 
27
54
  await pool.connect(); // Connect to the database
28
55
 
@@ -31,6 +58,35 @@ export const getPool = async (db: Database) => {
31
58
 
32
59
  export const getPoolSingleton = async (db: Database) => databasesConnections[db.name] as SQL;
33
60
 
61
+ // A timeout other than the pool's own has to be set on a connection, which
62
+ // means pinning one for the duration. Only an override pays for that.
63
+ const executeReserved = async <T>(
64
+ pool: SQL,
65
+ timeoutMs: number,
66
+ query: string,
67
+ params: unknown[],
68
+ ) => {
69
+ // SET takes no bind parameters, so the value is interpolated. It is an
70
+ // integer from the config schema; re-checked here so a widened type upstream
71
+ // cannot turn this line into an injection point.
72
+ if (!Number.isInteger(timeoutMs) || timeoutMs < 0) {
73
+ throw new Error(`Invalid statement timeout: ${timeoutMs}`);
74
+ }
75
+
76
+ const reserved = await pool.reserve();
77
+
78
+ try {
79
+ await reserved.unsafe(`SET statement_timeout = ${timeoutMs}`);
80
+
81
+ return await reserved.unsafe<T>(query, params);
82
+ } finally {
83
+ // The connection goes back to the pool carrying the pool's default again,
84
+ // not this query's override.
85
+ await reserved.unsafe("RESET statement_timeout");
86
+ await reserved.release();
87
+ }
88
+ };
89
+
34
90
  export const executeQueryFactory =
35
91
  (singleQuery = false) =>
36
92
  async <T>(
@@ -38,13 +94,16 @@ export const executeQueryFactory =
38
94
  db: Database,
39
95
  variablesDefinition: VariableDefinition[],
40
96
  values: Record<string, unknown> = {},
97
+ timeoutMs?: number,
41
98
  ) => {
42
99
  const pool = singleQuery ? await getPool(db) : await getPoolSingleton(db);
100
+ const params = variablesDefinition.map((v) => values[v.name]);
43
101
 
44
- const result = await pool.unsafe<T>(
45
- query,
46
- variablesDefinition.map((v) => values[v.name]),
47
- );
102
+ const override = timeoutMs !== undefined && timeoutMs !== poolStatementTimeoutMs(db);
103
+
104
+ const result = override
105
+ ? await executeReserved<T>(pool, timeoutMs, query, params)
106
+ : await pool.unsafe<T>(query, params);
48
107
 
49
108
  if (singleQuery) {
50
109
  await pool.close();
@@ -63,12 +122,14 @@ export const executeQueryJSONFactory =
63
122
  db: Database,
64
123
  variablesDefinition: VariableDefinition[] = [],
65
124
  values: Record<string, unknown> = {},
125
+ timeoutMs?: number,
66
126
  ) => {
67
127
  const result = await (singleQuery ? executeQuerySingle : executeQuery)<[{ json_result: T }]>(
68
128
  query,
69
129
  db,
70
130
  variablesDefinition,
71
131
  values,
132
+ timeoutMs,
72
133
  );
73
134
 
74
135
  return result[0].json_result as T;
@@ -2,6 +2,7 @@ import { format as sqlFormatter } from "sql-formatter";
2
2
 
3
3
  import type { AnalysisResult } from "../../../analyzeQuery/types";
4
4
  import type { MergedEntities } from "../../../configuration/getSchemas/mergeEntities";
5
+ import type { PageLimits } from "../../common";
5
6
 
6
7
  import { generateSQL } from "../../core/query-builder";
7
8
 
@@ -16,8 +17,9 @@ export const genSql = (
16
17
  query: AnalysisResult,
17
18
  variables: Record<string, unknown> = {},
18
19
  hash = false,
20
+ pageLimits: PageLimits | null = null,
19
21
  ) => {
20
- const [[, sql]] = generateSQL(entitites, query, variables, hash);
22
+ const [[, sql]] = generateSQL(entitites, query, variables, hash, pageLimits);
21
23
 
22
24
  return format(sql);
23
25
  };
@@ -4,7 +4,7 @@ import type {
4
4
  VariableDefinition,
5
5
  } from "../../../../analyzeQuery/types";
6
6
  import type { MergedEntities } from "../../../../configuration/getSchemas/mergeEntities";
7
- import type { GroupByInfo } from "../../../common";
7
+ import type { GroupByInfo, PageLimits } from "../../../common";
8
8
 
9
9
  import {
10
10
  buildOrderByClausePG,
@@ -154,9 +154,10 @@ export const generateSQL = (
154
154
  operation: OperationAnalysis,
155
155
  variables: Record<string, unknown> = {},
156
156
  forHashMethod: boolean = false,
157
+ pageLimits: PageLimits | null = null,
157
158
  ): string => {
158
159
  if (forHashMethod) {
159
- return `SELECT MD5((${buildSQLForField(entities, operation.variables ?? [], variables, operation.fields[0], null, null, 1, {})})::text) AS "ResultHash"`;
160
+ return `SELECT MD5((${buildSQLForField(entities, operation.variables ?? [], variables, operation.fields[0], null, null, 1, {}, pageLimits)})::text) AS "ResultHash"`;
160
161
  }
161
162
 
162
163
  const variablesWithDefault = {
@@ -217,6 +218,7 @@ export const generateSQL = (
217
218
  null,
218
219
  index + 1,
219
220
  {},
221
+ pageLimits,
220
222
  );
221
223
 
222
224
  fieldQueries.push(`'${field.alias || field.name}', ${fieldSQL}`);
@@ -239,6 +241,7 @@ export const buildSQLForField = (
239
241
  parentTableAlias: string | null,
240
242
  level: number,
241
243
  aliasMap: { [alias: string]: string },
244
+ pageLimits: PageLimits | null,
242
245
  ): string => {
243
246
  const tableAlias = generateTableAlias(level);
244
247
 
@@ -303,6 +306,7 @@ export const buildSQLForField = (
303
306
  tableAlias,
304
307
  level,
305
308
  aliasMap,
309
+ pageLimits,
306
310
  ),
307
311
  ([name, selector]) => `'${name}', ${selector}`,
308
312
  );
@@ -310,7 +314,12 @@ export const buildSQLForField = (
310
314
  const fromClause = `FROM ${dottedQuotedName} ${tableAlias}`;
311
315
 
312
316
  const orderByClause = buildOrderByClausePG(entities, field, tableAlias);
313
- const paginationClause = buildPaginationClausePG(field, variablesDefinition);
317
+ const paginationClause = buildPaginationClausePG(
318
+ field,
319
+ variablesDefinition,
320
+ variables,
321
+ pageLimits,
322
+ );
314
323
 
315
324
  const isArraySelection = !!field.isArray && !withoutArrayWrapper;
316
325
 
@@ -28,6 +28,10 @@ export const getDatabasesStructure = async (
28
28
  ) => {
29
29
  const entities: TableResolver[] = [];
30
30
  const enhancedStoredProcedures: ProcedureResolver[] = [];
31
+ // Taken before the exclusion and auth-table filters below, because that is the
32
+ // only point where a name the config excludes is still visible. Resolving
33
+ // `schema.excludedTables` against `entities` could never succeed.
34
+ const tableNamesByDatabase: Record<string, string[]> = {};
31
35
 
32
36
  // The auth user table holds the password hash and must never be served via
33
37
  // the generated API. Its resolver key matches t.schemaName, which is always
@@ -39,10 +43,17 @@ export const getDatabasesStructure = async (
39
43
  for (const db of databases) {
40
44
  const { tables, storedProcedures } = await fetchStructure(db);
41
45
 
46
+ // Every other name comparison in this file folds case. This one used not to,
47
+ // and it is the one where a mismatch fails open: excludedTables is a
48
+ // do-not-serve list, so a name spelled in the wrong case served the table.
49
+ const excluded = new Set((db.schema?.excludedTables ?? []).map((name) => name.toLowerCase()));
50
+
51
+ tableNamesByDatabase[db.name] = tables.map((t) => t.schemaName);
52
+
42
53
  const tablesToAdd = tables
43
54
  .reduce<Tables>((acc, t) => {
44
55
  if (authUserKey && t.schemaName === authUserKey) return acc;
45
- if (db.schema?.excludedTables.includes(t.schemaName)) return acc;
56
+ if (excluded.has(t.schemaName.toLowerCase())) return acc;
46
57
 
47
58
  const tableOverride = db.schema?.database[t.schemaName];
48
59
 
@@ -150,7 +161,7 @@ export const getDatabasesStructure = async (
150
161
  enhancedStoredProcedures.push(...storedProceduresToAdd);
151
162
  }
152
163
 
153
- return { tables: entities, storedProcedures: enhancedStoredProcedures };
164
+ return { tables: entities, storedProcedures: enhancedStoredProcedures, tableNamesByDatabase };
154
165
  };
155
166
 
156
167
  export type EntitiesOfRole = {
package/src/index.ts CHANGED
@@ -18,10 +18,18 @@ import { getAgent, instantiateAI } from "./singletons/ai";
18
18
  import { getTokenService, setTokenService } from "./singletons/authentication";
19
19
  import { instantiateCronJobs } from "./singletons/cron";
20
20
  import { disconnectDatabases, instantiateDatabasesConnections } from "./singletons/databases";
21
+ import { getCacheRedisClient } from "./singletons/cache/redisClient";
21
22
  import { env } from "./singletons/env";
23
+ import { setQueryTimeoutMs } from "./singletons/queryTimeout";
22
24
  import { instantiateQueues } from "./singletons/queues";
23
25
  import { ConfigurationZod } from "./types/zod/configuration";
24
- import { S200, S400, S401, S404 } from "./utils/responses";
26
+ import {
27
+ createMemoryRateLimitStore,
28
+ createRateLimiter,
29
+ createRedisRateLimitStore,
30
+ resolveClientAddress,
31
+ } from "./utils/rateLimit";
32
+ import { S200, S400, S401, S404, S429 } from "./utils/responses";
25
33
  import { writeSchema } from "./utils/writeSchema";
26
34
  import { logger, configureLogging } from "./logging";
27
35
 
@@ -73,6 +81,20 @@ const bootAnalyzedConfiguration = async (env: Env) => {
73
81
  configureLogging(env.logger);
74
82
  }
75
83
 
84
+ if (env.maxQueryDepth === 0) {
85
+ logger("graphoria").warn(
86
+ "MAX_QUERY_DEPTH=0 disables the query depth limit; one deeply nested query can exhaust the server",
87
+ );
88
+ }
89
+
90
+ setQueryTimeoutMs(env.queryTimeoutMs);
91
+
92
+ if (env.queryTimeoutMs === 0) {
93
+ logger("graphoria").warn(
94
+ "QUERY_TIMEOUT_MS=0 disables the statement timeout; one slow query can hold a connection and its locks indefinitely",
95
+ );
96
+ }
97
+
76
98
  if (!env.configuration) {
77
99
  throw new Error("Configuration is required to create the GraphQL server");
78
100
  }
@@ -212,19 +234,57 @@ const createGraphQLServer = async (env: Env) => {
212
234
 
213
235
  const consoleHtml = await renderPlayground("../playgrounds/console/index.html", {});
214
236
 
237
+ const rateLimiter = createRateLimiter({
238
+ settings: env.rateLimit,
239
+ anonymousRole: env.anonymousRole,
240
+ permissions: projectConfiguration.auth?.permissions,
241
+ store: () =>
242
+ env.cache.store === "redis"
243
+ ? createRedisRateLimitStore(getCacheRedisClient())
244
+ : createMemoryRateLimitStore(),
245
+ });
246
+
215
247
  // Helper to get role-based handlers
216
- const getRoleHandlers = async (req: Request) => {
248
+ const getRoleHandlers = async (req: Request, server?: Bun.Server<unknown>) => {
217
249
  const session = await getTokenService().verifyTokenAndGetSession(
218
250
  req.headers.get(env.authorizationHeader),
219
251
  req.headers.get(env.admin.header),
220
252
  );
253
+
254
+ // The limit needs the role, and the role costs a token verification plus a
255
+ // revocation lookup — so it is spent here, once, rather than again inside a
256
+ // wrapper.
257
+ const limit = await rateLimiter?.check(
258
+ session,
259
+ resolveClientAddress(req, server, env.rateLimit.trustProxy),
260
+ );
261
+
221
262
  return {
222
263
  role: session.role!,
223
264
  session,
265
+ limit,
224
266
  ...analyzedConfiguration.roles[session.role!].handlers,
225
267
  };
226
268
  };
227
269
 
270
+ /**
271
+ * For the entry points that have no session to key on: the websocket upgrade
272
+ * (the token arrives in `connection_init`, after the upgrade) and MCP. They
273
+ * are keyed by address against the anonymous ceiling.
274
+ */
275
+ const withRateLimit =
276
+ <T>(handler: (req: BunRequest, server: Bun.Server<unknown>) => T | Promise<T>) =>
277
+ async (req: BunRequest, server: Bun.Server<unknown>) => {
278
+ const limit = await rateLimiter?.check(
279
+ null,
280
+ resolveClientAddress(req, server, env.rateLimit.trustProxy),
281
+ );
282
+
283
+ if (limit && !limit.allowed) return new S429(limit.retryAfterMs);
284
+
285
+ return handler(req, server);
286
+ };
287
+
228
288
  // Create routes map with all handlers
229
289
  const routes: RoutesMap = {};
230
290
 
@@ -251,7 +311,8 @@ const createGraphQLServer = async (env: Env) => {
251
311
  prefixes,
252
312
  projectConfiguration,
253
313
  analyzedConfiguration,
254
- getRoleHandlers,
314
+ tokenService: getTokenService(),
315
+ rateLimiter,
255
316
  }),
256
317
  );
257
318
  }
@@ -259,7 +320,7 @@ const createGraphQLServer = async (env: Env) => {
259
320
  // GraphQL endpoint
260
321
  routes[prefixes.graphql] = {
261
322
  ...(env.enableCors ? { OPTIONS: () => new S200(null) } : {}),
262
- GET: async (req: Request, server: Bun.Server<unknown>) => {
323
+ GET: withRateLimit(async (req: Request, server: Bun.Server<unknown>) => {
263
324
  try {
264
325
  if (req.headers.get("upgrade") === "websocket") {
265
326
  const success = server.upgrade(req, {
@@ -271,17 +332,19 @@ const createGraphQLServer = async (env: Env) => {
271
332
  } catch (error) {
272
333
  return new S400({ errors: [{ message: (error as Error)?.message }] });
273
334
  }
274
- },
275
- POST: async (req: BunRequest) => {
335
+ }),
336
+ POST: async (req: BunRequest, server: Bun.Server<unknown>) => {
276
337
  try {
277
- const { gql, session } = await getRoleHandlers(req);
338
+ const { gql, session, limit } = await getRoleHandlers(req, server);
339
+ if (limit && !limit.allowed) return new S429(limit.retryAfterMs);
340
+
278
341
  const { query, variables } = await req.json();
279
342
 
280
343
  if (gql.isIntrospectionQuery(query)) return new S200(gql.introspectionResult);
281
344
 
282
345
  if (gql.isNoDataQuery(query)) return new S200(gql.noDataResult);
283
346
 
284
- const { hasErrors, validationErrors } = gql.hasErrors(query);
347
+ const { hasErrors, validationErrors } = gql.hasErrors(query, { variables });
285
348
 
286
349
  if (hasErrors)
287
350
  return new S400({
@@ -312,7 +375,7 @@ const createGraphQLServer = async (env: Env) => {
312
375
 
313
376
  if (mcpEnabled) {
314
377
  const mcpPath = `${env.prefix}${env.ai?.mcp?.endpoint ?? projectConfiguration.ai?.endpoint ?? "/ai"}`;
315
- routes[mcpPath] = createMCPRoutes(analyzedConfiguration, {
378
+ const mcpRoutes = createMCPRoutes(analyzedConfiguration, {
316
379
  ...(env.ai?.mcp ?? {}),
317
380
  name: projectConfiguration.name,
318
381
  version: projectConfiguration.version,
@@ -320,15 +383,20 @@ const createGraphQLServer = async (env: Env) => {
320
383
  adminSecret: env.admin.secret,
321
384
  adminSecretHeader: env.admin.header,
322
385
  });
386
+
387
+ routes[mcpPath] = Object.fromEntries(
388
+ Object.entries(mcpRoutes).map(([method, handler]) => [method, withRateLimit(handler)]),
389
+ );
323
390
  }
324
391
 
325
392
  if (env.ai?.restEnabled) {
326
393
  const aiPath = `${env.prefix}/rest${projectConfiguration.ai.endpoint ?? "/ai"}`;
327
394
  routes[aiPath] = {
328
395
  ...(env.enableCors ? { OPTIONS: () => new S200(null) } : {}),
329
- POST: async (req: BunRequest) => {
396
+ POST: async (req: BunRequest, server: Bun.Server<unknown>) => {
330
397
  try {
331
- const { role } = await getRoleHandlers(req);
398
+ const { role, limit } = await getRoleHandlers(req, server);
399
+ if (limit && !limit.allowed) return new S429(limit.retryAfterMs);
332
400
  if (role !== env.superadmin.role) return new S404({ error: "Not Found" });
333
401
 
334
402
  const { prompt } = await req.json();
@@ -347,11 +415,12 @@ const createGraphQLServer = async (env: Env) => {
347
415
  }
348
416
 
349
417
  // REST API endpoint
350
- routes[`${prefixes.rest}/*`] = async (req: BunRequest) => {
418
+ routes[`${prefixes.rest}/*`] = async (req: BunRequest, server: Bun.Server<unknown>) => {
351
419
  if (req.method === "OPTIONS" && env.enableCors) return new S200(null);
352
420
 
353
421
  try {
354
- const { rest, session } = await getRoleHandlers(req);
422
+ const { rest, session, limit } = await getRoleHandlers(req, server);
423
+ if (limit && !limit.allowed) return new S429(limit.retryAfterMs);
355
424
 
356
425
  const urlParsed = new URL(req.url);
357
426
 
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The process-wide statement timeout in milliseconds, resolved from
3
+ * `QUERY_TIMEOUT_MS` at boot.
4
+ *
5
+ * It lives here rather than being read from `singletons/env` because the engine
6
+ * connection modules need it, and that module parses `process.env` the moment it
7
+ * is imported — reaching it from an engine would make every unit test that
8
+ * transitively touches a query builder fail without `ADMIN_SECRET` set.
9
+ *
10
+ * `0` disables the bound. That is also the value before boot sets it, so an
11
+ * engine used outside a booted server behaves exactly as it did before timeouts
12
+ * existed.
13
+ */
14
+ let queryTimeoutMs = 0;
15
+
16
+ export const setQueryTimeoutMs = (ms: number) => {
17
+ queryTimeoutMs = ms;
18
+ };
19
+
20
+ export const getQueryTimeoutMs = () => queryTimeoutMs;
@@ -15,10 +15,26 @@ export const createDatabaseSubscriptionStrategy = (): SubscriptionStrategy => ({
15
15
  source: EntitySource.TABLE,
16
16
 
17
17
  getSubscriptionKey(context) {
18
- const { analysis } = context;
18
+ const { analysis, field, session, variables } = context;
19
19
  const operation = analysis.operations[0];
20
- // Use operation name as the base key (existing behavior)
21
- return operation.name ?? operation.fields[0]?.name ?? "unknown";
20
+ const base = operation.name ?? operation.fields[0]?.name ?? "unknown";
21
+
22
+ // Every part of this is load-bearing. A group shares one poller, and every
23
+ // client joined to it is served that poller's rows — so two callers may
24
+ // only share a key when the query they asked for is the same query,
25
+ // resolved against the same session. The role and the subject carry the row
26
+ // filter; the variables carry everything the caller chose; the selections
27
+ // carry the shape. Two keys that differ where they should not merely cost a
28
+ // second poller, but two that match where they should not is a cross-tenant
29
+ // read.
30
+ return [
31
+ base,
32
+ session.role ?? "",
33
+ session.sub ?? "",
34
+ field.alias ?? "",
35
+ JSON.stringify(field.selections),
36
+ JSON.stringify(variables),
37
+ ].join("\u0000");
22
38
  },
23
39
 
24
40
  async subscribe(context: SubscriptionContext): Promise<SubscriptionResult> {