@graphoria/server 0.3.0 → 0.5.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 (203) hide show
  1. package/README.md +28 -20
  2. package/dist/src/ai/mcp/index.d.ts +9 -2
  3. package/dist/src/ai/mcp/index.d.ts.map +1 -1
  4. package/dist/src/analyzeQuery/analyzers/selectionAnalyzer.d.ts.map +1 -1
  5. package/dist/src/analyzeQuery/depthLimit.d.ts +1 -13
  6. package/dist/src/analyzeQuery/depthLimit.d.ts.map +1 -1
  7. package/dist/src/authentication/capabilities.d.ts +18 -0
  8. package/dist/src/authentication/capabilities.d.ts.map +1 -0
  9. package/dist/src/authentication/capabilities.test.d.ts +2 -0
  10. package/dist/src/authentication/capabilities.test.d.ts.map +1 -0
  11. package/dist/src/authentication/jwt.d.ts.map +1 -1
  12. package/dist/src/authentication/paseto.d.ts.map +1 -1
  13. package/dist/src/authentication/secrets.d.ts +9 -0
  14. package/dist/src/authentication/secrets.d.ts.map +1 -0
  15. package/dist/src/authentication/secrets.test.d.ts +2 -0
  16. package/dist/src/authentication/secrets.test.d.ts.map +1 -0
  17. package/dist/src/authentication/tokenRepository.d.ts.map +1 -1
  18. package/dist/src/config/types/db.d.ts +25 -0
  19. package/dist/src/config/types/db.d.ts.map +1 -1
  20. package/dist/src/configuration/getSchemas/index.d.ts +40 -2
  21. package/dist/src/configuration/getSchemas/index.d.ts.map +1 -1
  22. package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts +20 -1
  23. package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts.map +1 -1
  24. package/dist/src/configuration/gql/gqlAuthOperations.d.ts.map +1 -1
  25. package/dist/src/configuration/gql/handleGraphQLRequestFactory.d.ts.map +1 -1
  26. package/dist/src/configuration/gql/handleGraphQLSubscriptionFactory.d.ts.map +1 -1
  27. package/dist/src/configuration/index.d.ts +30 -1
  28. package/dist/src/configuration/index.d.ts.map +1 -1
  29. package/dist/src/configuration/rest/generateOpenAPI.d.ts.map +1 -1
  30. package/dist/src/configuration/rest/generateOpenAPI.test.d.ts +2 -0
  31. package/dist/src/configuration/rest/generateOpenAPI.test.d.ts.map +1 -0
  32. package/dist/src/configuration/rest/handleRESTRequestFactory.d.ts.map +1 -1
  33. package/dist/src/console/api.d.ts.map +1 -1
  34. package/dist/src/console/session.d.ts +12 -4
  35. package/dist/src/console/session.d.ts.map +1 -1
  36. package/dist/src/cron/index.d.ts.map +1 -1
  37. package/dist/src/databases/common.d.ts +20 -6
  38. package/dist/src/databases/common.d.ts.map +1 -1
  39. package/dist/src/databases/core/executor.d.ts +4 -6
  40. package/dist/src/databases/core/executor.d.ts.map +1 -1
  41. package/dist/src/databases/core/function-mapping.d.ts +2 -3
  42. package/dist/src/databases/core/function-mapping.d.ts.map +1 -1
  43. package/dist/src/databases/core/procedure-arguments.d.ts +17 -0
  44. package/dist/src/databases/core/procedure-arguments.d.ts.map +1 -0
  45. package/dist/src/databases/core/procedure-arguments.test.d.ts +2 -0
  46. package/dist/src/databases/core/procedure-arguments.test.d.ts.map +1 -0
  47. package/dist/src/databases/core/query-builder.d.ts +5 -0
  48. package/dist/src/databases/core/query-builder.d.ts.map +1 -1
  49. package/dist/src/databases/engines/mssql/connection.d.ts +1 -1
  50. package/dist/src/databases/engines/mssql/connection.d.ts.map +1 -1
  51. package/dist/src/databases/engines/mssql/getStructure.d.ts +5 -0
  52. package/dist/src/databases/engines/mssql/getStructure.d.ts.map +1 -1
  53. package/dist/src/databases/engines/mssql/query/index.d.ts +2 -2
  54. package/dist/src/databases/engines/mssql/query/index.d.ts.map +1 -1
  55. package/dist/src/databases/engines/mysql/connection.d.ts +1 -1
  56. package/dist/src/databases/engines/mysql/connection.d.ts.map +1 -1
  57. package/dist/src/databases/engines/mysql/getStructure.d.ts +5 -0
  58. package/dist/src/databases/engines/mysql/getStructure.d.ts.map +1 -1
  59. package/dist/src/databases/engines/mysql/query/index.d.ts +2 -2
  60. package/dist/src/databases/engines/mysql/query/index.d.ts.map +1 -1
  61. package/dist/src/databases/engines/postgresql/connection.d.ts +1 -1
  62. package/dist/src/databases/engines/postgresql/connection.d.ts.map +1 -1
  63. package/dist/src/databases/engines/postgresql/getStructure.d.ts +5 -0
  64. package/dist/src/databases/engines/postgresql/getStructure.d.ts.map +1 -1
  65. package/dist/src/databases/engines/postgresql/query/index.d.ts +2 -2
  66. package/dist/src/databases/engines/postgresql/query/index.d.ts.map +1 -1
  67. package/dist/src/databases/high-level-operations.d.ts +5 -0
  68. package/dist/src/databases/high-level-operations.d.ts.map +1 -1
  69. package/dist/src/databases/metadata/structure.d.ts +5 -0
  70. package/dist/src/databases/metadata/structure.d.ts.map +1 -1
  71. package/dist/src/databases/transformers/data-transformers.d.ts +30 -0
  72. package/dist/src/databases/transformers/data-transformers.d.ts.map +1 -1
  73. package/dist/src/index.d.ts +4 -0
  74. package/dist/src/index.d.ts.map +1 -1
  75. package/dist/src/logging/audit.d.ts +27 -0
  76. package/dist/src/logging/audit.d.ts.map +1 -0
  77. package/dist/src/logging/audit.test.d.ts +2 -0
  78. package/dist/src/logging/audit.test.d.ts.map +1 -0
  79. package/dist/src/logging/slowQuery.d.ts +29 -0
  80. package/dist/src/logging/slowQuery.d.ts.map +1 -0
  81. package/dist/src/logging/slowQuery.test.d.ts +2 -0
  82. package/dist/src/logging/slowQuery.test.d.ts.map +1 -0
  83. package/dist/src/observability/health.d.ts +23 -0
  84. package/dist/src/observability/health.d.ts.map +1 -0
  85. package/dist/src/observability/health.test.d.ts +2 -0
  86. package/dist/src/observability/health.test.d.ts.map +1 -0
  87. package/dist/src/observability/httpMetrics.d.ts +7 -0
  88. package/dist/src/observability/httpMetrics.d.ts.map +1 -0
  89. package/dist/src/observability/httpMetrics.test.d.ts +2 -0
  90. package/dist/src/observability/httpMetrics.test.d.ts.map +1 -0
  91. package/dist/src/observability/httpTracing.d.ts +11 -0
  92. package/dist/src/observability/httpTracing.d.ts.map +1 -0
  93. package/dist/src/observability/httpTracing.test.d.ts +2 -0
  94. package/dist/src/observability/httpTracing.test.d.ts.map +1 -0
  95. package/dist/src/observability/metrics.d.ts +81 -0
  96. package/dist/src/observability/metrics.d.ts.map +1 -0
  97. package/dist/src/observability/metrics.perf.test.d.ts +2 -0
  98. package/dist/src/observability/metrics.perf.test.d.ts.map +1 -0
  99. package/dist/src/observability/metrics.test.d.ts +2 -0
  100. package/dist/src/observability/metrics.test.d.ts.map +1 -0
  101. package/dist/src/observability/metricsRoute.d.ts +19 -0
  102. package/dist/src/observability/metricsRoute.d.ts.map +1 -0
  103. package/dist/src/observability/metricsRoute.test.d.ts +2 -0
  104. package/dist/src/observability/metricsRoute.test.d.ts.map +1 -0
  105. package/dist/src/observability/tracing.d.ts +92 -0
  106. package/dist/src/observability/tracing.d.ts.map +1 -0
  107. package/dist/src/observability/tracing.perf.test.d.ts +2 -0
  108. package/dist/src/observability/tracing.perf.test.d.ts.map +1 -0
  109. package/dist/src/observability/tracing.test.d.ts +2 -0
  110. package/dist/src/observability/tracing.test.d.ts.map +1 -0
  111. package/dist/src/queues/kafka.d.ts +6 -0
  112. package/dist/src/queues/kafka.d.ts.map +1 -1
  113. package/dist/src/queues/kafka.test.d.ts +2 -0
  114. package/dist/src/queues/kafka.test.d.ts.map +1 -0
  115. package/dist/src/queues/rabbitmq.d.ts.map +1 -1
  116. package/dist/src/remoteREST/proxy.d.ts.map +1 -1
  117. package/dist/src/remoteSchemas/proxy.d.ts.map +1 -1
  118. package/dist/src/singletons/cache/redisClient.d.ts +2 -2
  119. package/dist/src/singletons/cache/redisClient.d.ts.map +1 -1
  120. package/dist/src/singletons/databases.d.ts +1 -0
  121. package/dist/src/singletons/databases.d.ts.map +1 -1
  122. package/dist/src/singletons/env.d.ts +37 -4
  123. package/dist/src/singletons/env.d.ts.map +1 -1
  124. package/dist/src/singletons/queues.d.ts +1 -0
  125. package/dist/src/singletons/queues.d.ts.map +1 -1
  126. package/dist/src/subscriptions/strategies/database.d.ts.map +1 -1
  127. package/dist/src/subscriptions/utils/polling.d.ts +1 -0
  128. package/dist/src/subscriptions/utils/polling.d.ts.map +1 -1
  129. package/dist/src/subscriptions/utils/polling.test.d.ts +2 -0
  130. package/dist/src/subscriptions/utils/polling.test.d.ts.map +1 -0
  131. package/dist/src/types/env.d.ts +113 -12
  132. package/dist/src/types/env.d.ts.map +1 -1
  133. package/dist/src/types/zod/configuration.d.ts +25 -0
  134. package/dist/src/types/zod/configuration.d.ts.map +1 -1
  135. package/dist/src/types/zod/db.d.ts +85 -0
  136. package/dist/src/types/zod/db.d.ts.map +1 -1
  137. package/dist/src/utils/rateLimit.d.ts.map +1 -1
  138. package/dist/src/utils/redis.d.ts +17 -0
  139. package/dist/src/utils/redis.d.ts.map +1 -0
  140. package/dist/src/utils/redis.test.d.ts +2 -0
  141. package/dist/src/utils/redis.test.d.ts.map +1 -0
  142. package/dist/src/utils/responses.d.ts +6 -0
  143. package/dist/src/utils/responses.d.ts.map +1 -1
  144. package/dist/src/utils/sessionVariables.d.ts +2 -0
  145. package/dist/src/utils/sessionVariables.d.ts.map +1 -1
  146. package/dist/tsconfig.tsbuildinfo +1 -1
  147. package/package.json +8 -5
  148. package/playgrounds/console/index.html +9 -9
  149. package/playgrounds/graphiql/index.html +549 -686
  150. package/playgrounds/scalar/index.html +1 -1
  151. package/src/ai/mcp/index.ts +19 -14
  152. package/src/analyzeQuery/analyzers/selectionAnalyzer.ts +2 -1
  153. package/src/analyzeQuery/depthLimit.ts +15 -5
  154. package/src/authentication/capabilities.ts +56 -0
  155. package/src/authentication/index.ts +3 -3
  156. package/src/authentication/jwt.ts +30 -25
  157. package/src/authentication/paseto.ts +36 -26
  158. package/src/authentication/secrets.ts +30 -0
  159. package/src/authentication/tokenRepository.ts +2 -3
  160. package/src/config/types/db.ts +16 -2
  161. package/src/configuration/getSchemas/mergeEntities/index.ts +0 -26
  162. package/src/configuration/gql/gqlAuthOperations.ts +26 -0
  163. package/src/configuration/gql/handleGraphQLRequestFactory.ts +241 -148
  164. package/src/configuration/gql/handleGraphQLSubscriptionFactory.ts +9 -0
  165. package/src/configuration/rest/generateOpenAPI.ts +61 -11
  166. package/src/configuration/rest/handleRESTRequestFactory.ts +26 -0
  167. package/src/console/api.ts +64 -24
  168. package/src/console/session.ts +30 -26
  169. package/src/cron/index.ts +59 -36
  170. package/src/databases/common.ts +109 -10
  171. package/src/databases/core/executor.ts +97 -20
  172. package/src/databases/core/function-mapping.ts +1 -2
  173. package/src/databases/core/procedure-arguments.ts +25 -0
  174. package/src/databases/engines/mssql/connection.ts +0 -3
  175. package/src/databases/engines/mssql/query/index.ts +4 -1
  176. package/src/databases/engines/mysql/connection.ts +10 -7
  177. package/src/databases/engines/mysql/query/index.ts +5 -2
  178. package/src/databases/engines/postgresql/connection.ts +10 -7
  179. package/src/databases/engines/postgresql/query/index.ts +5 -2
  180. package/src/databases/high-level-operations.ts +44 -2
  181. package/src/index.ts +200 -76
  182. package/src/logging/audit.ts +86 -0
  183. package/src/logging/slowQuery.ts +59 -0
  184. package/src/observability/health.ts +67 -0
  185. package/src/observability/httpMetrics.ts +37 -0
  186. package/src/observability/httpTracing.ts +48 -0
  187. package/src/observability/metrics.ts +243 -0
  188. package/src/observability/metricsRoute.ts +33 -0
  189. package/src/observability/tracing.ts +421 -0
  190. package/src/queues/kafka.ts +45 -2
  191. package/src/queues/rabbitmq.ts +37 -1
  192. package/src/remoteREST/proxy.ts +17 -0
  193. package/src/remoteSchemas/proxy.ts +17 -0
  194. package/src/singletons/cache/redisClient.ts +4 -5
  195. package/src/singletons/databases.ts +5 -0
  196. package/src/singletons/queues.ts +9 -3
  197. package/src/subscriptions/strategies/database.ts +2 -0
  198. package/src/subscriptions/utils/polling.ts +80 -21
  199. package/src/types/env.ts +53 -8
  200. package/src/utils/rateLimit.ts +6 -1
  201. package/src/utils/redis.ts +71 -0
  202. package/src/utils/responses.ts +12 -0
  203. package/src/utils/sessionVariables.ts +2 -0
@@ -12,11 +12,12 @@ import type { SessionContext } from "../../utils/sessionVariables";
12
12
 
13
13
  import { analyzeQuery } from "../../analyzeQuery";
14
14
  import { checkQueryCost } from "../../analyzeQuery/costLimit";
15
- import { depthLimitRule } from "../../analyzeQuery/depthLimit";
15
+ import { depthLimitRule, isDepthLimitError } from "../../analyzeQuery/depthLimit";
16
16
  import { resolveVariableRef, resolveVariables } from "../../analyzeQuery/resolveVariables";
17
17
  import { callStoredProcedure, executeQueryJSON, generateSQL } from "../../databases";
18
18
  import { proxyRemoteField } from "../../remoteSchemas/proxy";
19
19
  import { getAgent } from "../../singletons/ai";
20
+ import { actorFromSession, audit } from "../../logging/audit";
20
21
  import { databasesConnections, repositoryMap } from "../../singletons/databases";
21
22
  import { env } from "../../singletons/env";
22
23
  import { queueManager } from "../../singletons/queues";
@@ -24,6 +25,8 @@ import { EntitySource } from "../../types/resolver";
24
25
  import { filterResultBySelection } from "../../utils/selection";
25
26
  import { handleAuthMeQuery, handleAuthMutation } from "./gqlAuthOperations";
26
27
  import { logger } from "../../logging";
28
+ import { incMetric, isMetricsEnabled, observeMetric } from "../../observability/metrics";
29
+ import { startSpan, withActiveSpan } from "../../observability/tracing";
27
30
 
28
31
  // Handle GraphQL query
29
32
  export const handleGraphQLRequestFactory = (
@@ -44,7 +47,7 @@ export const handleGraphQLRequestFactory = (
44
47
  ) => Promise<{ data: object }>
45
48
  >
46
49
  > = {
47
- [EntitySource.QUEUE_PUBLISHER]: async (field, variables) => {
50
+ [EntitySource.QUEUE_PUBLISHER]: async (field, variables, _queryAnalysis, _req, session) => {
48
51
  const data = resolveVariableRef(variables, field.arguments?.data);
49
52
 
50
53
  const publisher = entities.queuesMap[field.name];
@@ -53,6 +56,12 @@ export const handleGraphQLRequestFactory = (
53
56
  throw new Error(`Queue publisher not found: ${field.name}`);
54
57
  }
55
58
 
59
+ audit().emit({
60
+ action: "queue.publish",
61
+ actor: actorFromSession(session),
62
+ target: { kind: "publisher", name: publisher.resolverName },
63
+ });
64
+
56
65
  return {
57
66
  data: {
58
67
  [field.alias || field.name]: await queueManager?.sendMessage(
@@ -100,7 +109,7 @@ export const handleGraphQLRequestFactory = (
100
109
  };
101
110
  },
102
111
 
103
- [EntitySource.STORED_PROCEDURE]: async (field, variables, queryAnalysis) => {
112
+ [EntitySource.STORED_PROCEDURE]: async (field, variables, queryAnalysis, _req, session) => {
104
113
  const sp = entities.mutationsMap[field.name];
105
114
 
106
115
  if (!sp) {
@@ -116,8 +125,15 @@ export const handleGraphQLRequestFactory = (
116
125
 
117
126
  const result = await callStoredProcedure(
118
127
  sp,
119
- queryAnalysis.operations[0].variables ?? [],
120
128
  argumentsReplaced as Record<string, string | number | boolean | null>,
129
+ {
130
+ operation: {
131
+ type: "mutation",
132
+ name: queryAnalysis.operations[0]?.name ?? null,
133
+ fields: [field.name],
134
+ },
135
+ role: session?.role,
136
+ },
121
137
  );
122
138
 
123
139
  return {
@@ -230,6 +246,7 @@ export const handleGraphQLRequestFactory = (
230
246
  ) => {
231
247
  const enforceDepthLimit = options?.enforceDepthLimit ?? true;
232
248
  const enforceCostLimit = options?.enforceCostLimit ?? true;
249
+ let costRejected = false;
233
250
  const entry = getCacheEntry(query);
234
251
  // Unparseable queries aren't cached; let parse surface the syntax error
235
252
  const document = entry?.document ?? parse(query);
@@ -257,7 +274,20 @@ export const handleGraphQLRequestFactory = (
257
274
  env.maxQueryCost,
258
275
  );
259
276
 
260
- if (costError) validationErrors = [costError];
277
+ if (costError) {
278
+ validationErrors = [costError];
279
+ costRejected = true;
280
+ }
281
+ }
282
+
283
+ if (validationErrors.length > 0) {
284
+ incMetric("graphoria_graphql_rejections_total", {
285
+ reason: costRejected
286
+ ? "cost"
287
+ : validationErrors.some(isDepthLimitError)
288
+ ? "depth"
289
+ : "validation",
290
+ });
261
291
  }
262
292
 
263
293
  return {
@@ -289,180 +319,243 @@ export const handleGraphQLRequestFactory = (
289
319
  const startTime = Bun.nanoseconds();
290
320
 
291
321
  // Reuse cached analysis on repeated identical queries
322
+ const analyzeSpan = startSpan("graphoria.analyze");
292
323
  const entry = isString(query) ? getCacheEntry(query) : undefined;
293
324
  let queryAnalysis: AnalysisResult;
294
325
  if (entry?.analysis) {
295
326
  queryAnalysis = entry.analysis;
327
+ analyzeSpan?.setAttribute("graphoria.analysis.cached", true);
296
328
  } else {
297
329
  queryAnalysis = isString(query) ? analyzeQuery(query, entities, gqlEntities.schema) : query;
298
330
  if (entry) entry.analysis = queryAnalysis;
331
+ analyzeSpan?.setAttribute("graphoria.analysis.cached", false);
299
332
  }
333
+ analyzeSpan?.end();
300
334
 
301
335
  if (queryAnalysis.operations.length === 0) {
302
336
  return { data: {} };
303
337
  }
304
338
 
305
339
  const operation = queryAnalysis.operations[0];
306
- log.debug(
307
- {
308
- operation: operation.operation,
309
- name: operation.name,
310
- fieldCount: operation.fields?.length,
311
- queryLength: isString(query) ? query.length : undefined,
312
- },
313
- "graphql request",
314
- );
315
-
316
- // Single pass: validate, flatten object vars, resolve field args + session vars
317
- // Returns an immutable ResolvedOperation — original operation is not mutated
318
- const resolved = resolveVariables(operation, variables, session);
319
-
320
- if (operation.operation === "query") {
321
- // Separate auth fields, remote schema fields, and table fields
322
- const authFields = resolved.fields.filter((field) => field.source === EntitySource.AUTH);
323
- const remoteFields = resolved.fields.filter(
324
- (field) => field.source === EntitySource.REMOTE_SCHEMA,
325
- );
326
- const aiFields = resolved.fields.filter((field) => field.source === EntitySource.AI);
327
- const tableFields = resolved.fields.filter(
328
- (field) =>
329
- field.source !== EntitySource.AUTH &&
330
- field.source !== EntitySource.REMOTE_SCHEMA &&
331
- field.source !== EntitySource.AI,
340
+ // An anonymous operation is named by its root fields, exactly as the slow
341
+ // query log names one: the document itself would carry inline literals,
342
+ // which are caller data.
343
+ const operationName =
344
+ operation.name ?? (operation.fields ?? []).map((field) => field.name).join(",");
345
+ const role = session?.role ?? "anonymous";
346
+ const recordOperation = (outcome: "success" | "error") => {
347
+ if (!isMetricsEnabled()) return;
348
+ const labels = {
349
+ operation: operationName,
350
+ type: operation.operation,
351
+ role,
352
+ };
353
+ incMetric("graphoria_graphql_operations_total", { ...labels, outcome });
354
+ observeMetric(
355
+ "graphoria_graphql_operation_duration_seconds",
356
+ labels,
357
+ (Bun.nanoseconds() - startTime) / 1e9,
332
358
  );
359
+ };
360
+ let outcome: "success" | "error" = "success";
361
+ const span = startSpan(`${operation.operation} ${operationName}`, {
362
+ attributes: {
363
+ "graphql.operation.name": operationName,
364
+ "graphql.operation.type": operation.operation,
365
+ "graphoria.role": role,
366
+ },
367
+ });
333
368
 
334
- // Handle auth queries (e.g. auth_me)
335
- let authData: Record<string, unknown> = {};
336
- for (const field of authFields) {
337
- Object.assign(authData, handleAuthMeQuery(field, session));
338
- }
339
-
340
- // Handle remote schema queries in parallel
341
- let remoteData: Record<string, unknown> = {};
342
- if (remoteFields.length > 0) {
343
- const remoteResults = await Promise.all(
344
- remoteFields.map(async (field) => {
345
- const entry = entities.remoteQueriesMap[field.name];
346
- if (!entry) {
347
- throw new Error(`Remote schema query not found: ${field.name}`);
348
- }
349
- const result = await proxyRemoteField(
350
- field,
351
- entry.remoteSchema,
352
- entry.originalFieldName,
353
- resolved.allVariables,
354
- "query",
355
- req,
356
- );
357
- return { [field.alias || field.name]: result };
358
- }),
359
- );
360
- remoteData = remoteResults.reduce((acc, curr) => Object.assign(acc, curr), {});
361
- }
362
-
363
- // Handle AI agent queries (admin-only `ask` field)
364
- let aiData: Record<string, unknown> = {};
365
- for (const field of aiFields) {
366
- const alias = field.alias || field.name;
367
- const prompt = field.arguments?.prompt;
368
- aiData[alias] = await getAgent()(String(prompt ?? ""));
369
- }
370
-
371
- // Skip SQL generation if there are no table fields
372
- if (tableFields.length === 0) {
369
+ // The span is entered rather than passed: the statements this operation
370
+ // runs reach the executor through call chains that carry no span, and the
371
+ // async context is what joins them to this one.
372
+ return withActiveSpan(span, async () => {
373
+ try {
373
374
  log.debug(
374
- { durationMs: (Bun.nanoseconds() - startTime) / 1e6 },
375
- "graphql request completed (no table fields)",
376
- );
377
- return { data: { ...authData, ...remoteData, ...aiData } };
378
- }
379
-
380
- const tableQueryAnalysis = {
381
- ...queryAnalysis,
382
- operations: [
383
375
  {
384
- ...operation,
385
- fields: tableFields,
386
- variables: resolved.variables,
376
+ operation: operation.operation,
377
+ name: operation.name,
378
+ fieldCount: operation.fields?.length,
379
+ queryLength: isString(query) ? query.length : undefined,
387
380
  },
388
- ],
389
- };
381
+ "graphql request",
382
+ );
390
383
 
391
- const sqlQueries = generateSQL(
392
- entities,
393
- tableQueryAnalysis,
394
- resolved.allVariables,
395
- false,
396
- (options?.enforcePageLimits ?? true) ? pageLimits : null,
397
- options?.timeoutMs,
398
- );
384
+ // Single pass: validate, flatten object vars, resolve field args + session vars
385
+ // Returns an immutable ResolvedOperation — original operation is not mutated
386
+ const resolved = resolveVariables(operation, variables, session);
399
387
 
400
- const data = await Promise.all<object>(
401
- sqlQueries.map(([db, query]) =>
402
- executeQueryJSON(
403
- query,
404
- db,
405
- resolved.variables,
406
- resolved.allVariables as Record<string, string | number | boolean | null>,
407
- options?.timeoutMs,
408
- ),
409
- ),
410
- );
388
+ if (operation.operation === "query") {
389
+ // Separate auth fields, remote schema fields, and table fields
390
+ const authFields = resolved.fields.filter(
391
+ (field) => field.source === EntitySource.AUTH,
392
+ );
393
+ const remoteFields = resolved.fields.filter(
394
+ (field) => field.source === EntitySource.REMOTE_SCHEMA,
395
+ );
396
+ const aiFields = resolved.fields.filter((field) => field.source === EntitySource.AI);
397
+ const tableFields = resolved.fields.filter(
398
+ (field) =>
399
+ field.source !== EntitySource.AUTH &&
400
+ field.source !== EntitySource.REMOTE_SCHEMA &&
401
+ field.source !== EntitySource.AI,
402
+ );
411
403
 
412
- log.debug(
413
- { durationMs: (Bun.nanoseconds() - startTime) / 1e6, dbCount: sqlQueries.length },
414
- "graphql request completed",
415
- );
404
+ // Handle auth queries (e.g. auth_me)
405
+ let authData: Record<string, unknown> = {};
406
+ for (const field of authFields) {
407
+ Object.assign(authData, handleAuthMeQuery(field, session));
408
+ }
409
+
410
+ // Handle remote schema queries in parallel
411
+ let remoteData: Record<string, unknown> = {};
412
+ if (remoteFields.length > 0) {
413
+ const remoteResults = await Promise.all(
414
+ remoteFields.map(async (field) => {
415
+ const entry = entities.remoteQueriesMap[field.name];
416
+ if (!entry) {
417
+ throw new Error(`Remote schema query not found: ${field.name}`);
418
+ }
419
+ const result = await proxyRemoteField(
420
+ field,
421
+ entry.remoteSchema,
422
+ entry.originalFieldName,
423
+ resolved.allVariables,
424
+ "query",
425
+ req,
426
+ );
427
+ return { [field.alias || field.name]: result };
428
+ }),
429
+ );
430
+ remoteData = remoteResults.reduce((acc, curr) => Object.assign(acc, curr), {});
431
+ }
432
+
433
+ // Handle AI agent queries (admin-only `ask` field)
434
+ let aiData: Record<string, unknown> = {};
435
+ for (const field of aiFields) {
436
+ const alias = field.alias || field.name;
437
+ const prompt = String(field.arguments?.prompt ?? "");
438
+ audit().emit({
439
+ action: "ai.ask",
440
+ actor: actorFromSession(session),
441
+ target: { kind: "ai", via: "graphql" },
442
+ prompt,
443
+ });
444
+ aiData[alias] = await getAgent()(prompt);
445
+ }
446
+
447
+ // Skip SQL generation if there are no table fields
448
+ if (tableFields.length === 0) {
449
+ log.debug(
450
+ { durationMs: (Bun.nanoseconds() - startTime) / 1e6 },
451
+ "graphql request completed (no table fields)",
452
+ );
453
+ return { data: { ...authData, ...remoteData, ...aiData } };
454
+ }
455
+
456
+ const tableQueryAnalysis = {
457
+ ...queryAnalysis,
458
+ operations: [
459
+ {
460
+ ...operation,
461
+ fields: tableFields,
462
+ variables: resolved.variables,
463
+ },
464
+ ],
465
+ };
416
466
 
417
- return {
418
- ...(env.queryOnResponse
419
- ? {
420
- sqlQueries: sqlQueries.map(([db, query]) => ({
421
- db: db.name,
422
- query,
423
- })),
424
- }
425
- : {}),
426
- data: data.reduce((acc, curr) => Object.assign(acc, curr), {
427
- ...authData,
428
- ...remoteData,
429
- ...aiData,
430
- }),
431
- };
432
- } else if (operation.operation === "mutation") {
433
- // Route mutation to the appropriate handler based on field source
434
- let results: Record<string, object> = {};
467
+ const sqlQueries = generateSQL(
468
+ entities,
469
+ tableQueryAnalysis,
470
+ resolved.allVariables,
471
+ false,
472
+ (options?.enforcePageLimits ?? true) ? pageLimits : null,
473
+ options?.timeoutMs,
474
+ );
435
475
 
436
- for await (const field of resolved.fields) {
437
- const source = field.source;
476
+ const data = await Promise.all<object>(
477
+ sqlQueries.map(([db, query]) =>
478
+ executeQueryJSON(
479
+ query,
480
+ db,
481
+ resolved.variables,
482
+ resolved.allVariables as Record<string, string | number | boolean | null>,
483
+ options?.timeoutMs,
484
+ {
485
+ operation: {
486
+ type: operation.operation,
487
+ name: operation.name,
488
+ fields: tableFields.map((field) => field.name),
489
+ },
490
+ role: session?.role,
491
+ },
492
+ ),
493
+ ),
494
+ );
438
495
 
439
- if (source && mutationHandlers[source]) {
440
- const result = await mutationHandlers[source]!(
441
- field,
442
- resolved.allVariables,
443
- queryAnalysis,
444
- req,
445
- session,
496
+ log.debug(
497
+ { durationMs: (Bun.nanoseconds() - startTime) / 1e6, dbCount: sqlQueries.length },
498
+ "graphql request completed",
446
499
  );
447
500
 
448
- results = {
449
- ...results,
450
- ...result.data,
501
+ return {
502
+ ...(env.queryOnResponse
503
+ ? {
504
+ sqlQueries: sqlQueries.map(([db, query]) => ({
505
+ db: db.name,
506
+ query,
507
+ })),
508
+ }
509
+ : {}),
510
+ data: data.reduce((acc, curr) => Object.assign(acc, curr), {
511
+ ...authData,
512
+ ...remoteData,
513
+ ...aiData,
514
+ }),
451
515
  };
452
- }
453
- }
516
+ } else if (operation.operation === "mutation") {
517
+ // Route mutation to the appropriate handler based on field source
518
+ let results: Record<string, object> = {};
519
+
520
+ for await (const field of resolved.fields) {
521
+ const source = field.source;
522
+
523
+ if (source && mutationHandlers[source]) {
524
+ const result = await mutationHandlers[source]!(
525
+ field,
526
+ resolved.allVariables,
527
+ queryAnalysis,
528
+ req,
529
+ session,
530
+ );
531
+
532
+ results = {
533
+ ...results,
534
+ ...result.data,
535
+ };
536
+ }
537
+ }
454
538
 
455
- log.debug(
456
- { durationMs: (Bun.nanoseconds() - startTime) / 1e6 },
457
- "graphql request completed",
458
- );
539
+ log.debug(
540
+ { durationMs: (Bun.nanoseconds() - startTime) / 1e6 },
541
+ "graphql request completed",
542
+ );
459
543
 
460
- return {
461
- data: results,
462
- };
463
- }
544
+ return {
545
+ data: results,
546
+ };
547
+ }
464
548
 
465
- return { data: {} };
549
+ return { data: {} };
550
+ } catch (error) {
551
+ outcome = "error";
552
+ span?.recordError(error);
553
+ throw error;
554
+ } finally {
555
+ recordOperation(outcome);
556
+ span?.end();
557
+ }
558
+ });
466
559
  },
467
560
  };
468
561
 
@@ -10,6 +10,7 @@ import { getTokenService } from "../../singletons/authentication";
10
10
  import { createSubscriptionStrategyRegistry, getStrategyForSource } from "../../subscriptions";
11
11
  import { createQueryEventEmitter } from "../../utils/event-emitter";
12
12
  import { logger } from "../../logging";
13
+ import { audit } from "../../logging/audit";
13
14
 
14
15
  export const queryEventEmitter = createQueryEventEmitter();
15
16
 
@@ -60,6 +61,14 @@ const handleGraphQLSubscriptionFactory = (
60
61
 
61
62
  subscriptionMapping.set(ws, session);
62
63
 
64
+ if (session.authMethod === "admin_secret") {
65
+ audit().emit({
66
+ action: "admin_secret.used",
67
+ actor: { type: "admin_secret", ip: ws.remoteAddress },
68
+ target: { kind: "websocket" },
69
+ });
70
+ }
71
+
63
72
  logger("subscriptions").debug({ role: session.role }, "client connected");
64
73
 
65
74
  ws.send(
@@ -1,38 +1,77 @@
1
+ import { GraphQLObjectType } from "graphql";
2
+
3
+ import type { GraphQLType } from "graphql";
1
4
  import type { OpenAPIV3_1 } from "openapi-types";
2
5
  import type { SelectionAnalysis } from "../../analyzeQuery/types";
3
6
  import type { GetSchemaReturn } from "../../configuration/getSchemas";
4
7
  import type { RemoteRESTResolved } from "../../remoteREST/types";
5
8
  import type { Env } from "../../types/env";
6
9
 
10
+ import { getFieldType, unwrapType } from "../../analyzeQuery/typeUtils";
7
11
  import { convertFromZod, errors } from "./openApiErrors";
8
12
 
13
+ // Deliberately coarse: Int and Float both map to "number", so a column keeps
14
+ // the OpenAPI type it had when types were read off the SQL data type.
15
+ const openApiTypeOf = (namedType: GraphQLType | undefined) => {
16
+ const name = namedType && "name" in namedType ? namedType.name : undefined;
17
+
18
+ switch (name) {
19
+ case "Int":
20
+ case "Float":
21
+ return "number";
22
+ case "Boolean":
23
+ return "boolean";
24
+ default:
25
+ return "string";
26
+ }
27
+ };
28
+
29
+ const resolveFieldType = (parentType: GraphQLObjectType | undefined, fieldName: string) => {
30
+ const fieldType = parentType && getFieldType(parentType, fieldName);
31
+ const namedType = fieldType ? unwrapType(fieldType).type : undefined;
32
+
33
+ return {
34
+ namedType,
35
+ objectType: namedType instanceof GraphQLObjectType ? namedType : undefined,
36
+ };
37
+ };
38
+
39
+ /**
40
+ * Resolves selections against the GraphQL schema rather than against a table
41
+ * name. `<table>_aggregate` returns a GroupBy wrapper whose fields — `key`,
42
+ * `count`, `items`, `min`/`max`/`sum`/`avg` — are not tables, so looking a
43
+ * selection's parent name up in the entity map typed everything under an
44
+ * aggregate as a string. Walking the schema also makes sanitised column names
45
+ * resolve, since the schema is what the client actually queries.
46
+ */
9
47
  const processSelections = (
10
- schema: GetSchemaReturn,
11
- name: string,
12
- selections: SelectionAnalysis[] = [],
48
+ parentType: GraphQLObjectType | undefined,
49
+ fieldParent: SelectionAnalysis,
13
50
  ) =>
14
- selections.reduce<Record<string, unknown>>((acc, field) => {
15
- const type = schema.getColumnTypeForOpenApi(name, field.name);
51
+ fieldParent.selections?.reduce<Record<string, unknown>>((acc, field) => {
52
+ const { namedType, objectType } = resolveFieldType(parentType, field.name);
16
53
 
17
54
  if (field.isArray) {
18
55
  acc[field.alias || field.name] = {
19
56
  type: "array",
20
57
  items: {
21
58
  type: "object",
22
- properties: processSelections(schema, field.name, field.selections),
59
+ properties: processSelections(objectType, field),
23
60
  required: (field.selections ?? [])
24
61
  .filter((f) => f.isRequired)
25
62
  .map((f) => f.alias || f.name),
63
+ nullable: !field.isRequired,
26
64
  },
27
65
  };
28
66
  } else {
29
67
  acc[field.alias || field.name] = field.selections
30
68
  ? {
31
69
  type: "object",
32
- properties: processSelections(schema, field.name, field.selections),
70
+ properties: processSelections(objectType, field),
33
71
  required: field.selections.filter((f) => f.isRequired).map((f) => f.alias || f.name),
72
+ nullable: !field.isRequired,
34
73
  }
35
- : { type };
74
+ : { type: openApiTypeOf(namedType), nullable: !field.isRequired };
36
75
  }
37
76
 
38
77
  return acc;
@@ -137,7 +176,12 @@ export const generateOpenAPI = ({
137
176
  const responseDataSchema = value.output
138
177
  ? convertFromZod(value.output)
139
178
  : (() => {
140
- const fields = value.queryStructure!.operations[0].fields;
179
+ const operation = value.queryStructure!.operations[0];
180
+ const fields = operation.fields;
181
+ const rootType =
182
+ (operation.operation === "mutation"
183
+ ? schema.schema.getMutationType()
184
+ : schema.schema.getQueryType()) ?? undefined;
141
185
  return {
142
186
  type: "object" as const,
143
187
  properties: fields.reduce<Record<string, unknown>>((acc, field) => {
@@ -146,7 +190,10 @@ export const generateOpenAPI = ({
146
190
  type: "array",
147
191
  items: {
148
192
  type: "object",
149
- properties: processSelections(schema, field.name, field.selections!),
193
+ properties: processSelections(
194
+ resolveFieldType(rootType, field.name).objectType,
195
+ field,
196
+ ),
150
197
  required: (field.selections ?? [])
151
198
  .filter((f) => f.isRequired)
152
199
  .map((f) => f.alias || f.name),
@@ -155,7 +202,10 @@ export const generateOpenAPI = ({
155
202
  } else {
156
203
  acc[field.alias || field.name] = {
157
204
  type: "object",
158
- properties: processSelections(schema, field.name, field.selections!),
205
+ properties: processSelections(
206
+ resolveFieldType(rootType, field.name).objectType,
207
+ field,
208
+ ),
159
209
  required: (field.selections ?? [])
160
210
  .filter((f) => f.isRequired)
161
211
  .map((f) => f.alias || f.name),