@graphoria/server 0.4.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 (150) hide show
  1. package/README.md +4 -0
  2. package/dist/src/analyzeQuery/depthLimit.d.ts +1 -13
  3. package/dist/src/analyzeQuery/depthLimit.d.ts.map +1 -1
  4. package/dist/src/authentication/capabilities.d.ts +1 -1
  5. package/dist/src/authentication/capabilities.d.ts.map +1 -1
  6. package/dist/src/authentication/tokenRepository.d.ts.map +1 -1
  7. package/dist/src/config/types/db.d.ts +25 -0
  8. package/dist/src/config/types/db.d.ts.map +1 -1
  9. package/dist/src/configuration/getSchemas/index.d.ts +40 -0
  10. package/dist/src/configuration/getSchemas/index.d.ts.map +1 -1
  11. package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts +20 -0
  12. package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts.map +1 -1
  13. package/dist/src/configuration/gql/handleGraphQLRequestFactory.d.ts.map +1 -1
  14. package/dist/src/configuration/index.d.ts +30 -0
  15. package/dist/src/configuration/index.d.ts.map +1 -1
  16. package/dist/src/console/api.d.ts.map +1 -1
  17. package/dist/src/cron/index.d.ts.map +1 -1
  18. package/dist/src/databases/common.d.ts +20 -6
  19. package/dist/src/databases/common.d.ts.map +1 -1
  20. package/dist/src/databases/core/executor.d.ts +4 -6
  21. package/dist/src/databases/core/executor.d.ts.map +1 -1
  22. package/dist/src/databases/core/function-mapping.d.ts +1 -2
  23. package/dist/src/databases/core/function-mapping.d.ts.map +1 -1
  24. package/dist/src/databases/core/query-builder.d.ts +5 -0
  25. package/dist/src/databases/core/query-builder.d.ts.map +1 -1
  26. package/dist/src/databases/engines/mssql/getStructure.d.ts +5 -0
  27. package/dist/src/databases/engines/mssql/getStructure.d.ts.map +1 -1
  28. package/dist/src/databases/engines/mssql/query/index.d.ts +2 -2
  29. package/dist/src/databases/engines/mssql/query/index.d.ts.map +1 -1
  30. package/dist/src/databases/engines/mysql/getStructure.d.ts +5 -0
  31. package/dist/src/databases/engines/mysql/getStructure.d.ts.map +1 -1
  32. package/dist/src/databases/engines/mysql/query/index.d.ts +2 -2
  33. package/dist/src/databases/engines/mysql/query/index.d.ts.map +1 -1
  34. package/dist/src/databases/engines/postgresql/getStructure.d.ts +5 -0
  35. package/dist/src/databases/engines/postgresql/getStructure.d.ts.map +1 -1
  36. package/dist/src/databases/engines/postgresql/query/index.d.ts +2 -2
  37. package/dist/src/databases/engines/postgresql/query/index.d.ts.map +1 -1
  38. package/dist/src/databases/high-level-operations.d.ts +5 -0
  39. package/dist/src/databases/high-level-operations.d.ts.map +1 -1
  40. package/dist/src/databases/metadata/structure.d.ts +5 -0
  41. package/dist/src/databases/metadata/structure.d.ts.map +1 -1
  42. package/dist/src/databases/transformers/data-transformers.d.ts +30 -0
  43. package/dist/src/databases/transformers/data-transformers.d.ts.map +1 -1
  44. package/dist/src/index.d.ts +4 -0
  45. package/dist/src/index.d.ts.map +1 -1
  46. package/dist/src/logging/slowQuery.d.ts +29 -0
  47. package/dist/src/logging/slowQuery.d.ts.map +1 -0
  48. package/dist/src/logging/slowQuery.test.d.ts +2 -0
  49. package/dist/src/logging/slowQuery.test.d.ts.map +1 -0
  50. package/dist/src/observability/health.d.ts +23 -0
  51. package/dist/src/observability/health.d.ts.map +1 -0
  52. package/dist/src/observability/health.test.d.ts +2 -0
  53. package/dist/src/observability/health.test.d.ts.map +1 -0
  54. package/dist/src/observability/httpMetrics.d.ts +7 -0
  55. package/dist/src/observability/httpMetrics.d.ts.map +1 -0
  56. package/dist/src/observability/httpMetrics.test.d.ts +2 -0
  57. package/dist/src/observability/httpMetrics.test.d.ts.map +1 -0
  58. package/dist/src/observability/httpTracing.d.ts +11 -0
  59. package/dist/src/observability/httpTracing.d.ts.map +1 -0
  60. package/dist/src/observability/httpTracing.test.d.ts +2 -0
  61. package/dist/src/observability/httpTracing.test.d.ts.map +1 -0
  62. package/dist/src/observability/metrics.d.ts +81 -0
  63. package/dist/src/observability/metrics.d.ts.map +1 -0
  64. package/dist/src/observability/metrics.perf.test.d.ts +2 -0
  65. package/dist/src/observability/metrics.perf.test.d.ts.map +1 -0
  66. package/dist/src/observability/metrics.test.d.ts +2 -0
  67. package/dist/src/observability/metrics.test.d.ts.map +1 -0
  68. package/dist/src/observability/metricsRoute.d.ts +19 -0
  69. package/dist/src/observability/metricsRoute.d.ts.map +1 -0
  70. package/dist/src/observability/metricsRoute.test.d.ts +2 -0
  71. package/dist/src/observability/metricsRoute.test.d.ts.map +1 -0
  72. package/dist/src/observability/tracing.d.ts +92 -0
  73. package/dist/src/observability/tracing.d.ts.map +1 -0
  74. package/dist/src/observability/tracing.perf.test.d.ts +2 -0
  75. package/dist/src/observability/tracing.perf.test.d.ts.map +1 -0
  76. package/dist/src/observability/tracing.test.d.ts +2 -0
  77. package/dist/src/observability/tracing.test.d.ts.map +1 -0
  78. package/dist/src/queues/kafka.d.ts +6 -0
  79. package/dist/src/queues/kafka.d.ts.map +1 -1
  80. package/dist/src/queues/kafka.test.d.ts +2 -0
  81. package/dist/src/queues/kafka.test.d.ts.map +1 -0
  82. package/dist/src/queues/rabbitmq.d.ts.map +1 -1
  83. package/dist/src/remoteREST/proxy.d.ts.map +1 -1
  84. package/dist/src/remoteSchemas/proxy.d.ts.map +1 -1
  85. package/dist/src/singletons/cache/redisClient.d.ts +2 -2
  86. package/dist/src/singletons/cache/redisClient.d.ts.map +1 -1
  87. package/dist/src/singletons/databases.d.ts +1 -0
  88. package/dist/src/singletons/databases.d.ts.map +1 -1
  89. package/dist/src/singletons/env.d.ts +29 -0
  90. package/dist/src/singletons/env.d.ts.map +1 -1
  91. package/dist/src/singletons/queues.d.ts +1 -0
  92. package/dist/src/singletons/queues.d.ts.map +1 -1
  93. package/dist/src/subscriptions/strategies/database.d.ts.map +1 -1
  94. package/dist/src/subscriptions/utils/polling.d.ts +1 -0
  95. package/dist/src/subscriptions/utils/polling.d.ts.map +1 -1
  96. package/dist/src/subscriptions/utils/polling.test.d.ts +2 -0
  97. package/dist/src/subscriptions/utils/polling.test.d.ts.map +1 -0
  98. package/dist/src/types/env.d.ts +89 -0
  99. package/dist/src/types/env.d.ts.map +1 -1
  100. package/dist/src/types/zod/configuration.d.ts +25 -0
  101. package/dist/src/types/zod/configuration.d.ts.map +1 -1
  102. package/dist/src/types/zod/db.d.ts +85 -0
  103. package/dist/src/types/zod/db.d.ts.map +1 -1
  104. package/dist/src/utils/rateLimit.d.ts.map +1 -1
  105. package/dist/src/utils/redis.d.ts +17 -0
  106. package/dist/src/utils/redis.d.ts.map +1 -0
  107. package/dist/src/utils/redis.test.d.ts +2 -0
  108. package/dist/src/utils/redis.test.d.ts.map +1 -0
  109. package/dist/src/utils/responses.d.ts +3 -0
  110. package/dist/src/utils/responses.d.ts.map +1 -1
  111. package/dist/tsconfig.tsbuildinfo +1 -1
  112. package/package.json +4 -1
  113. package/playgrounds/console/index.html +8 -8
  114. package/playgrounds/graphiql/index.html +114 -114
  115. package/playgrounds/scalar/index.html +1 -1
  116. package/src/analyzeQuery/depthLimit.ts +15 -5
  117. package/src/authentication/capabilities.ts +3 -1
  118. package/src/authentication/tokenRepository.ts +2 -3
  119. package/src/config/types/db.ts +16 -2
  120. package/src/configuration/gql/handleGraphQLRequestFactory.ts +233 -152
  121. package/src/console/api.ts +1 -6
  122. package/src/cron/index.ts +59 -36
  123. package/src/databases/common.ts +109 -10
  124. package/src/databases/core/executor.ts +96 -18
  125. package/src/databases/core/function-mapping.ts +1 -1
  126. package/src/databases/engines/mssql/query/index.ts +4 -1
  127. package/src/databases/engines/mysql/query/index.ts +5 -2
  128. package/src/databases/engines/postgresql/query/index.ts +5 -2
  129. package/src/databases/high-level-operations.ts +44 -2
  130. package/src/index.ts +147 -62
  131. package/src/logging/slowQuery.ts +59 -0
  132. package/src/observability/health.ts +67 -0
  133. package/src/observability/httpMetrics.ts +37 -0
  134. package/src/observability/httpTracing.ts +48 -0
  135. package/src/observability/metrics.ts +243 -0
  136. package/src/observability/metricsRoute.ts +33 -0
  137. package/src/observability/tracing.ts +421 -0
  138. package/src/queues/kafka.ts +45 -2
  139. package/src/queues/rabbitmq.ts +37 -1
  140. package/src/remoteREST/proxy.ts +17 -0
  141. package/src/remoteSchemas/proxy.ts +17 -0
  142. package/src/singletons/cache/redisClient.ts +4 -5
  143. package/src/singletons/databases.ts +5 -0
  144. package/src/singletons/queues.ts +9 -3
  145. package/src/subscriptions/strategies/database.ts +2 -0
  146. package/src/subscriptions/utils/polling.ts +80 -21
  147. package/src/types/env.ts +27 -0
  148. package/src/utils/rateLimit.ts +6 -1
  149. package/src/utils/redis.ts +71 -0
  150. package/src/utils/responses.ts +6 -0
package/src/cron/index.ts CHANGED
@@ -7,6 +7,8 @@ import type { TickContext } from "../types/zod/cron";
7
7
  import { databasesConnections, repositoryMap } from "../singletons/databases";
8
8
  import { queueManager } from "../singletons/queues";
9
9
  import { logger } from "../logging";
10
+ import { incMetric, observeMetric } from "../observability/metrics";
11
+ import { startSpan, withActiveSpan } from "../observability/tracing";
10
12
 
11
13
  /**
12
14
  * Interface for a scheduled cron job instance
@@ -58,42 +60,63 @@ const createScheduledJob = (config: CronJob, gqlQuery: GqlQueryFn<true>): Schedu
58
60
  context: config.context,
59
61
  },
60
62
  async (self) => {
61
- try {
62
- log.info("executing");
63
-
64
- executionCount++;
65
-
66
- // Execute query if provided
67
- const response = config.query
68
- ? await gqlQuery(config.query, config.variables ?? {})
69
- : undefined;
70
-
71
- const tickContext: TickContext = {
72
- name: config.name,
73
- pattern: config.pattern,
74
- variables: config.variables || {},
75
- executionCount,
76
- nextRun: self.nextRun(),
77
- previousRun: self.previousRun(),
78
- };
79
-
80
- // Call the onTick callback if provided, passing options object
81
- await config.onTick?.(
82
- {
83
- gqlQuery,
84
- databases: databasesConnections,
85
- queues: queueManager,
86
- repository: repositoryMap,
87
- },
88
- tickContext,
89
- response,
90
- );
91
-
92
- log.info({ executionCount, nextRun: self.nextRun() }, "completed");
93
- } catch (error) {
94
- log.error({ err: error }, "failed");
95
- throw error;
96
- }
63
+ const startTime = Bun.nanoseconds();
64
+ let outcome: "success" | "error" = "success";
65
+ // A tick is not inside a request, so it roots its own trace. The span is
66
+ // entered so anything `onTick` runs through `gqlQuery` lands under it.
67
+ const span = startSpan("cron.tick", {
68
+ parent: null,
69
+ attributes: { "graphoria.cron.job": config.name },
70
+ });
71
+
72
+ return withActiveSpan(span, async () => {
73
+ try {
74
+ log.info("executing");
75
+
76
+ executionCount++;
77
+
78
+ // Execute query if provided
79
+ const response = config.query
80
+ ? await gqlQuery(config.query, config.variables ?? {})
81
+ : undefined;
82
+
83
+ const tickContext: TickContext = {
84
+ name: config.name,
85
+ pattern: config.pattern,
86
+ variables: config.variables || {},
87
+ executionCount,
88
+ nextRun: self.nextRun(),
89
+ previousRun: self.previousRun(),
90
+ };
91
+
92
+ // Call the onTick callback if provided, passing options object
93
+ await config.onTick?.(
94
+ {
95
+ gqlQuery,
96
+ databases: databasesConnections,
97
+ queues: queueManager,
98
+ repository: repositoryMap,
99
+ },
100
+ tickContext,
101
+ response,
102
+ );
103
+
104
+ log.info({ executionCount, nextRun: self.nextRun() }, "completed");
105
+ } catch (error) {
106
+ outcome = "error";
107
+ log.error({ err: error }, "failed");
108
+ span?.recordError(error);
109
+ throw error;
110
+ } finally {
111
+ incMetric("graphoria_cron_runs_total", { job: config.name, outcome });
112
+ observeMetric(
113
+ "graphoria_cron_run_duration_seconds",
114
+ { job: config.name },
115
+ (Bun.nanoseconds() - startTime) / 1e9,
116
+ );
117
+ span?.end();
118
+ }
119
+ });
97
120
  },
98
121
  );
99
122
 
@@ -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,16 +116,17 @@ 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
  };
@@ -79,6 +134,7 @@ export const executeQueryJSON = async <T>(
79
134
  export const callStoredProcedure = async (
80
135
  sp: ProcedureResolver,
81
136
  variables: Record<string, unknown> = {},
137
+ source?: QuerySource,
82
138
  ) => {
83
139
  const adapter = databaseAdapters[sp.db!.type];
84
140
  if (!adapter) {
@@ -91,16 +147,38 @@ export const callStoredProcedure = async (
91
147
  procedure: sp.dottedName,
92
148
  });
93
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
+ });
94
168
 
95
169
  try {
96
170
  const result = await adapter.callStoredProcedure(sp, variables);
97
- log.debug({ durationMs: (Bun.nanoseconds() - startTime) / 1e6 }, "stored procedure executed");
171
+ const durationMs = (Bun.nanoseconds() - startTime) / 1e6;
172
+ log.debug({ durationMs }, "stored procedure executed");
173
+ report(durationMs, "success");
174
+ span?.end();
98
175
  return result;
99
176
  } catch (error) {
100
- log.error(
101
- { err: error, durationMs: (Bun.nanoseconds() - startTime) / 1e6 },
102
- "stored procedure failed",
103
- );
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();
104
182
  throw error;
105
183
  }
106
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,
@@ -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
  );
@@ -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
  );
@@ -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
  );