@graphoria/server 0.2.2 → 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 (160) 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/ai.d.ts +0 -6
  14. package/dist/src/config/types/ai.d.ts.map +1 -1
  15. package/dist/src/config/types/auth.d.ts +8 -29
  16. package/dist/src/config/types/auth.d.ts.map +1 -1
  17. package/dist/src/config/types/cron.d.ts +0 -94
  18. package/dist/src/config/types/cron.d.ts.map +1 -1
  19. package/dist/src/config/types/db.d.ts +2 -87
  20. package/dist/src/config/types/db.d.ts.map +1 -1
  21. package/dist/src/config/types/operation.d.ts +4 -0
  22. package/dist/src/config/types/operation.d.ts.map +1 -1
  23. package/dist/src/config/types/operation.test.d.ts +2 -0
  24. package/dist/src/config/types/operation.test.d.ts.map +1 -0
  25. package/dist/src/config/types/queue.d.ts +0 -47
  26. package/dist/src/config/types/queue.d.ts.map +1 -1
  27. package/dist/src/config/types/remote-rest.d.ts +0 -9
  28. package/dist/src/config/types/remote-rest.d.ts.map +1 -1
  29. package/dist/src/config/types/remote-schema.d.ts +0 -10
  30. package/dist/src/config/types/remote-schema.d.ts.map +1 -1
  31. package/dist/src/configuration/crossReferences.d.ts +27 -0
  32. package/dist/src/configuration/crossReferences.d.ts.map +1 -0
  33. package/dist/src/configuration/crossReferences.test.d.ts +2 -0
  34. package/dist/src/configuration/crossReferences.test.d.ts.map +1 -0
  35. package/dist/src/configuration/getSchemas/index.d.ts +70 -4
  36. package/dist/src/configuration/getSchemas/index.d.ts.map +1 -1
  37. package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts +3 -0
  38. package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts.map +1 -1
  39. package/dist/src/configuration/gql/handleGraphQLRequestFactory.d.ts +32 -2
  40. package/dist/src/configuration/gql/handleGraphQLRequestFactory.d.ts.map +1 -1
  41. package/dist/src/configuration/gql/handleGraphQLSubscriptionFactory.d.ts.map +1 -1
  42. package/dist/src/configuration/index.d.ts +41 -2
  43. package/dist/src/configuration/index.d.ts.map +1 -1
  44. package/dist/src/configuration/rest/handleRESTRequestFactory.d.ts.map +1 -1
  45. package/dist/src/configuration/rest/index.d.ts.map +1 -1
  46. package/dist/src/console/api.d.ts +7 -5
  47. package/dist/src/console/api.d.ts.map +1 -1
  48. package/dist/src/console/session.d.ts +21 -0
  49. package/dist/src/console/session.d.ts.map +1 -0
  50. package/dist/src/console/session.test.d.ts +2 -0
  51. package/dist/src/console/session.test.d.ts.map +1 -0
  52. package/dist/src/cron/index.d.ts +1 -1
  53. package/dist/src/cron/index.d.ts.map +1 -1
  54. package/dist/src/cron/index.test.d.ts +2 -0
  55. package/dist/src/cron/index.test.d.ts.map +1 -0
  56. package/dist/src/databases/common.d.ts +12 -4
  57. package/dist/src/databases/common.d.ts.map +1 -1
  58. package/dist/src/databases/core/executor.d.ts +6 -2
  59. package/dist/src/databases/core/executor.d.ts.map +1 -1
  60. package/dist/src/databases/core/function-mapping.d.ts +4 -3
  61. package/dist/src/databases/core/function-mapping.d.ts.map +1 -1
  62. package/dist/src/databases/core/query-builder.d.ts +7 -1
  63. package/dist/src/databases/core/query-builder.d.ts.map +1 -1
  64. package/dist/src/databases/engines/mssql/connection.d.ts +29 -6
  65. package/dist/src/databases/engines/mssql/connection.d.ts.map +1 -1
  66. package/dist/src/databases/engines/mssql/connection.test.d.ts +2 -0
  67. package/dist/src/databases/engines/mssql/connection.test.d.ts.map +1 -0
  68. package/dist/src/databases/engines/mssql/format.d.ts +2 -1
  69. package/dist/src/databases/engines/mssql/format.d.ts.map +1 -1
  70. package/dist/src/databases/engines/mssql/query/index.d.ts +3 -2
  71. package/dist/src/databases/engines/mssql/query/index.d.ts.map +1 -1
  72. package/dist/src/databases/engines/mysql/connection.d.ts +16 -0
  73. package/dist/src/databases/engines/mysql/connection.d.ts.map +1 -1
  74. package/dist/src/databases/engines/mysql/connection.test.d.ts +2 -0
  75. package/dist/src/databases/engines/mysql/connection.test.d.ts.map +1 -0
  76. package/dist/src/databases/engines/mysql/format.d.ts +2 -1
  77. package/dist/src/databases/engines/mysql/format.d.ts.map +1 -1
  78. package/dist/src/databases/engines/mysql/query/index.d.ts +3 -2
  79. package/dist/src/databases/engines/mysql/query/index.d.ts.map +1 -1
  80. package/dist/src/databases/engines/postgresql/connection.d.ts +25 -6
  81. package/dist/src/databases/engines/postgresql/connection.d.ts.map +1 -1
  82. package/dist/src/databases/engines/postgresql/connection.test.d.ts +2 -0
  83. package/dist/src/databases/engines/postgresql/connection.test.d.ts.map +1 -0
  84. package/dist/src/databases/engines/postgresql/format.d.ts +2 -1
  85. package/dist/src/databases/engines/postgresql/format.d.ts.map +1 -1
  86. package/dist/src/databases/engines/postgresql/query/index.d.ts +3 -2
  87. package/dist/src/databases/engines/postgresql/query/index.d.ts.map +1 -1
  88. package/dist/src/databases/high-level-operations.d.ts +2 -0
  89. package/dist/src/databases/high-level-operations.d.ts.map +1 -1
  90. package/dist/src/databases/transformers/data-transformers.d.ts +2 -0
  91. package/dist/src/databases/transformers/data-transformers.d.ts.map +1 -1
  92. package/dist/src/index.d.ts.map +1 -1
  93. package/dist/src/singletons/env.d.ts +19 -0
  94. package/dist/src/singletons/env.d.ts.map +1 -1
  95. package/dist/src/singletons/queryTimeout.d.ts +3 -0
  96. package/dist/src/singletons/queryTimeout.d.ts.map +1 -0
  97. package/dist/src/subscriptions/strategies/database.d.ts.map +1 -1
  98. package/dist/src/subscriptions/strategies/database.test.d.ts +2 -0
  99. package/dist/src/subscriptions/strategies/database.test.d.ts.map +1 -0
  100. package/dist/src/subscriptions/types.d.ts +4 -1
  101. package/dist/src/subscriptions/types.d.ts.map +1 -1
  102. package/dist/src/subscriptions/utils/polling.d.ts.map +1 -1
  103. package/dist/src/types/env.d.ts +73 -0
  104. package/dist/src/types/env.d.ts.map +1 -1
  105. package/dist/src/types/zod/auth.d.ts +12 -0
  106. package/dist/src/types/zod/auth.d.ts.map +1 -1
  107. package/dist/src/types/zod/configuration.d.ts +28 -0
  108. package/dist/src/types/zod/configuration.d.ts.map +1 -1
  109. package/dist/src/utils/rateLimit.d.ts +65 -0
  110. package/dist/src/utils/rateLimit.d.ts.map +1 -0
  111. package/dist/src/utils/rateLimit.test.d.ts +2 -0
  112. package/dist/src/utils/rateLimit.test.d.ts.map +1 -0
  113. package/dist/src/utils/responses.d.ts +3 -0
  114. package/dist/src/utils/responses.d.ts.map +1 -1
  115. package/dist/tsconfig.tsbuildinfo +1 -1
  116. package/package.json +13 -13
  117. package/playgrounds/console/index.html +7 -7
  118. package/playgrounds/graphiql/index.html +535 -536
  119. package/playgrounds/scalar/index.html +1 -1
  120. package/src/ai/tools/core.ts +30 -6
  121. package/src/analyzeQuery/costLimit.ts +236 -0
  122. package/src/analyzeQuery/depthLimit.ts +4 -23
  123. package/src/analyzeQuery/fragments.ts +18 -0
  124. package/src/config/types/auth.ts +7 -0
  125. package/src/config/types/db.ts +7 -0
  126. package/src/config/types/operation.ts +4 -0
  127. package/src/configuration/crossReferences.ts +340 -0
  128. package/src/configuration/getSchemas/type-definition-generator/index.ts +1 -1
  129. package/src/configuration/gql/buildExecute.ts +1 -1
  130. package/src/configuration/gql/handleGraphQLRequestFactory.ts +80 -10
  131. package/src/configuration/gql/handleGraphQLSubscriptionFactory.ts +22 -2
  132. package/src/configuration/index.ts +24 -6
  133. package/src/configuration/rest/handleRESTRequestFactory.ts +6 -4
  134. package/src/configuration/rest/index.ts +11 -2
  135. package/src/console/api.ts +51 -10
  136. package/src/console/session.ts +125 -0
  137. package/src/cron/index.ts +6 -2
  138. package/src/databases/common.ts +55 -10
  139. package/src/databases/core/executor.ts +12 -2
  140. package/src/databases/core/function-mapping.ts +5 -0
  141. package/src/databases/core/query-builder.ts +7 -0
  142. package/src/databases/engines/mssql/connection.ts +66 -14
  143. package/src/databases/engines/mssql/format.ts +3 -1
  144. package/src/databases/engines/mssql/query/index.ts +12 -2
  145. package/src/databases/engines/mysql/connection.ts +30 -13
  146. package/src/databases/engines/mysql/format.ts +3 -1
  147. package/src/databases/engines/mysql/query/index.ts +25 -4
  148. package/src/databases/engines/postgresql/connection.ts +76 -15
  149. package/src/databases/engines/postgresql/format.ts +3 -1
  150. package/src/databases/engines/postgresql/query/index.ts +12 -3
  151. package/src/databases/high-level-operations.ts +13 -2
  152. package/src/index.ts +82 -13
  153. package/src/singletons/queryTimeout.ts +20 -0
  154. package/src/subscriptions/strategies/database.ts +19 -3
  155. package/src/subscriptions/types.ts +4 -1
  156. package/src/subscriptions/utils/polling.ts +6 -3
  157. package/src/types/env.ts +21 -1
  158. package/src/utils/rateLimit.ts +209 -0
  159. package/src/utils/responses.ts +10 -0
  160. package/src/ai/README.md +0 -257
@@ -0,0 +1,340 @@
1
+ import { isString } from "es-toolkit/compat";
2
+
3
+ import type { Configuration } from "../types/configuration";
4
+ import type { ProcedureResolver, TableResolver } from "../types/db";
5
+
6
+ import { FILTER_OPERATORS } from "../config/types/auth";
7
+ import { columnFieldName } from "../databases/transformers/graphqlName";
8
+
9
+ /**
10
+ * Everything the configuration is allowed to point at that only exists once the
11
+ * databases have been introspected.
12
+ */
13
+ export type KnownEntities = {
14
+ tables: TableResolver[];
15
+ storedProcedures: ProcedureResolver[];
16
+ /**
17
+ * Every table name a database reported, keyed by database name and recorded
18
+ * *before* exclusions are applied. `excludedTables` names are absent from
19
+ * `tables` by construction, so they cannot be resolved against it.
20
+ */
21
+ tableNamesByDatabase: Record<string, string[]>;
22
+ };
23
+
24
+ const OPERATORS = new Set<string>(FILTER_OPERATORS);
25
+
26
+ const isOperatorObject = (value: object): boolean =>
27
+ Object.keys(value).some((key) => OPERATORS.has(key));
28
+
29
+ const editDistance = (a: string, b: string): number => {
30
+ if (a === b) return 0;
31
+ if (!a.length) return b.length;
32
+ if (!b.length) return a.length;
33
+
34
+ let previous = Array.from({ length: b.length + 1 }, (_, i) => i);
35
+
36
+ for (let i = 1; i <= a.length; i++) {
37
+ const current = [i];
38
+
39
+ for (let j = 1; j <= b.length; j++) {
40
+ current[j] = Math.min(
41
+ previous[j]! + 1,
42
+ current[j - 1]! + 1,
43
+ previous[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1),
44
+ );
45
+ }
46
+
47
+ previous = current;
48
+ }
49
+
50
+ return previous[b.length]!;
51
+ };
52
+
53
+ /**
54
+ * Closest candidate, or nothing when none is close enough. The budget — half the
55
+ * misspelling's length, never more than three edits — is what keeps the hint
56
+ * honest: without it every unresolved name draws a confident suggestion picked
57
+ * from whatever happened to be shortest.
58
+ */
59
+ const nearest = (value: string, candidates: readonly string[]): string | undefined => {
60
+ const needle = value.toLowerCase();
61
+
62
+ let best: string | undefined;
63
+ let bestDistance = Number.POSITIVE_INFINITY;
64
+
65
+ for (const candidate of candidates) {
66
+ const distance = editDistance(needle, candidate.toLowerCase());
67
+
68
+ if (distance < bestDistance) {
69
+ bestDistance = distance;
70
+ best = candidate;
71
+ }
72
+ }
73
+
74
+ const budget = Math.min(3, Math.max(1, Math.floor(needle.length / 2)));
75
+
76
+ return best !== undefined && bestDistance <= budget ? best : undefined;
77
+ };
78
+
79
+ const segment = (key: string): string =>
80
+ /^[A-Za-z_][A-Za-z0-9_]*$/.test(key) ? `.${key}` : `[${JSON.stringify(key)}]`;
81
+
82
+ const unresolved = (
83
+ path: string,
84
+ kind: string,
85
+ value: string,
86
+ candidates: readonly string[],
87
+ ): string => {
88
+ const suggestion = nearest(value, candidates);
89
+
90
+ return `${path} — no ${kind} named ${JSON.stringify(value)}${
91
+ suggestion ? ` (did you mean ${JSON.stringify(suggestion)}?)` : ""
92
+ }`;
93
+ };
94
+
95
+ /**
96
+ * Column references are matched case-insensitively against both the GraphQL
97
+ * field name and the raw SQL name. That is looser than the query builder, which
98
+ * compares field names exactly — deliberately so. A boot-blocking check that is
99
+ * stricter than the runtime would refuse configurations that work today; one
100
+ * that is slightly looser still catches every genuine typo.
101
+ */
102
+ const columnNames = (table: TableResolver): string[] =>
103
+ table.columns.flatMap((column) => {
104
+ const field = columnFieldName(column);
105
+
106
+ return field === column.name ? [field] : [field, column.name];
107
+ });
108
+
109
+ const hasColumn = (table: TableResolver, name: string): boolean =>
110
+ columnNames(table).some((candidate) => candidate.toLowerCase() === name.toLowerCase());
111
+
112
+ /** Table names reachable from `table` through a relation, in either direction. */
113
+ const relatedTableNames = (table: TableResolver): string[] => [
114
+ ...table.relationships.map((fk) => fk.toResolverName),
115
+ ...table.relationshipsReversed.map((fk) => fk.fromResolverName),
116
+ ];
117
+
118
+ const collectListErrors = (
119
+ path: string,
120
+ kind: string,
121
+ permission: "ALL" | string[] | undefined,
122
+ candidates: readonly string[],
123
+ ): string[] => {
124
+ if (!Array.isArray(permission)) return [];
125
+
126
+ const known = new Set(candidates.map((name) => name.toLowerCase()));
127
+
128
+ return permission
129
+ .filter((name) => !known.has(name.toLowerCase()))
130
+ .map((name) => unresolved(`${path}[${JSON.stringify(name)}]`, kind, name, candidates));
131
+ };
132
+
133
+ /**
134
+ * Every name a configuration points at that resolves to nothing, with the config
135
+ * path that carries it. Returns them all rather than throwing on the first, so a
136
+ * misconfigured deployment is fixed in one pass.
137
+ *
138
+ * A permission naming something that does not exist fails closed — the role
139
+ * simply loses the entity — which is why these never surfaced on their own: the
140
+ * rule the operator wrote silently does not apply.
141
+ */
142
+ export const collectCrossReferenceErrors = (
143
+ configuration: Configuration,
144
+ known: KnownEntities,
145
+ ): string[] => {
146
+ const errors: string[] = [];
147
+
148
+ const tableNames = known.tables.map((table) => table.resolverName);
149
+ const tablesByName = new Map(
150
+ known.tables.map((table) => [table.resolverName.toLowerCase(), table]),
151
+ );
152
+ const procedureNames = known.storedProcedures.map((procedure) => procedure.name);
153
+ const queueNames = configuration.queues.map((queue) => queue.name);
154
+ const operationNames = Object.keys(configuration.operations);
155
+ const remoteSchemaNames = (configuration.remoteSchemas ?? []).map((remote) => remote.name);
156
+ const remoteRESTNames = (configuration.remoteREST ?? []).map((remote) => remote.name);
157
+
158
+ const collectFilterErrors = (
159
+ filter: object,
160
+ table: TableResolver,
161
+ path: string,
162
+ visited: ReadonlySet<string>,
163
+ ): void => {
164
+ for (const [key, value] of Object.entries(filter) as [string, unknown][]) {
165
+ const keyPath = `${path}${segment(key)}`;
166
+
167
+ // Mirrors buildConditions: an object carrying a known operator is a column
168
+ // filter, anything else is a nested relation.
169
+ if (typeof value !== "object" || value === null || isOperatorObject(value)) {
170
+ if (!hasColumn(table, key)) {
171
+ errors.push(
172
+ unresolved(keyPath, `column on table ${JSON.stringify(table.resolverName)}`, key, [
173
+ ...columnNames(table),
174
+ ...relatedTableNames(table),
175
+ ]),
176
+ );
177
+ }
178
+
179
+ continue;
180
+ }
181
+
182
+ const related = relatedTableNames(table);
183
+ const nested = related.includes(key) ? tablesByName.get(key.toLowerCase()) : undefined;
184
+
185
+ if (!nested) {
186
+ errors.push(
187
+ unresolved(
188
+ keyPath,
189
+ `column or relation on table ${JSON.stringify(table.resolverName)}`,
190
+ key,
191
+ [...columnNames(table), ...related],
192
+ ),
193
+ );
194
+
195
+ continue;
196
+ }
197
+
198
+ // A filter that walks back into a table already on the path would recurse
199
+ // forever; the runtime would too, so stop rather than report it here.
200
+ if (visited.has(nested.resolverName)) continue;
201
+
202
+ collectFilterErrors(value, nested, keyPath, new Set([...visited, nested.resolverName]));
203
+ }
204
+ };
205
+
206
+ for (const [role, permissions] of Object.entries(configuration.auth.permissions ?? {})) {
207
+ const base = `auth.permissions${segment(role)}`;
208
+
209
+ errors.push(
210
+ ...collectListErrors(
211
+ `${base}.storedProcedures`,
212
+ "stored procedure",
213
+ permissions.storedProcedures,
214
+ procedureNames,
215
+ ),
216
+ ...collectListErrors(`${base}.queues`, "queue", permissions.queues, queueNames),
217
+ ...collectListErrors(
218
+ `${base}.operations`,
219
+ "operation",
220
+ permissions.operations,
221
+ operationNames,
222
+ ),
223
+ ...collectListErrors(
224
+ `${base}.remoteSchemas`,
225
+ "remote schema",
226
+ permissions.remoteSchemas,
227
+ remoteSchemaNames,
228
+ ),
229
+ ...collectListErrors(
230
+ `${base}.remoteREST`,
231
+ "remote REST API",
232
+ permissions.remoteREST,
233
+ remoteRESTNames,
234
+ ),
235
+ );
236
+
237
+ if (isString(permissions.tables)) continue;
238
+
239
+ for (const [tableKey, tablePermission] of Object.entries(permissions.tables)) {
240
+ const tablePath = `${base}.tables${segment(tableKey)}`;
241
+ const table = tablesByName.get(tableKey.toLowerCase());
242
+
243
+ if (!table) {
244
+ errors.push(unresolved(tablePath, "table or view", tableKey, tableNames));
245
+
246
+ continue;
247
+ }
248
+
249
+ if (!isString(tablePermission.columns)) {
250
+ for (const column of tablePermission.columns) {
251
+ if (!hasColumn(table, column)) {
252
+ errors.push(
253
+ unresolved(
254
+ `${tablePath}.columns[${JSON.stringify(column)}]`,
255
+ "column",
256
+ column,
257
+ columnNames(table),
258
+ ),
259
+ );
260
+ }
261
+ }
262
+ }
263
+
264
+ for (const [index, orderBy] of (tablePermission.orderBy ?? []).entries()) {
265
+ if (!hasColumn(table, orderBy.column)) {
266
+ errors.push(
267
+ unresolved(
268
+ `${tablePath}.orderBy[${index}].column`,
269
+ "column",
270
+ orderBy.column,
271
+ columnNames(table),
272
+ ),
273
+ );
274
+ }
275
+ }
276
+
277
+ if (tablePermission.filter) {
278
+ collectFilterErrors(
279
+ tablePermission.filter,
280
+ table,
281
+ `${tablePath}.filter`,
282
+ new Set([table.resolverName]),
283
+ );
284
+ }
285
+ }
286
+ }
287
+
288
+ for (const [index, queue] of configuration.queues.entries()) {
289
+ const topicNames = Object.keys(queue.topics);
290
+ const base = `queues[${index}]`;
291
+
292
+ for (const [group, entries] of [
293
+ ["publishers", queue.publishers],
294
+ ["subscribers", queue.subscribers],
295
+ ] as const) {
296
+ for (const [key, entry] of Object.entries(entries)) {
297
+ if (!topicNames.includes(entry.topic)) {
298
+ errors.push(
299
+ unresolved(`${base}.${group}${segment(key)}.topic`, "topic", entry.topic, topicNames),
300
+ );
301
+ }
302
+ }
303
+ }
304
+ }
305
+
306
+ for (const [index, database] of configuration.enabledDatabases.entries()) {
307
+ if (!database.schema) continue;
308
+
309
+ const introspected = known.tableNamesByDatabase[database.name] ?? [];
310
+ const base = `databases[${index}].schema`;
311
+
312
+ errors.push(
313
+ ...collectListErrors(
314
+ `${base}.excludedTables`,
315
+ "table or view",
316
+ database.schema.excludedTables,
317
+ introspected,
318
+ ),
319
+ );
320
+
321
+ // Exact, unlike every other name here, because the override lookup itself is
322
+ // exact — two tables can differ only in case, and picking one of them for a
323
+ // key that matches both would be a guess. A mis-cased key is reported rather
324
+ // than quietly resolved.
325
+ for (const tableKey of Object.keys(database.schema.database)) {
326
+ if (!introspected.includes(tableKey)) {
327
+ errors.push(
328
+ unresolved(
329
+ `${base}.database${segment(tableKey)}`,
330
+ "table or view",
331
+ tableKey,
332
+ introspected,
333
+ ),
334
+ );
335
+ }
336
+ }
337
+ }
338
+
339
+ return errors;
340
+ };
@@ -103,7 +103,7 @@ export const generateTableType = (mergedEntities: MergedEntities) =>
103
103
  lj(
104
104
  relationshipsReversed,
105
105
  ({ fromResolverName, fromInternalName }) =>
106
- `${fromResolverName}(where: ${fromInternalName}WhereInput, orderBy: [${fromInternalName}OrderByInput]): [${fromInternalName}]`,
106
+ `${fromResolverName}(where: ${fromInternalName}WhereInput, orderBy: [${fromInternalName}OrderByInput], limit: Int, offset: Int): [${fromInternalName}]`,
107
107
  ),
108
108
  ])}
109
109
  }
@@ -36,7 +36,7 @@ export const buildExecute = (roles: RolesWithGqlHandler, defaultRole: string) =>
36
36
  if (gql.isIntrospectionQuery(query)) return gql.introspectionResult;
37
37
  if (gql.isNoDataQuery(query)) return gql.noDataResult;
38
38
 
39
- const { hasErrors, validationErrors } = gql.hasErrors(query);
39
+ const { hasErrors, validationErrors } = gql.hasErrors(query, { variables });
40
40
 
41
41
  if (hasErrors) {
42
42
  return {
@@ -11,6 +11,7 @@ import type { Auth } from "../../types/configuration";
11
11
  import type { SessionContext } from "../../utils/sessionVariables";
12
12
 
13
13
  import { analyzeQuery } from "../../analyzeQuery";
14
+ import { checkQueryCost } from "../../analyzeQuery/costLimit";
14
15
  import { depthLimitRule } from "../../analyzeQuery/depthLimit";
15
16
  import { resolveVariableRef, resolveVariables } from "../../analyzeQuery/resolveVariables";
16
17
  import { callStoredProcedure, executeQueryJSON, generateSQL } from "../../databases";
@@ -82,7 +83,7 @@ export const handleGraphQLRequestFactory = (
82
83
  const result = await operation.handler(
83
84
  {
84
85
  databases: databasesConnections,
85
- gqlQuery: gql.handler,
86
+ gqlQuery: operatorQuery,
86
87
  queues: queueManager,
87
88
  repository: repositoryMap,
88
89
  },
@@ -162,6 +163,8 @@ export const handleGraphQLRequestFactory = (
162
163
 
163
164
  const queryCache = new LRUCache<string, CachedQuery>({ max: 1000 });
164
165
 
166
+ const pageLimits = { defaultPageSize: env.defaultPageSize, maxPageSize: env.maxPageSize };
167
+
165
168
  // undefined = unparseable query (never cached)
166
169
  const getCacheEntry = (query: string): CachedQuery | undefined => {
167
170
  const hit = queryCache.get(query);
@@ -207,20 +210,54 @@ export const handleGraphQLRequestFactory = (
207
210
  // Return the introspection result for clients like GraphiQL or Apollo Client
208
211
  introspectionResult: { data: gqlEntities.introspection },
209
212
  noDataResult: { data: { _no_data: "No data available" } },
210
- hasErrors: (query: string) => {
213
+ // `enforceDepthLimit: false` and `enforceCostLimit: false` are for
214
+ // operator-authored queries (REST operations), which are config, not
215
+ // attacker input. The depth verdict is never cached under them: the cache is
216
+ // keyed on query text alone, so storing a depth-free verdict would serve a
217
+ // caller sending the same text over the wire.
218
+ //
219
+ // Two flags rather than one because they are exempted for the same reason
220
+ // but enforced in different places — the depth rule inside the memoised
221
+ // `validate`, the cost check after it. Overloading one flag would leave the
222
+ // REST call sites reading as if they only exempted depth.
223
+ hasErrors: (
224
+ query: string,
225
+ options?: {
226
+ enforceDepthLimit?: boolean;
227
+ enforceCostLimit?: boolean;
228
+ variables?: Record<string, unknown>;
229
+ },
230
+ ) => {
231
+ const enforceDepthLimit = options?.enforceDepthLimit ?? true;
232
+ const enforceCostLimit = options?.enforceCostLimit ?? true;
211
233
  const entry = getCacheEntry(query);
212
234
  // Unparseable queries aren't cached; let parse surface the syntax error
213
235
  const document = entry?.document ?? parse(query);
214
236
 
215
- let validationErrors = entry?.validationErrors;
237
+ let validationErrors = enforceDepthLimit ? entry?.validationErrors : undefined;
216
238
  if (!validationErrors) {
217
- const rules =
218
- env.maxQueryDepth > 0
219
- ? [...specifiedRules, depthLimitRule(env.maxQueryDepth)]
220
- : undefined; // undefined = use default specifiedRules
239
+ const maxDepth = enforceDepthLimit ? env.maxQueryDepth : 0;
240
+ const rules = maxDepth > 0 ? [...specifiedRules, depthLimitRule(maxDepth)] : undefined; // undefined = use default specifiedRules
221
241
 
222
242
  validationErrors = validate(gqlEntities.schema, document, rules);
223
- if (entry) entry.validationErrors = validationErrors;
243
+ if (entry && enforceDepthLimit) entry.validationErrors = validationErrors;
244
+ }
245
+
246
+ // Outside the block above, and never cached: the estimate depends on the
247
+ // request's variable values, so the same query text has different verdicts
248
+ // for different callers. Only for a query that already typechecks — an
249
+ // estimate drawn from a document that does not resolve against the schema
250
+ // would bury the error that actually explains the rejection.
251
+ if (enforceCostLimit && env.maxQueryCost > 0 && validationErrors.length === 0) {
252
+ const costError = checkQueryCost(
253
+ document,
254
+ gqlEntities.schema,
255
+ options?.variables ?? {},
256
+ pageLimits,
257
+ env.maxQueryCost,
258
+ );
259
+
260
+ if (costError) validationErrors = [costError];
224
261
  }
225
262
 
226
263
  return {
@@ -234,6 +271,18 @@ export const handleGraphQLRequestFactory = (
234
271
  variables: Record<string, unknown> = {},
235
272
  req?: BunRequest,
236
273
  session?: SessionContext,
274
+ // `enforcePageLimits: false` is for operator-authored queries (REST
275
+ // operations, cron jobs, the `gqlQuery` handed to operation hooks). They
276
+ // are configuration, not caller input, so a page cap could only reject
277
+ // the operator's own intent. Use `gql.operatorQuery` rather than passing
278
+ // this by hand.
279
+ //
280
+ // `timeoutMs` is the per-operation override only. Left undefined, each
281
+ // engine stays on its own default — the pool's on Postgres and SQL
282
+ // Server, QUERY_TIMEOUT_MS on MySQL, which has no pool-level route — so
283
+ // an ordinary caller query is bounded without the factory resolving
284
+ // anything.
285
+ options?: { enforcePageLimits?: boolean; timeoutMs?: number },
237
286
  // oxlint-disable-next-line typescript/no-explicit-any
238
287
  ): Promise<{ data: any }> => {
239
288
  const log = logger("graphql").child({ role: session?.role });
@@ -339,7 +388,14 @@ export const handleGraphQLRequestFactory = (
339
388
  ],
340
389
  };
341
390
 
342
- const sqlQueries = generateSQL(entities, tableQueryAnalysis, resolved.allVariables);
391
+ const sqlQueries = generateSQL(
392
+ entities,
393
+ tableQueryAnalysis,
394
+ resolved.allVariables,
395
+ false,
396
+ (options?.enforcePageLimits ?? true) ? pageLimits : null,
397
+ options?.timeoutMs,
398
+ );
343
399
 
344
400
  const data = await Promise.all<object>(
345
401
  sqlQueries.map(([db, query]) =>
@@ -348,6 +404,7 @@ export const handleGraphQLRequestFactory = (
348
404
  db,
349
405
  resolved.variables,
350
406
  resolved.allVariables as Record<string, string | number | boolean | null>,
407
+ options?.timeoutMs,
351
408
  ),
352
409
  ),
353
410
  );
@@ -409,7 +466,20 @@ export const handleGraphQLRequestFactory = (
409
466
  },
410
467
  };
411
468
 
412
- return gql;
469
+ /**
470
+ * `handler` for operator-authored queries — REST operations, cron jobs, and
471
+ * the `gqlQuery` handed to operation hooks and handlers. Their text comes from
472
+ * configuration, never from a caller, so page limits do not apply.
473
+ */
474
+ const operatorQuery = (
475
+ query: Parameters<typeof gql.handler>[0],
476
+ variables?: Parameters<typeof gql.handler>[1],
477
+ req?: Parameters<typeof gql.handler>[2],
478
+ session?: Parameters<typeof gql.handler>[3],
479
+ timeoutMs?: number,
480
+ ) => gql.handler(query, variables, req, session, { enforcePageLimits: false, timeoutMs });
481
+
482
+ return { ...gql, operatorQuery };
413
483
  };
414
484
 
415
485
  export type HandleGraphQLRequest = ReturnType<typeof handleGraphQLRequestFactory>;
@@ -1,4 +1,5 @@
1
1
  import type { ServerWebSocket, WebSocketHandler } from "bun";
2
+ import type { AnalysisResult } from "../../analyzeQuery/types";
2
3
  import type { AnalyzedConfiguration } from "../../configuration";
3
4
  import type { SubscriptionContext } from "../../subscriptions";
4
5
  import type { SessionContext } from "../../utils/sessionVariables";
@@ -96,7 +97,9 @@ const handleGraphQLSubscriptionFactory = (
96
97
  const schemaEntity = roles[session.role!];
97
98
 
98
99
  // Validate query
99
- const { hasErrors, validationErrors } = schemaEntity.handlers.gql.hasErrors(query);
100
+ const { hasErrors, validationErrors } = schemaEntity.handlers.gql.hasErrors(query, {
101
+ variables,
102
+ });
100
103
 
101
104
  if (hasErrors) {
102
105
  queryEventEmitter.sendErrorToSingleClient(ws, id, validationErrors);
@@ -146,15 +149,32 @@ const handleGraphQLSubscriptionFactory = (
146
149
  return;
147
150
  }
148
151
 
152
+ // The SQL builders read `$n` from a definition's index in the
153
+ // operation's own variable list, so the analysis handed to them has to
154
+ // be the resolved one — the raw analysis knows nothing of the statics
155
+ // that binding a role filter mints. Same construction as the HTTP path.
156
+ const resolvedAnalysis: AnalysisResult = {
157
+ ...analysis,
158
+ operations: [
159
+ {
160
+ ...operation,
161
+ fields: resolved.fields,
162
+ variables: resolved.variables,
163
+ },
164
+ ...analysis.operations.slice(1),
165
+ ],
166
+ };
167
+
149
168
  // Build context for the strategy
150
169
  const context: SubscriptionContext = {
151
170
  ws,
152
171
  subscriptionId: id,
153
- analysis,
172
+ analysis: resolvedAnalysis,
154
173
  field: resolved.fields[0],
155
174
  variableDefinitions: resolved.variables,
156
175
  variables: resolved.allVariables,
157
176
  schemaEntity,
177
+ session,
158
178
  eventEmitter: queryEventEmitter,
159
179
  };
160
180
 
@@ -19,6 +19,7 @@ import {
19
19
  virtualColumnExpression,
20
20
  virtualColumnFunction,
21
21
  } from "../types/configuration";
22
+ import { collectCrossReferenceErrors } from "./crossReferences";
22
23
  import { getSchema, getSchemas } from "./getSchemas";
23
24
  import { generateOpenAPI } from "./rest/generateOpenAPI";
24
25
 
@@ -60,12 +61,29 @@ export const analyzeConfiguration = async (configuration: Configuration, options
60
61
  // Use pre-calculated enabledDatabases from configuration parsing
61
62
  const enabledDatabases = configuration.enabledDatabases;
62
63
 
63
- const [{ tables, storedProcedures }, resolvedRemoteSchemas, resolvedRemoteREST] =
64
- await Promise.all([
65
- getDatabasesStructure(enabledDatabases, configuration.auth),
66
- resolveRemoteSchemas(configuration.remoteSchemas ?? []),
67
- resolveRemoteRESTApis(configuration.remoteREST ?? []),
68
- ]);
64
+ const [
65
+ { tables, storedProcedures, tableNamesByDatabase },
66
+ resolvedRemoteSchemas,
67
+ resolvedRemoteREST,
68
+ ] = await Promise.all([
69
+ getDatabasesStructure(enabledDatabases, configuration.auth),
70
+ resolveRemoteSchemas(configuration.remoteSchemas ?? []),
71
+ resolveRemoteRESTApis(configuration.remoteREST ?? []),
72
+ ]);
73
+
74
+ const crossReferenceErrors = collectCrossReferenceErrors(configuration, {
75
+ tables,
76
+ storedProcedures,
77
+ tableNamesByDatabase,
78
+ });
79
+
80
+ if (crossReferenceErrors.length) {
81
+ throw new Error(
82
+ `Configuration references ${crossReferenceErrors.length} name${
83
+ crossReferenceErrors.length === 1 ? "" : "s"
84
+ } that do not exist:\n${crossReferenceErrors.map((error) => ` - ${error}`).join("\n")}`,
85
+ );
86
+ }
69
87
 
70
88
  const operationsWithRestEndpoints = Object.fromEntries(
71
89
  Object.entries(configuration.operations).filter(([, operationConfig]) => operationConfig.rest),
@@ -108,7 +108,7 @@ export const handleRESTRequestFactory = (
108
108
 
109
109
  if (routesInitDataPromises[route.routeKey] === undefined) {
110
110
  routesInitDataPromises[route.routeKey] = await route.hooks?.init?.({
111
- gqlQuery: gqlSuperadminHandler?.handler ?? gql.handler,
111
+ gqlQuery: gqlSuperadminHandler?.operatorQuery ?? gql.operatorQuery,
112
112
  databases: databasesConnections,
113
113
  queues: queueManager,
114
114
  repository: repositoryMap,
@@ -269,7 +269,7 @@ export const handleRESTRequestFactory = (
269
269
  try {
270
270
  const result = await route.handler(
271
271
  {
272
- gqlQuery: gqlSuperadminHandler?.handler ?? gql.handler,
272
+ gqlQuery: gqlSuperadminHandler?.operatorQuery ?? gql.operatorQuery,
273
273
  databases: databasesConnections,
274
274
  queues: queueManager,
275
275
  repository: repositoryMap,
@@ -330,7 +330,7 @@ export const handleRESTRequestFactory = (
330
330
  // Execute the GraphQL request, then transform via afterRequest before
331
331
  // caching so hits and misses return the same shape.
332
332
  const result = await applyAfterRequest(
333
- await gql.handler(queryAnalysis!, variables, req, session),
333
+ await gql.operatorQuery(queryAnalysis!, variables, req, session, route.timeout),
334
334
  );
335
335
 
336
336
  // Cache the (already transformed) result
@@ -344,7 +344,9 @@ export const handleRESTRequestFactory = (
344
344
  } else if (queryAnalysis) {
345
345
  // No caching for this route, execute normally
346
346
  return new S200(
347
- await applyAfterRequest(await gql.handler(queryAnalysis, variables, req, session)),
347
+ await applyAfterRequest(
348
+ await gql.operatorQuery(queryAnalysis, variables, req, session, route.timeout),
349
+ ),
348
350
  );
349
351
  }
350
352
 
@@ -42,11 +42,20 @@ export const buildApiRoutes = (
42
42
  let hasError = false;
43
43
 
44
44
  if (routeConfig.query) {
45
- const validationErrors = gql.hasErrors(routeConfig.query);
45
+ // Operation queries come from the configuration, not from a caller, so the
46
+ // depth and cost limits do not apply to them — they would turn an
47
+ // operator's own deep or wide query into a dead route.
48
+ const validationErrors = gql.hasErrors(routeConfig.query, {
49
+ enforceDepthLimit: false,
50
+ enforceCostLimit: false,
51
+ });
46
52
 
47
53
  if (validationErrors.hasErrors && gqlSuperadminHandler) {
48
54
  // Re-validate with superadmin handler if available
49
- const superadminErrors = gqlSuperadminHandler.hasErrors(routeConfig.query);
55
+ const superadminErrors = gqlSuperadminHandler.hasErrors(routeConfig.query, {
56
+ enforceDepthLimit: false,
57
+ enforceCostLimit: false,
58
+ });
50
59
 
51
60
  if (superadminErrors.hasErrors) {
52
61
  throw new Error(`Endpoint ${routeKey} has invalid query.`);