@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
@@ -59,6 +59,18 @@ const resolveBooleanOperand = (
59
59
 
60
60
  export const generateTableAlias = (level: number): string => `t${level}`;
61
61
 
62
+ /**
63
+ * A table whose alias is lexically in scope above the join being emitted, outermost
64
+ * first and including the immediate parent. Nested selections compile to correlated
65
+ * subqueries, so every enclosing alias stays referenceable — this is what lets a
66
+ * relationship condition name a column on an ancestor rather than a literal.
67
+ *
68
+ * Built fresh at each recursion rather than read off `aliasMap`: that map is flat,
69
+ * shared and written by two different level counters, so it cannot distinguish an
70
+ * enclosing scope from a sibling that merely ran earlier.
71
+ */
72
+ export type QueryPathFrame = { table: string; alias: string };
73
+
62
74
  const mappingDbTypeCharVar: Record<DatabaseType, string> = {
63
75
  pg: "$",
64
76
  mysql: "$",
@@ -76,6 +88,7 @@ export const buildConditions = (
76
88
  aliasMap: { [alias: string]: string },
77
89
  /** Query name of the table being filtered — resolves its virtual columns. */
78
90
  tableName?: string,
91
+ ancestors: readonly QueryPathFrame[] = [],
79
92
  ): string => {
80
93
  if (!whereArgs) return "";
81
94
 
@@ -135,6 +148,13 @@ export const buildConditions = (
135
148
 
136
149
  const parentTableName = aliasMap[tableAlias];
137
150
 
151
+ // An EXISTS sits inside the WHERE of the block that binds `tableAlias`, so that
152
+ // block encloses it just as a selection subquery would. Extending the scope here
153
+ // is what makes a relation filter emit the same predicate as the selection form.
154
+ const scope = parentTableName
155
+ ? [...ancestors, { table: parentTableName, alias: tableAlias }]
156
+ : ancestors;
157
+
138
158
  const joinCondition = findJoinCondition(
139
159
  entities,
140
160
  parentTableName,
@@ -142,6 +162,7 @@ export const buildConditions = (
142
162
  tableAlias,
143
163
  nestedTableAlias,
144
164
  dbType,
165
+ scope,
145
166
  );
146
167
 
147
168
  // Record this relation's alias→table so a deeper nested where (3+ levels) can resolve
@@ -159,6 +180,7 @@ export const buildConditions = (
159
180
  level + 1,
160
181
  aliasMap,
161
182
  fieldName,
183
+ scope,
162
184
  );
163
185
 
164
186
  const existsClause = `EXISTS (
@@ -324,6 +346,47 @@ const columnCollation = (
324
346
  return column && "collation" in column ? column.collation : undefined;
325
347
  };
326
348
 
349
+ // Resolves an `ancestor` condition to a qualified column on an enclosing alias.
350
+ // Matches on entity identity rather than on the frame's key, because a frame may be
351
+ // keyed by a suffixed relationship name (`app_users_ref`) and because resolverName
352
+ // depends on the database's configurable `fieldNaming` — neither is comparable to
353
+ // the schema/name pair the config declares. Nearest enclosing match wins, so a
354
+ // self-referential chain binds to the closest occurrence.
355
+ const ancestorOperand = (
356
+ entities: MergedEntities,
357
+ ancestors: readonly QueryPathFrame[],
358
+ ancestor: { schema: string; name: string; column: string },
359
+ leftTable: string,
360
+ leftColumn: string,
361
+ dbType: DatabaseType,
362
+ ): { sql: string; collate: string } => {
363
+ for (let i = ancestors.length - 1; i >= 0; i--) {
364
+ const frame = ancestors[i]!;
365
+ const entity = entities.queriesMap[frame.table];
366
+
367
+ if (entity?.schema !== ancestor.schema || entity.name !== ancestor.name) continue;
368
+
369
+ return {
370
+ sql: `${frame.alias}.${wrapIdentifierFp(dbType)(ancestor.column)}`,
371
+ // Unlike a literal comparison, this equates two character columns that may
372
+ // carry different collations — the case collateSuffix exists for.
373
+ collate: collateSuffix(
374
+ dbType,
375
+ columnCollation(entities, leftTable, leftColumn),
376
+ columnCollation(entities, frame.table, ancestor.column),
377
+ ),
378
+ };
379
+ }
380
+
381
+ const inScope = ancestors.map((a) => a.table).join(" > ");
382
+
383
+ throw new Error(
384
+ `Relationship condition references ancestor table "${ancestor.schema}.${ancestor.name}", which is not in this query's path. ` +
385
+ `In scope here: ${inScope || "(none)"}. ` +
386
+ `Nest this selection under ${ancestor.schema}.${ancestor.name}, or drop the "ancestor" condition.`,
387
+ );
388
+ };
389
+
327
390
  const pairsToAnd = (
328
391
  pairs: {
329
392
  parentAlias: string;
@@ -377,6 +440,7 @@ const renderCondition = (
377
440
  column: string,
378
441
  operator: string,
379
442
  value: string | number | boolean | undefined,
443
+ ancestor?: { sql: string; collate: string },
380
444
  ): string => {
381
445
  const target = `${alias}.${wrapIdentifierFp(dbType)(column)}`;
382
446
 
@@ -384,7 +448,13 @@ const renderCondition = (
384
448
  if (operator === "is_not_null") return `${target} IS NOT NULL`;
385
449
 
386
450
  const sqlOperator = RELATIONSHIP_CONDITION_SQL_OPERATORS[operator];
387
- if (!sqlOperator || value === undefined) {
451
+ if (!sqlOperator) {
452
+ throw new Error(`Invalid relationship condition operator "${operator}"`);
453
+ }
454
+
455
+ if (ancestor) return `${target} ${sqlOperator} ${ancestor.sql}${ancestor.collate}`;
456
+
457
+ if (value === undefined) {
388
458
  throw new Error(`Invalid relationship condition operator "${operator}"`);
389
459
  }
390
460
 
@@ -396,16 +466,25 @@ const renderCondition = (
396
466
  // toAlias). The from/to aliases flip between forward and reverse joins.
397
467
  const conditionsToAnd = (
398
468
  conditions: readonly RelationshipCondition[] | undefined,
399
- fromAlias: string,
400
- toAlias: string,
469
+ from: { alias: string; table: string },
470
+ to: { alias: string; table: string },
401
471
  dbType: DatabaseType,
472
+ entities: MergedEntities,
473
+ ancestors: readonly QueryPathFrame[],
402
474
  ): string =>
403
475
  (conditions ?? [])
404
- .map((c) =>
405
- c.source !== undefined
406
- ? renderCondition(dbType, fromAlias, c.source, c.operator ?? "eq", c.value)
407
- : renderCondition(dbType, toAlias, c.target!, c.operator ?? "eq", c.value),
408
- )
476
+ .map((c) => {
477
+ const side = c.source !== undefined ? from : to;
478
+ const column = (c.source ?? c.target)!;
479
+
480
+ // The from/to flip above picks the left operand only; an ancestor is the right
481
+ // operand and resolves by table identity, so it is direction-independent.
482
+ const ancestor = c.ancestor
483
+ ? ancestorOperand(entities, ancestors, c.ancestor, side.table, column, dbType)
484
+ : undefined;
485
+
486
+ return renderCondition(dbType, side.alias, column, c.operator ?? "eq", c.value, ancestor);
487
+ })
409
488
  .join(" AND ");
410
489
 
411
490
  const joinParts = (...parts: string[]): string => parts.filter(Boolean).join(" AND ");
@@ -417,6 +496,7 @@ export const findJoinCondition = (
417
496
  parentAlias: string,
418
497
  childAlias: string,
419
498
  dbType: DatabaseType,
499
+ ancestors: readonly QueryPathFrame[] = [],
420
500
  ): string => {
421
501
  const relations = entities.getForeignKeysBetweenTables(parentTableName, childTableName);
422
502
 
@@ -436,7 +516,14 @@ export const findJoinCondition = (
436
516
  })),
437
517
  dbType,
438
518
  ),
439
- conditionsToAnd(relation.conditions, parentAlias, childAlias, dbType),
519
+ conditionsToAnd(
520
+ relation.conditions,
521
+ { alias: parentAlias, table: parentTableName },
522
+ { alias: childAlias, table: childTableName },
523
+ dbType,
524
+ entities,
525
+ ancestors,
526
+ ),
440
527
  ),
441
528
  ) ?? []),
442
529
  ...(relations.relationshipsReversed?.map((relation) =>
@@ -452,7 +539,14 @@ export const findJoinCondition = (
452
539
  })),
453
540
  dbType,
454
541
  ),
455
- conditionsToAnd(relation.conditions, childAlias, parentAlias, dbType),
542
+ conditionsToAnd(
543
+ relation.conditions,
544
+ { alias: childAlias, table: childTableName },
545
+ { alias: parentAlias, table: parentTableName },
546
+ dbType,
547
+ entities,
548
+ ancestors,
549
+ ),
456
550
  ),
457
551
  ) ?? []),
458
552
  ].join(" AND ");
@@ -470,6 +564,7 @@ export const buildWhereClauseFp =
470
564
  parentTableAlias: string | null,
471
565
  level: number,
472
566
  aliasMap: { [alias: string]: string },
567
+ ancestors: readonly QueryPathFrame[] = [],
473
568
  ): string => {
474
569
  const whereConditions: string[] = [
475
570
  buildConditions(
@@ -482,7 +577,10 @@ export const buildWhereClauseFp =
482
577
  level + 1,
483
578
  aliasMap,
484
579
  field.name ?? aliasMap[tableAlias],
580
+ ancestors,
485
581
  ),
582
+ // `ancestors` already ends with this join's parent, and the child is the field
583
+ // being emitted, so the scope needs no splicing here.
486
584
  parentTableName && parentTableAlias
487
585
  ? findJoinCondition(
488
586
  entities,
@@ -491,6 +589,7 @@ export const buildWhereClauseFp =
491
589
  parentTableAlias,
492
590
  tableAlias,
493
591
  dbType,
592
+ ancestors,
494
593
  )
495
594
  : null,
496
595
  ].filter(Boolean) as string[];
@@ -1,14 +1,24 @@
1
1
  import type { VariableDefinition } from "../../analyzeQuery/types";
2
+ import type { QuerySource } from "../../logging/slowQuery";
2
3
  import type { Database } from "../../types/configuration";
3
4
  import type { ProcedureResolver } from "../../types/db";
4
5
 
5
6
  import { databaseAdapters } from "./function-mapping";
6
7
  import { logger } from "../../logging";
8
+ import { reportQueryDuration } from "../../logging/slowQuery";
9
+ import { startSpan } from "../../observability/tracing";
7
10
 
8
11
  /**
9
12
  * Core database execution functions
10
13
  */
11
14
 
15
+ /** Names only. The variable values are caller data and never reach a span. */
16
+ const sourceAttributes = (source?: QuerySource) => ({
17
+ "graphql.operation.name": source?.operation.name ?? undefined,
18
+ "graphql.operation.type": source?.operation.type,
19
+ "graphoria.role": source?.role,
20
+ });
21
+
12
22
  export const executeQuery = async <T>(
13
23
  query: string,
14
24
  db: Database,
@@ -16,6 +26,7 @@ export const executeQuery = async <T>(
16
26
  variables: Record<string, unknown>,
17
27
  /** Per-operation override. `undefined` leaves the engine on its own default. */
18
28
  timeoutMs?: number,
29
+ source?: QuerySource,
19
30
  ) => {
20
31
  const adapter = databaseAdapters[db?.type];
21
32
  if (!adapter) {
@@ -24,16 +35,40 @@ export const executeQuery = async <T>(
24
35
 
25
36
  const log = logger("db").child({ dbType: db.type, dbName: db.name });
26
37
  const startTime = Bun.nanoseconds();
38
+ const report = (durationMs: number, outcome: "success" | "error") =>
39
+ reportQueryDuration({
40
+ durationMs,
41
+ dbType: db.type,
42
+ dbName: db.name,
43
+ sql: query,
44
+ outcome,
45
+ ...source,
46
+ });
47
+ // Every literal is hoisted into a bound parameter, so the statement text
48
+ // carries no caller data and is safe to export.
49
+ const span = startSpan("db.query", {
50
+ kind: "client",
51
+ attributes: {
52
+ "db.system": db.type,
53
+ "db.name": db.name,
54
+ "db.statement": query,
55
+ ...sourceAttributes(source),
56
+ },
57
+ });
27
58
 
28
59
  try {
29
60
  const result = await adapter.execute<T>(query, db, variablesDefinition, variables, timeoutMs);
30
- log.debug(
31
- { durationMs: (Bun.nanoseconds() - startTime) / 1e6, queryLength: query.length },
32
- "query executed",
33
- );
61
+ const durationMs = (Bun.nanoseconds() - startTime) / 1e6;
62
+ log.debug({ durationMs, queryLength: query.length }, "query executed");
63
+ report(durationMs, "success");
64
+ span?.end();
34
65
  return result;
35
66
  } catch (error) {
36
- log.error({ err: error, durationMs: (Bun.nanoseconds() - startTime) / 1e6 }, "query failed");
67
+ const durationMs = (Bun.nanoseconds() - startTime) / 1e6;
68
+ log.error({ err: error, durationMs }, "query failed");
69
+ report(durationMs, "error");
70
+ span?.recordError(error);
71
+ span?.end();
37
72
  throw error;
38
73
  }
39
74
  };
@@ -45,6 +80,7 @@ export const executeQueryJSON = async <T>(
45
80
  variables: Record<string, unknown>,
46
81
  /** Per-operation override. `undefined` leaves the engine on its own default. */
47
82
  timeoutMs?: number,
83
+ source?: QuerySource,
48
84
  ) => {
49
85
  const adapter = databaseAdapters[db.type];
50
86
  if (!adapter) {
@@ -53,6 +89,24 @@ export const executeQueryJSON = async <T>(
53
89
 
54
90
  const log = logger("db").child({ dbType: db.type, dbName: db.name });
55
91
  const startTime = Bun.nanoseconds();
92
+ const report = (durationMs: number, outcome: "success" | "error") =>
93
+ reportQueryDuration({
94
+ durationMs,
95
+ dbType: db.type,
96
+ dbName: db.name,
97
+ sql: query,
98
+ outcome,
99
+ ...source,
100
+ });
101
+ const span = startSpan("db.query", {
102
+ kind: "client",
103
+ attributes: {
104
+ "db.system": db.type,
105
+ "db.name": db.name,
106
+ "db.statement": query,
107
+ ...sourceAttributes(source),
108
+ },
109
+ });
56
110
 
57
111
  try {
58
112
  const result = await adapter.executeJson<T>(
@@ -62,24 +116,25 @@ export const executeQueryJSON = async <T>(
62
116
  variables,
63
117
  timeoutMs,
64
118
  );
65
- log.debug(
66
- { durationMs: (Bun.nanoseconds() - startTime) / 1e6, queryLength: query.length },
67
- "query executed (json)",
68
- );
119
+ const durationMs = (Bun.nanoseconds() - startTime) / 1e6;
120
+ log.debug({ durationMs, queryLength: query.length }, "query executed (json)");
121
+ report(durationMs, "success");
122
+ span?.end();
69
123
  return result;
70
124
  } catch (error) {
71
- log.error(
72
- { err: error, durationMs: (Bun.nanoseconds() - startTime) / 1e6 },
73
- "query failed (json)",
74
- );
125
+ const durationMs = (Bun.nanoseconds() - startTime) / 1e6;
126
+ log.error({ err: error, durationMs }, "query failed (json)");
127
+ report(durationMs, "error");
128
+ span?.recordError(error);
129
+ span?.end();
75
130
  throw error;
76
131
  }
77
132
  };
78
133
 
79
134
  export const callStoredProcedure = async (
80
135
  sp: ProcedureResolver,
81
- variablesDefinition: VariableDefinition[],
82
136
  variables: Record<string, unknown> = {},
137
+ source?: QuerySource,
83
138
  ) => {
84
139
  const adapter = databaseAdapters[sp.db!.type];
85
140
  if (!adapter) {
@@ -92,16 +147,38 @@ export const callStoredProcedure = async (
92
147
  procedure: sp.dottedName,
93
148
  });
94
149
  const startTime = Bun.nanoseconds();
150
+ const report = (durationMs: number, outcome: "success" | "error") =>
151
+ reportQueryDuration({
152
+ durationMs,
153
+ dbType: sp.db!.type,
154
+ dbName: sp.db!.name,
155
+ procedure: sp.dottedName,
156
+ outcome,
157
+ ...source,
158
+ });
159
+ const span = startSpan("db.procedure", {
160
+ kind: "client",
161
+ attributes: {
162
+ "db.system": sp.db!.type,
163
+ "db.name": sp.db!.name,
164
+ "db.operation": sp.dottedName,
165
+ ...sourceAttributes(source),
166
+ },
167
+ });
95
168
 
96
169
  try {
97
- const result = await adapter.callStoredProcedure(sp, variablesDefinition, variables);
98
- log.debug({ durationMs: (Bun.nanoseconds() - startTime) / 1e6 }, "stored procedure executed");
170
+ const result = await adapter.callStoredProcedure(sp, variables);
171
+ const durationMs = (Bun.nanoseconds() - startTime) / 1e6;
172
+ log.debug({ durationMs }, "stored procedure executed");
173
+ report(durationMs, "success");
174
+ span?.end();
99
175
  return result;
100
176
  } catch (error) {
101
- log.error(
102
- { err: error, durationMs: (Bun.nanoseconds() - startTime) / 1e6 },
103
- "stored procedure failed",
104
- );
177
+ const durationMs = (Bun.nanoseconds() - startTime) / 1e6;
178
+ log.error({ err: error, durationMs }, "stored procedure failed");
179
+ report(durationMs, "error");
180
+ span?.recordError(error);
181
+ span?.end();
105
182
  throw error;
106
183
  }
107
184
  };
@@ -80,7 +80,7 @@ export type InsertAuthUserInput = {
80
80
  claims?: Record<string, unknown>;
81
81
  };
82
82
 
83
- type DatabaseFunctions = {
83
+ export type DatabaseFunctions = {
84
84
  query: (
85
85
  entities: MergedEntities,
86
86
  operation: OperationAnalysis,
@@ -105,7 +105,6 @@ type DatabaseFunctions = {
105
105
  ) => Promise<T>;
106
106
  callStoredProcedure: (
107
107
  sp: ProcedureResolver,
108
- variablesDefinition: VariableDefinition[],
109
108
  parameters: Record<string, unknown>,
110
109
  // oxlint-disable-next-line typescript/no-explicit-any
111
110
  ) => Promise<any>;
@@ -0,0 +1,25 @@
1
+ import type { VariableDefinition } from "../../analyzeQuery/types";
2
+ import type { ProcedureResolver } from "../../types/db";
3
+
4
+ /**
5
+ * Orders a procedure call's arguments for the engines that bind positionally.
6
+ *
7
+ * `$1, $2, …` are numbered by one list and filled from another, so the two have
8
+ * to be the same list or the values land on the wrong parameters. Signature
9
+ * order comes from the introspected metadata; the caller's argument object is
10
+ * unordered and reflects the GraphQL query text.
11
+ *
12
+ * Arguments the metadata doesn't account for keep their supplied order and
13
+ * follow: an unnamed procedure parameter is introspected under a synthesized
14
+ * name that no caller can match, and dropping the value there would be worse
15
+ * than binding it where it was already going.
16
+ */
17
+ export const orderProcedureArguments = (
18
+ sp: ProcedureResolver,
19
+ variables: Record<string, unknown>,
20
+ ): VariableDefinition[] => {
21
+ const declared = sp.parameters.map((p) => p.name).filter((name) => name in variables);
22
+ const undeclared = Object.keys(variables).filter((name) => !declared.includes(name));
23
+
24
+ return [...declared, ...undeclared].map((name) => ({ name, type: "", required: false }));
25
+ };
@@ -161,7 +161,6 @@ export const executeQueryJSONSingle = executeQueryJSONFactory(true);
161
161
 
162
162
  export const callStoredProcedure = async (
163
163
  sp: ProcedureResolver,
164
- variablesDefinition: VariableDefinition[],
165
164
  variables: Record<string, unknown> = {},
166
165
  ) => {
167
166
  try {
@@ -170,8 +169,6 @@ export const callStoredProcedure = async (
170
169
  const request = pool!.request();
171
170
 
172
171
  for (const [key, value] of Object.entries(variables)) {
173
- if (!value) continue;
174
-
175
172
  const paramFound = sp.parameters.find((p) => p.name === `@${key}`);
176
173
 
177
174
  if (paramFound?.dataType === "varchar") {
@@ -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, PageLimits } from "../../../common";
7
+ import type { GroupByInfo, PageLimits, QueryPathFrame } from "../../../common";
8
8
 
9
9
  import {
10
10
  buildOrderByClauseMSSQL,
@@ -265,6 +265,7 @@ export const buildSQLForField = (
265
265
  level: number,
266
266
  aliasMap: { [alias: string]: string },
267
267
  pageLimits: PageLimits | null,
268
+ ancestors: readonly QueryPathFrame[] = [],
268
269
  ): string => {
269
270
  const tableAlias = generateTableAlias(level);
270
271
 
@@ -290,6 +291,7 @@ export const buildSQLForField = (
290
291
  parentTableAlias,
291
292
  level,
292
293
  aliasMap,
294
+ ancestors,
293
295
  );
294
296
 
295
297
  // Check if this is a GROUP BY query
@@ -330,6 +332,7 @@ export const buildSQLForField = (
330
332
  level,
331
333
  aliasMap,
332
334
  pageLimits,
335
+ [...ancestors, { table: resolverName, alias: tableAlias }],
333
336
  ),
334
337
  ([name, selector]) => `${selector} AS ${wrapIdentifierMSSQL(name)}`,
335
338
  );
@@ -7,6 +7,7 @@ import type { ProcedureResolver } from "../../../types/db";
7
7
 
8
8
  import { BunSQLConnectionOptionsZod } from "../../../config/types/db";
9
9
  import { databasesConnections } from "../../../singletons/databases";
10
+ import { orderProcedureArguments } from "../../core/procedure-arguments";
10
11
  import { toMySQLPlaceholders } from "./placeholders";
11
12
 
12
13
  // Every pool bound comes from the schema, which is the only place they are
@@ -97,22 +98,24 @@ export const executeQueryJSONFactory =
97
98
  export const executeQueryJSON = executeQueryJSONFactory();
98
99
  export const executeQueryJSONSingle = executeQueryJSONFactory(true);
99
100
 
101
+ // The generated schema types every stored procedure mutation as `Boolean!`, so
102
+ // the call reports whether it ran, not what it selected. Returning the rows here
103
+ // would put an array behind a field the schema promises is a boolean.
100
104
  export const callStoredProcedure = async (
101
105
  sp: ProcedureResolver,
102
- variablesDefinition: VariableDefinition[],
103
106
  variables: Record<string, unknown>,
104
107
  ) => {
105
108
  try {
106
- const data = await executeQuery(
107
- `CALL ${sp.dottedQuotedName}(${Object.keys(variables)
108
- .map((_, i) => `$${i + 1}`)
109
- .join(", ")});`,
109
+ const args = orderProcedureArguments(sp, variables);
110
+
111
+ await executeQuery(
112
+ `CALL ${sp.dottedQuotedName}(${args.map((_, i) => `$${i + 1}`).join(", ")});`,
110
113
  sp.db!,
111
- variablesDefinition,
114
+ args,
112
115
  variables,
113
116
  );
114
117
 
115
- return data;
118
+ return true;
116
119
  } catch {
117
120
  return false;
118
121
  }
@@ -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, PageLimits } from "../../../common";
7
+ import type { GroupByInfo, PageLimits, QueryPathFrame } from "../../../common";
8
8
 
9
9
  import {
10
10
  buildOrderByClauseMySQL,
@@ -105,7 +105,7 @@ const buildGroupedQuery = (
105
105
  selectClauses.push(`'${agg.nameResolved}', ${cteAlias}.${agg.alias}`);
106
106
  } else {
107
107
  selectClauses.push(
108
- `'${agg.nameResolved}', JSON_OBJECT('${agg.fieldAlias}', COALESCE(${cteAlias}.${agg.alias}, NULL))`,
108
+ `'${agg.nameResolved}', JSON_OBJECT('${agg.fieldAlias}', ${cteAlias}.${agg.alias})`,
109
109
  );
110
110
  }
111
111
  });
@@ -257,6 +257,7 @@ export const buildSQLForField = (
257
257
  level: number,
258
258
  aliasMap: { [alias: string]: string },
259
259
  pageLimits: PageLimits | null,
260
+ ancestors: readonly QueryPathFrame[] = [],
260
261
  ): string => {
261
262
  const tableAlias = generateTableAlias(level);
262
263
 
@@ -282,6 +283,7 @@ export const buildSQLForField = (
282
283
  parentTableAlias,
283
284
  level,
284
285
  aliasMap,
286
+ ancestors,
285
287
  );
286
288
 
287
289
  // Check if this is a GROUP BY query
@@ -322,6 +324,7 @@ export const buildSQLForField = (
322
324
  level,
323
325
  aliasMap,
324
326
  pageLimits,
327
+ [...ancestors, { table: resolverName, alias: tableAlias }],
325
328
  ),
326
329
  ([name, selector]) => `'${name}', ${selector}`,
327
330
  );
@@ -7,6 +7,7 @@ import type { ProcedureResolver } from "../../../types/db";
7
7
 
8
8
  import { BunSQLConnectionOptionsZod } from "../../../config/types/db";
9
9
  import { databasesConnections } from "../../../singletons/databases";
10
+ import { orderProcedureArguments } from "../../core/procedure-arguments";
10
11
  import { getQueryTimeoutMs } from "../../../singletons/queryTimeout";
11
12
 
12
13
  // Every pool bound comes from the schema, which is the only place they are
@@ -138,22 +139,24 @@ export const executeQueryJSONFactory =
138
139
  export const executeQueryJSON = executeQueryJSONFactory();
139
140
  export const executeQueryJSONSingle = executeQueryJSONFactory(true);
140
141
 
142
+ // The generated schema types every stored procedure mutation as `Boolean!`, so
143
+ // the call reports whether it ran, not what it selected. Returning the rows here
144
+ // would put an array behind a field the schema promises is a boolean.
141
145
  export const callStoredProcedure = async (
142
146
  sp: ProcedureResolver,
143
- variablesDefinition: VariableDefinition[],
144
147
  variables: Record<string, unknown>,
145
148
  ) => {
146
149
  try {
147
- const data = await executeQuery(
148
- `SELECT * FROM ${sp.dottedQuotedName}(${Object.keys(variables)
149
- .map((_, i) => `$${i + 1}`)
150
- .join(", ")});`,
150
+ const args = orderProcedureArguments(sp, variables);
151
+
152
+ await executeQuery(
153
+ `SELECT * FROM ${sp.dottedQuotedName}(${args.map((_, i) => `$${i + 1}`).join(", ")});`,
151
154
  sp.db!,
152
- variablesDefinition,
155
+ args,
153
156
  variables,
154
157
  );
155
158
 
156
- return data;
159
+ return true;
157
160
  } catch {
158
161
  return false;
159
162
  }
@@ -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, PageLimits } from "../../../common";
7
+ import type { GroupByInfo, PageLimits, QueryPathFrame } from "../../../common";
8
8
 
9
9
  import {
10
10
  buildOrderByClausePG,
@@ -101,7 +101,7 @@ const buildGroupedQuery = (
101
101
  selectClauses.push(`'${agg.nameResolved}', ${cteAlias}.${agg.alias}`);
102
102
  } else {
103
103
  selectClauses.push(
104
- `'${agg.nameResolved}', json_build_object('${agg.fieldAlias}', COALESCE(${cteAlias}.${agg.alias}, null))`,
104
+ `'${agg.nameResolved}', json_build_object('${agg.fieldAlias}', ${cteAlias}.${agg.alias})`,
105
105
  );
106
106
  }
107
107
  });
@@ -242,6 +242,7 @@ export const buildSQLForField = (
242
242
  level: number,
243
243
  aliasMap: { [alias: string]: string },
244
244
  pageLimits: PageLimits | null,
245
+ ancestors: readonly QueryPathFrame[] = [],
245
246
  ): string => {
246
247
  const tableAlias = generateTableAlias(level);
247
248
 
@@ -267,6 +268,7 @@ export const buildSQLForField = (
267
268
  parentTableAlias,
268
269
  level,
269
270
  aliasMap,
271
+ ancestors,
270
272
  );
271
273
 
272
274
  // Check if this is a GROUP BY query
@@ -307,6 +309,7 @@ export const buildSQLForField = (
307
309
  level,
308
310
  aliasMap,
309
311
  pageLimits,
312
+ [...ancestors, { table: resolverName, alias: tableAlias }],
310
313
  ),
311
314
  ([name, selector]) => `'${name}', ${selector}`,
312
315
  );