@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
@@ -48,6 +48,44 @@ export const getDatabasesStructure = async (
48
48
  // do-not-serve list, so a name spelled in the wrong case served the table.
49
49
  const excluded = new Set((db.schema?.excludedTables ?? []).map((name) => name.toLowerCase()));
50
50
 
51
+ // Normalising the ancestor to introspected casing here is what lets the query
52
+ // builder match it with a plain === against the entity it resolves at runtime.
53
+ // Resolution is per-database by construction, so a cross-database ancestor
54
+ // cannot resolve — which is correct, nothing joins across connections.
55
+ const resolveAncestor = (ancestor: { schema: string; name: string; column: string }) => {
56
+ const ancestorTable = tables.find(
57
+ (a) =>
58
+ `${ancestor.schema}_${ancestor.name}`.toLowerCase() ===
59
+ `${a.schema}_${a.name}`.toLowerCase(),
60
+ );
61
+
62
+ if (!ancestorTable) {
63
+ throw new Error(
64
+ `Ancestor table ${ancestor.schema}_${ancestor.name} not found in database ${db.name}`,
65
+ );
66
+ }
67
+
68
+ // An excluded table can never appear in a query path, so the condition could
69
+ // only ever throw at request time. Fail at boot instead.
70
+ if (excluded.has(ancestorTable.schemaName.toLowerCase())) {
71
+ throw new Error(
72
+ `Ancestor table ${ancestor.schema}_${ancestor.name} is in excludedTables and can never be in a query path`,
73
+ );
74
+ }
75
+
76
+ const column = ancestorTable.columns.find(
77
+ (c) => c.name.toLowerCase() === ancestor.column.toLowerCase(),
78
+ );
79
+
80
+ if (!column) {
81
+ throw new Error(
82
+ `Ancestor column ${ancestor.column} not found in table ${ancestorTable.name}`,
83
+ );
84
+ }
85
+
86
+ return { schema: ancestorTable.schema, name: ancestorTable.name, column: column.name };
87
+ };
88
+
51
89
  tableNamesByDatabase[db.name] = tables.map((t) => t.schemaName);
52
90
 
53
91
  const tablesToAdd = tables
@@ -117,6 +155,10 @@ export const getDatabasesStructure = async (
117
155
  };
118
156
  }),
119
157
  conditions: (fk.conditions ?? []).map((cond) => {
158
+ const ancestor = cond.ancestor
159
+ ? { ancestor: resolveAncestor(cond.ancestor) }
160
+ : undefined;
161
+
120
162
  if (cond.source !== undefined) {
121
163
  const sourceColumn = t.columns.find(
122
164
  (col) => col.name.toLowerCase() === cond.source!.toLowerCase(),
@@ -126,7 +168,7 @@ export const getDatabasesStructure = async (
126
168
  throw new Error(`Condition column ${cond.source} not found in table ${t.name}`);
127
169
  }
128
170
 
129
- return { ...cond, source: sourceColumn.name };
171
+ return { ...cond, ...ancestor, source: sourceColumn.name };
130
172
  }
131
173
 
132
174
  const targetColumn = toTable.columns.find(
@@ -139,7 +181,7 @@ export const getDatabasesStructure = async (
139
181
  );
140
182
  }
141
183
 
142
- return { ...cond, target: targetColumn.name };
184
+ return { ...cond, ...ancestor, target: targetColumn.name };
143
185
  }),
144
186
  };
145
187
  });
package/src/index.ts CHANGED
@@ -20,11 +20,17 @@ import { createCapabilityAuthorizer } from "./authentication/capabilities";
20
20
  import { getAgent, instantiateAI } from "./singletons/ai";
21
21
  import { getTokenService, setTokenService } from "./singletons/authentication";
22
22
  import { instantiateCronJobs } from "./singletons/cron";
23
- import { disconnectDatabases, instantiateDatabasesConnections } from "./singletons/databases";
23
+ import {
24
+ databasesConnections,
25
+ disconnectDatabases,
26
+ instantiateDatabasesConnections,
27
+ pingConnection,
28
+ } from "./singletons/databases";
24
29
  import { getCacheRedisClient } from "./singletons/cache/redisClient";
25
30
  import { env } from "./singletons/env";
26
31
  import { setQueryTimeoutMs } from "./singletons/queryTimeout";
27
- import { instantiateQueues } from "./singletons/queues";
32
+ import { setSlowQueryMs } from "./logging/slowQuery";
33
+ import { instantiateQueues, queueManager } from "./singletons/queues";
28
34
  import { ConfigurationZod } from "./types/zod/configuration";
29
35
  import {
30
36
  createMemoryRateLimitStore,
@@ -36,6 +42,12 @@ import { S200, S400, S401, S404, S429 } from "./utils/responses";
36
42
  import { writeSchema } from "./utils/writeSchema";
37
43
  import { logger, configureLogging } from "./logging";
38
44
  import { actorFromSession, audit } from "./logging/audit";
45
+ import { createHealthRoutes } from "./observability/health";
46
+ import { configureMetrics, renderMetrics } from "./observability/metrics";
47
+ import { configureTracing } from "./observability/tracing";
48
+ import { withHttpMetrics } from "./observability/httpMetrics";
49
+ import { withHttpTracing } from "./observability/httpTracing";
50
+ import { createMetricsRoute } from "./observability/metricsRoute";
39
51
 
40
52
  // Re-export for consumers
41
53
  export { configureLogging };
@@ -46,6 +58,8 @@ type RouteHandler =
46
58
 
47
59
  type RoutesMap = Record<string, RouteHandler | Record<string, RouteHandler>>;
48
60
 
61
+ const HEALTH_CHECK_TIMEOUT_MS = 2000;
62
+
49
63
  const renderPlayground = async (filepath: string, replacements: Record<string, string>) => {
50
64
  const path = join(import.meta.dir, filepath);
51
65
  const content = await Bun.file(path).text();
@@ -71,6 +85,8 @@ const generatePrefixes = (options: Env) => ({
71
85
  rest: options.prefix + options.restApiPrefix,
72
86
  openapi: options.prefix + options.openApiEndpoint,
73
87
  console: options.prefix + options.console.endpoint,
88
+ health: options.prefix + "/health",
89
+ metrics: options.prefix + options.metrics.endpoint,
74
90
  });
75
91
 
76
92
  /**
@@ -92,6 +108,18 @@ const bootAnalyzedConfiguration = async (env: Env) => {
92
108
  }
93
109
 
94
110
  setQueryTimeoutMs(env.queryTimeoutMs);
111
+ setSlowQueryMs(env.slowQueryMs);
112
+ configureMetrics({
113
+ enabled: env.metrics.enabled,
114
+ maxOperationLabels: env.metrics.maxOperationLabels,
115
+ });
116
+ configureTracing({
117
+ enabled: env.tracing.enabled,
118
+ endpoint: env.tracing.endpoint,
119
+ headers: env.tracing.headers,
120
+ serviceName: env.tracing.serviceName,
121
+ sampleRatio: env.tracing.sampleRatio,
122
+ });
95
123
 
96
124
  if (env.queryTimeoutMs === 0) {
97
125
  logger("graphoria").warn(
@@ -330,6 +358,51 @@ const createGraphQLServer = async (env: Env) => {
330
358
  routes[prefixes.graphiql] = () => html(graphiqlFile);
331
359
  routes[prefixes.scalar] = () => html(scalarFile);
332
360
 
361
+ // Probes for an orchestrator: no auth, no rate limit. Redis is a dependency
362
+ // only where something reads it — the token repository or the redis cache.
363
+ const redisInUse = projectConfiguration.auth?.enabled || env.cache.store === "redis";
364
+ Object.assign(
365
+ routes,
366
+ createHealthRoutes({
367
+ basePath: prefixes.health,
368
+ timeoutMs: HEALTH_CHECK_TIMEOUT_MS,
369
+ checks: () => [
370
+ ...analyzedConfiguration.databases.map((database) => ({
371
+ kind: "database",
372
+ name: database.name,
373
+ probe: async () => {
374
+ const connection = databasesConnections[database.name];
375
+ if (!connection) return false;
376
+ await pingConnection(connection, database.type);
377
+ return true;
378
+ },
379
+ })),
380
+ ...(redisInUse
381
+ ? [{ kind: "redis", probe: async () => (await getCacheRedisClient().ping()) === "PONG" }]
382
+ : []),
383
+ ...(queueManager?.connections() ?? []).map(({ type, name, connected }) => ({
384
+ kind: type,
385
+ name,
386
+ probe: () => connected,
387
+ })),
388
+ ],
389
+ }),
390
+ );
391
+
392
+ // Prometheus exposition, opt-in via METRICS_ENABLED. Gated: the series name
393
+ // operations, roles and databases.
394
+ if (env.metrics.enabled) {
395
+ Object.assign(
396
+ routes,
397
+ createMetricsRoute({
398
+ path: prefixes.metrics,
399
+ secretHeader: env.admin.header,
400
+ authorize: (candidate) => authorizeCapability(candidate, "metrics"),
401
+ render: renderMetrics,
402
+ }),
403
+ );
404
+ }
405
+
333
406
  // Console (admin UI + status APIs), opt-in via CONSOLE_ENABLED
334
407
  if (env.console.enabled) {
335
408
  const consoleHandler = () => html(consoleHtml);
@@ -352,51 +425,60 @@ const createGraphQLServer = async (env: Env) => {
352
425
  // GraphQL endpoint
353
426
  routes[prefixes.graphql] = {
354
427
  ...(env.enableCors ? { OPTIONS: () => new S200(null) } : {}),
355
- GET: withRateLimit(async (req: Request, server: Bun.Server<unknown>) => {
356
- try {
357
- if (req.headers.get("upgrade") === "websocket") {
358
- const success = server.upgrade(req, {
359
- data: {},
360
- });
361
- return success ? undefined : new Response("WebSocket upgrade error", { status: 400 });
362
- }
363
- return new S404({ error: "Not Found" });
364
- } catch (error) {
365
- return new S400({ errors: [{ message: (error as Error)?.message }] });
366
- }
367
- }),
368
- POST: async (req: BunRequest, server: Bun.Server<unknown>) => {
369
- try {
370
- const { gql, session, limit } = await getRoleHandlers(req, server);
371
- if (limit && !limit.allowed) return new S429(limit.retryAfterMs);
372
-
373
- const { query, variables } = await req.json();
374
-
375
- if (gql.isIntrospectionQuery(query)) return new S200(gql.introspectionResult);
376
-
377
- if (gql.isNoDataQuery(query)) return new S200(gql.noDataResult);
378
-
379
- const { hasErrors, validationErrors } = gql.hasErrors(query, { variables });
380
-
381
- if (hasErrors)
382
- return new S400({
383
- errors: validationErrors.map((error) => ({
384
- message: error.message,
385
- locations: error.locations,
386
- })),
387
- });
428
+ GET: withHttpTracing(
429
+ "graphql",
430
+ withHttpMetrics(
431
+ "graphql",
432
+ withRateLimit(async (req: Request, server: Bun.Server<unknown>) => {
433
+ try {
434
+ if (req.headers.get("upgrade") === "websocket") {
435
+ const success = server.upgrade(req, {
436
+ data: {},
437
+ });
438
+ return success ? undefined : new Response("WebSocket upgrade error", { status: 400 });
439
+ }
440
+ return new S404({ error: "Not Found" });
441
+ } catch (error) {
442
+ return new S400({ errors: [{ message: (error as Error)?.message }] });
443
+ }
444
+ }),
445
+ ),
446
+ ),
447
+ POST: withHttpTracing(
448
+ "graphql",
449
+ withHttpMetrics("graphql", async (req: BunRequest, server: Bun.Server<unknown>) => {
450
+ try {
451
+ const { gql, session, limit } = await getRoleHandlers(req, server);
452
+ if (limit && !limit.allowed) return new S429(limit.retryAfterMs);
453
+
454
+ const { query, variables } = await req.json();
455
+
456
+ if (gql.isIntrospectionQuery(query)) return new S200(gql.introspectionResult);
457
+
458
+ if (gql.isNoDataQuery(query)) return new S200(gql.noDataResult);
459
+
460
+ const { hasErrors, validationErrors } = gql.hasErrors(query, { variables });
461
+
462
+ if (hasErrors)
463
+ return new S400({
464
+ errors: validationErrors.map((error) => ({
465
+ message: error.message,
466
+ locations: error.locations,
467
+ })),
468
+ });
388
469
 
389
- return new S200(await gql.handler(query, variables, req, session));
390
- } catch (error) {
391
- const message = (error as Error)?.message;
470
+ return new S200(await gql.handler(query, variables, req, session));
471
+ } catch (error) {
472
+ const message = (error as Error)?.message;
392
473
 
393
- if (message === "Invalid username or password") {
394
- return new S401({ errors: [{ message }] });
395
- } else {
396
- return new S400({ errors: [{ message }] });
474
+ if (message === "Invalid username or password") {
475
+ return new S401({ errors: [{ message }] });
476
+ } else {
477
+ return new S400({ errors: [{ message }] });
478
+ }
397
479
  }
398
- }
399
- },
480
+ }),
481
+ ),
400
482
  };
401
483
 
402
484
  const aiEnabled = projectConfiguration.ai?.enabled ?? false;
@@ -454,26 +536,29 @@ const createGraphQLServer = async (env: Env) => {
454
536
  }
455
537
 
456
538
  // REST API endpoint
457
- routes[`${prefixes.rest}/*`] = async (req: BunRequest, server: Bun.Server<unknown>) => {
458
- if (req.method === "OPTIONS" && env.enableCors) return new S200(null);
459
-
460
- try {
461
- const { rest, session, limit } = await getRoleHandlers(req, server);
462
- if (limit && !limit.allowed) return new S429(limit.retryAfterMs);
539
+ routes[`${prefixes.rest}/*`] = withHttpTracing(
540
+ "rest",
541
+ withHttpMetrics("rest", async (req: BunRequest, server: Bun.Server<unknown>) => {
542
+ if (req.method === "OPTIONS" && env.enableCors) return new S200(null);
463
543
 
464
- const urlParsed = new URL(req.url);
544
+ try {
545
+ const { rest, session, limit } = await getRoleHandlers(req, server);
546
+ if (limit && !limit.allowed) return new S429(limit.retryAfterMs);
465
547
 
466
- return await rest.handler(
467
- urlParsed,
468
- urlParsed.pathname.replace(prefixes.rest, ""),
469
- req.method,
470
- req,
471
- session,
472
- );
473
- } catch {
474
- return new S400({ errors: [{ message: "Bad request" }] });
475
- }
476
- };
548
+ const urlParsed = new URL(req.url);
549
+
550
+ return await rest.handler(
551
+ urlParsed,
552
+ urlParsed.pathname.replace(prefixes.rest, ""),
553
+ req.method,
554
+ req,
555
+ session,
556
+ );
557
+ } catch {
558
+ return new S400({ errors: [{ message: "Bad request" }] });
559
+ }
560
+ }),
561
+ );
477
562
 
478
563
  // Create WebSocket handler
479
564
  const websocketHandler = websocketHandlerFactory(analyzedConfiguration.roles);
@@ -0,0 +1,59 @@
1
+ import type { Logger } from "pino";
2
+
3
+ import { logger } from "./index";
4
+
5
+ /** The GraphQL operation a statement was generated for. Names only, never values. */
6
+ export type QuerySource = {
7
+ operation: {
8
+ type: "query" | "mutation" | "subscription";
9
+ name: string | null;
10
+ fields: string[];
11
+ };
12
+ role?: string | undefined;
13
+ };
14
+
15
+ export type SlowQueryRecord = {
16
+ durationMs: number;
17
+ dbType: string;
18
+ dbName: string;
19
+ sql?: string;
20
+ procedure?: string;
21
+ outcome: "success" | "error";
22
+ } & Partial<QuerySource>;
23
+
24
+ export type SlowQueryLog = {
25
+ emit(record: SlowQueryRecord & { thresholdMs: number }): void;
26
+ };
27
+
28
+ /**
29
+ * Resolved from `SLOW_QUERY_MS` at boot. `0` — also the value before boot sets
30
+ * it — reports nothing, so an executor used outside a booted server stays quiet.
31
+ */
32
+ let thresholdMs = 0;
33
+
34
+ export const setSlowQueryMs = (ms: number): void => {
35
+ thresholdMs = ms;
36
+ };
37
+
38
+ export const createSlowQueryLog = (base: Logger): SlowQueryLog => ({
39
+ emit: (record) => base.warn(record, "slow query"),
40
+ });
41
+
42
+ let override: SlowQueryLog | null = null;
43
+ let instance: SlowQueryLog | null = null;
44
+
45
+ /** Test seam: pass `null` to restore the default log. */
46
+ export const setSlowQueryLog = (log: SlowQueryLog | null): void => {
47
+ override = log;
48
+ };
49
+
50
+ const slowQueryLog = (): SlowQueryLog => {
51
+ if (override) return override;
52
+ if (!instance) instance = createSlowQueryLog(logger("slow-query"));
53
+ return instance;
54
+ };
55
+
56
+ export const reportQueryDuration = (record: SlowQueryRecord): void => {
57
+ if (thresholdMs === 0 || record.durationMs <= thresholdMs) return;
58
+ slowQueryLog().emit({ ...record, thresholdMs });
59
+ };
@@ -0,0 +1,67 @@
1
+ import { logger } from "../logging";
2
+ import { S200, S503 } from "../utils/responses";
3
+
4
+ export type HealthCheck = {
5
+ kind: string;
6
+ name?: string;
7
+ probe: () => Promise<boolean> | boolean;
8
+ };
9
+
10
+ export type HealthRoutesOptions = {
11
+ basePath: string;
12
+ /** Read on every readiness request, so a check can follow state that changes after boot. */
13
+ checks: () => HealthCheck[];
14
+ timeoutMs: number;
15
+ };
16
+
17
+ const runCheck = async ({ probe }: HealthCheck, timeoutMs: number) => {
18
+ let timer: ReturnType<typeof setTimeout> | undefined;
19
+ try {
20
+ return await Promise.race([
21
+ Promise.resolve().then(probe),
22
+ new Promise<never>((_, reject) => {
23
+ timer = setTimeout(() => reject(new Error(`no answer within ${timeoutMs}ms`)), timeoutMs);
24
+ }),
25
+ ]);
26
+ } finally {
27
+ clearTimeout(timer);
28
+ }
29
+ };
30
+
31
+ /**
32
+ * Unauthenticated probes for an orchestrator. Liveness touches nothing, so a
33
+ * dependency outage never gets the process restarted; readiness pings every
34
+ * dependency and answers 503 while one is down. The body names each check and
35
+ * whether it passed — the reason stays in the log, because a driver error can
36
+ * carry a host and port.
37
+ */
38
+ export const createHealthRoutes = ({ basePath, checks, timeoutMs }: HealthRoutesOptions) => {
39
+ const log = logger("health");
40
+
41
+ const live = () => new S200({ status: "ok" });
42
+
43
+ const ready = async () => {
44
+ const results = await Promise.all(
45
+ checks().map(async (check) => {
46
+ let ok = false;
47
+ try {
48
+ ok = (await runCheck(check, timeoutMs)) === true;
49
+ if (!ok) log.warn({ kind: check.kind, name: check.name }, "readiness check failed");
50
+ } catch (error) {
51
+ log.warn({ kind: check.kind, name: check.name, err: error }, "readiness check failed");
52
+ }
53
+ return { kind: check.kind, ...(check.name ? { name: check.name } : {}), ok };
54
+ }),
55
+ );
56
+
57
+ const healthy = results.every((result) => result.ok);
58
+ const body = { status: healthy ? "ok" : "unavailable", checks: results };
59
+
60
+ return healthy ? new S200(body) : new S503(body);
61
+ };
62
+
63
+ return {
64
+ [`${basePath}/live`]: live,
65
+ [`${basePath}/ready`]: ready,
66
+ };
67
+ };
@@ -0,0 +1,37 @@
1
+ import { incMetric, isMetricsEnabled, observeMetric } from "./metrics";
2
+
3
+ /**
4
+ * Wraps a route handler so the request is counted and timed. The route label is
5
+ * the name of the mounted route, never the URL: a path carries identifiers, and
6
+ * one series per identifier is a cardinality leak.
7
+ */
8
+ export const withHttpMetrics =
9
+ <Req extends Request, Args extends unknown[]>(
10
+ route: string,
11
+ handler: (req: Req, ...args: Args) => Response | undefined | Promise<Response | undefined>,
12
+ ) =>
13
+ async (req: Req, ...args: Args): Promise<Response | undefined> => {
14
+ if (!isMetricsEnabled()) return handler(req, ...args);
15
+
16
+ const startTime = Bun.nanoseconds();
17
+ const method = req.method;
18
+ const record = (status: string) => {
19
+ incMetric("graphoria_http_requests_total", { route, method, status });
20
+ observeMetric(
21
+ "graphoria_http_request_duration_seconds",
22
+ { route, method },
23
+ (Bun.nanoseconds() - startTime) / 1e9,
24
+ );
25
+ };
26
+
27
+ try {
28
+ const response = await handler(req, ...args);
29
+ // A websocket upgrade answers no request, so it is not counted as one.
30
+ if (response) record(String(response.status));
31
+ return response;
32
+ } catch (error) {
33
+ // What Bun answers when a route handler throws.
34
+ record("500");
35
+ throw error;
36
+ }
37
+ };
@@ -0,0 +1,48 @@
1
+ import { isTracingEnabled, parseTraceparent, startSpan, withActiveSpan } from "./tracing";
2
+
3
+ /**
4
+ * Wraps a route handler so the request roots a trace. The span is named for the
5
+ * mounted route, never the URL: a path carries identifiers and a query string
6
+ * carries caller data, and a span name is exported verbatim.
7
+ *
8
+ * An inbound `traceparent` is continued, so a gateway's span and this one join
9
+ * up. `parent: null` is deliberate — a request is a root, never a child of
10
+ * whatever context the server happened to be in.
11
+ */
12
+ export const withHttpTracing =
13
+ <Req extends Request, Args extends unknown[]>(
14
+ route: string,
15
+ handler: (req: Req, ...args: Args) => Response | undefined | Promise<Response | undefined>,
16
+ ) =>
17
+ async (req: Req, ...args: Args): Promise<Response | undefined> => {
18
+ if (!isTracingEnabled()) return handler(req, ...args);
19
+
20
+ const url = new URL(req.url);
21
+ const span = startSpan(`${req.method} ${route}`, {
22
+ kind: "server",
23
+ parent: parseTraceparent(req.headers.get("traceparent")) ?? null,
24
+ attributes: {
25
+ "http.request.method": req.method,
26
+ "http.route": route,
27
+ "url.scheme": url.protocol.replace(":", ""),
28
+ "server.address": url.host,
29
+ },
30
+ });
31
+
32
+ return withActiveSpan(span, async () => {
33
+ try {
34
+ const response = await handler(req, ...args);
35
+ // A websocket upgrade answers no request, so it gets no span: leaving
36
+ // this one unended is what keeps it out of the export.
37
+ if (response) {
38
+ span?.setAttribute("http.response.status_code", response.status);
39
+ span?.end();
40
+ }
41
+ return response;
42
+ } catch (error) {
43
+ span?.recordError(error);
44
+ span?.end();
45
+ throw error;
46
+ }
47
+ });
48
+ };