@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.
- package/README.md +4 -0
- package/dist/src/analyzeQuery/depthLimit.d.ts +1 -13
- package/dist/src/analyzeQuery/depthLimit.d.ts.map +1 -1
- package/dist/src/authentication/capabilities.d.ts +1 -1
- package/dist/src/authentication/capabilities.d.ts.map +1 -1
- package/dist/src/authentication/tokenRepository.d.ts.map +1 -1
- package/dist/src/config/types/db.d.ts +25 -0
- package/dist/src/config/types/db.d.ts.map +1 -1
- package/dist/src/configuration/getSchemas/index.d.ts +40 -0
- package/dist/src/configuration/getSchemas/index.d.ts.map +1 -1
- package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts +20 -0
- package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts.map +1 -1
- package/dist/src/configuration/gql/handleGraphQLRequestFactory.d.ts.map +1 -1
- package/dist/src/configuration/index.d.ts +30 -0
- package/dist/src/configuration/index.d.ts.map +1 -1
- package/dist/src/console/api.d.ts.map +1 -1
- package/dist/src/cron/index.d.ts.map +1 -1
- package/dist/src/databases/common.d.ts +20 -6
- package/dist/src/databases/common.d.ts.map +1 -1
- package/dist/src/databases/core/executor.d.ts +4 -6
- package/dist/src/databases/core/executor.d.ts.map +1 -1
- package/dist/src/databases/core/function-mapping.d.ts +1 -2
- package/dist/src/databases/core/function-mapping.d.ts.map +1 -1
- package/dist/src/databases/core/query-builder.d.ts +5 -0
- package/dist/src/databases/core/query-builder.d.ts.map +1 -1
- package/dist/src/databases/engines/mssql/getStructure.d.ts +5 -0
- package/dist/src/databases/engines/mssql/getStructure.d.ts.map +1 -1
- package/dist/src/databases/engines/mssql/query/index.d.ts +2 -2
- package/dist/src/databases/engines/mssql/query/index.d.ts.map +1 -1
- package/dist/src/databases/engines/mysql/getStructure.d.ts +5 -0
- package/dist/src/databases/engines/mysql/getStructure.d.ts.map +1 -1
- package/dist/src/databases/engines/mysql/query/index.d.ts +2 -2
- package/dist/src/databases/engines/mysql/query/index.d.ts.map +1 -1
- package/dist/src/databases/engines/postgresql/getStructure.d.ts +5 -0
- package/dist/src/databases/engines/postgresql/getStructure.d.ts.map +1 -1
- package/dist/src/databases/engines/postgresql/query/index.d.ts +2 -2
- package/dist/src/databases/engines/postgresql/query/index.d.ts.map +1 -1
- package/dist/src/databases/high-level-operations.d.ts +5 -0
- package/dist/src/databases/high-level-operations.d.ts.map +1 -1
- package/dist/src/databases/metadata/structure.d.ts +5 -0
- package/dist/src/databases/metadata/structure.d.ts.map +1 -1
- package/dist/src/databases/transformers/data-transformers.d.ts +30 -0
- package/dist/src/databases/transformers/data-transformers.d.ts.map +1 -1
- package/dist/src/index.d.ts +4 -0
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/logging/slowQuery.d.ts +29 -0
- package/dist/src/logging/slowQuery.d.ts.map +1 -0
- package/dist/src/logging/slowQuery.test.d.ts +2 -0
- package/dist/src/logging/slowQuery.test.d.ts.map +1 -0
- package/dist/src/observability/health.d.ts +23 -0
- package/dist/src/observability/health.d.ts.map +1 -0
- package/dist/src/observability/health.test.d.ts +2 -0
- package/dist/src/observability/health.test.d.ts.map +1 -0
- package/dist/src/observability/httpMetrics.d.ts +7 -0
- package/dist/src/observability/httpMetrics.d.ts.map +1 -0
- package/dist/src/observability/httpMetrics.test.d.ts +2 -0
- package/dist/src/observability/httpMetrics.test.d.ts.map +1 -0
- package/dist/src/observability/httpTracing.d.ts +11 -0
- package/dist/src/observability/httpTracing.d.ts.map +1 -0
- package/dist/src/observability/httpTracing.test.d.ts +2 -0
- package/dist/src/observability/httpTracing.test.d.ts.map +1 -0
- package/dist/src/observability/metrics.d.ts +81 -0
- package/dist/src/observability/metrics.d.ts.map +1 -0
- package/dist/src/observability/metrics.perf.test.d.ts +2 -0
- package/dist/src/observability/metrics.perf.test.d.ts.map +1 -0
- package/dist/src/observability/metrics.test.d.ts +2 -0
- package/dist/src/observability/metrics.test.d.ts.map +1 -0
- package/dist/src/observability/metricsRoute.d.ts +19 -0
- package/dist/src/observability/metricsRoute.d.ts.map +1 -0
- package/dist/src/observability/metricsRoute.test.d.ts +2 -0
- package/dist/src/observability/metricsRoute.test.d.ts.map +1 -0
- package/dist/src/observability/tracing.d.ts +92 -0
- package/dist/src/observability/tracing.d.ts.map +1 -0
- package/dist/src/observability/tracing.perf.test.d.ts +2 -0
- package/dist/src/observability/tracing.perf.test.d.ts.map +1 -0
- package/dist/src/observability/tracing.test.d.ts +2 -0
- package/dist/src/observability/tracing.test.d.ts.map +1 -0
- package/dist/src/queues/kafka.d.ts +6 -0
- package/dist/src/queues/kafka.d.ts.map +1 -1
- package/dist/src/queues/kafka.test.d.ts +2 -0
- package/dist/src/queues/kafka.test.d.ts.map +1 -0
- package/dist/src/queues/rabbitmq.d.ts.map +1 -1
- package/dist/src/remoteREST/proxy.d.ts.map +1 -1
- package/dist/src/remoteSchemas/proxy.d.ts.map +1 -1
- package/dist/src/singletons/cache/redisClient.d.ts +2 -2
- package/dist/src/singletons/cache/redisClient.d.ts.map +1 -1
- package/dist/src/singletons/databases.d.ts +1 -0
- package/dist/src/singletons/databases.d.ts.map +1 -1
- package/dist/src/singletons/env.d.ts +29 -0
- package/dist/src/singletons/env.d.ts.map +1 -1
- package/dist/src/singletons/queues.d.ts +1 -0
- package/dist/src/singletons/queues.d.ts.map +1 -1
- package/dist/src/subscriptions/strategies/database.d.ts.map +1 -1
- package/dist/src/subscriptions/utils/polling.d.ts +1 -0
- package/dist/src/subscriptions/utils/polling.d.ts.map +1 -1
- package/dist/src/subscriptions/utils/polling.test.d.ts +2 -0
- package/dist/src/subscriptions/utils/polling.test.d.ts.map +1 -0
- package/dist/src/types/env.d.ts +89 -0
- package/dist/src/types/env.d.ts.map +1 -1
- package/dist/src/types/zod/configuration.d.ts +25 -0
- package/dist/src/types/zod/configuration.d.ts.map +1 -1
- package/dist/src/types/zod/db.d.ts +85 -0
- package/dist/src/types/zod/db.d.ts.map +1 -1
- package/dist/src/utils/rateLimit.d.ts.map +1 -1
- package/dist/src/utils/redis.d.ts +17 -0
- package/dist/src/utils/redis.d.ts.map +1 -0
- package/dist/src/utils/redis.test.d.ts +2 -0
- package/dist/src/utils/redis.test.d.ts.map +1 -0
- package/dist/src/utils/responses.d.ts +3 -0
- package/dist/src/utils/responses.d.ts.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +4 -1
- package/playgrounds/console/index.html +8 -8
- package/playgrounds/graphiql/index.html +114 -114
- package/playgrounds/scalar/index.html +1 -1
- package/src/analyzeQuery/depthLimit.ts +15 -5
- package/src/authentication/capabilities.ts +3 -1
- package/src/authentication/tokenRepository.ts +2 -3
- package/src/config/types/db.ts +16 -2
- package/src/configuration/gql/handleGraphQLRequestFactory.ts +233 -152
- package/src/console/api.ts +1 -6
- package/src/cron/index.ts +59 -36
- package/src/databases/common.ts +109 -10
- package/src/databases/core/executor.ts +96 -18
- package/src/databases/core/function-mapping.ts +1 -1
- package/src/databases/engines/mssql/query/index.ts +4 -1
- package/src/databases/engines/mysql/query/index.ts +5 -2
- package/src/databases/engines/postgresql/query/index.ts +5 -2
- package/src/databases/high-level-operations.ts +44 -2
- package/src/index.ts +147 -62
- package/src/logging/slowQuery.ts +59 -0
- package/src/observability/health.ts +67 -0
- package/src/observability/httpMetrics.ts +37 -0
- package/src/observability/httpTracing.ts +48 -0
- package/src/observability/metrics.ts +243 -0
- package/src/observability/metricsRoute.ts +33 -0
- package/src/observability/tracing.ts +421 -0
- package/src/queues/kafka.ts +45 -2
- package/src/queues/rabbitmq.ts +37 -1
- package/src/remoteREST/proxy.ts +17 -0
- package/src/remoteSchemas/proxy.ts +17 -0
- package/src/singletons/cache/redisClient.ts +4 -5
- package/src/singletons/databases.ts +5 -0
- package/src/singletons/queues.ts +9 -3
- package/src/subscriptions/strategies/database.ts +2 -0
- package/src/subscriptions/utils/polling.ts +80 -21
- package/src/types/env.ts +27 -0
- package/src/utils/rateLimit.ts +6 -1
- package/src/utils/redis.ts +71 -0
- 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 {
|
|
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 {
|
|
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:
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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
|
-
|
|
390
|
-
|
|
391
|
-
|
|
470
|
+
return new S200(await gql.handler(query, variables, req, session));
|
|
471
|
+
} catch (error) {
|
|
472
|
+
const message = (error as Error)?.message;
|
|
392
473
|
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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}/*`] =
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
|
|
544
|
+
try {
|
|
545
|
+
const { rest, session, limit } = await getRoleHandlers(req, server);
|
|
546
|
+
if (limit && !limit.allowed) return new S429(limit.retryAfterMs);
|
|
465
547
|
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
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
|
+
};
|