@graphoria/server 0.2.2 → 0.3.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 +25 -20
- package/dist/src/ai/tools/core.d.ts +6 -1
- package/dist/src/ai/tools/core.d.ts.map +1 -1
- package/dist/src/ai/tools/core.test.d.ts +2 -0
- package/dist/src/ai/tools/core.test.d.ts.map +1 -0
- package/dist/src/analyzeQuery/costLimit.d.ts +30 -0
- package/dist/src/analyzeQuery/costLimit.d.ts.map +1 -0
- package/dist/src/analyzeQuery/costLimit.test.d.ts +2 -0
- package/dist/src/analyzeQuery/costLimit.test.d.ts.map +1 -0
- package/dist/src/analyzeQuery/depthLimit.d.ts.map +1 -1
- package/dist/src/analyzeQuery/fragments.d.ts +6 -0
- package/dist/src/analyzeQuery/fragments.d.ts.map +1 -0
- package/dist/src/config/types/ai.d.ts +0 -6
- package/dist/src/config/types/ai.d.ts.map +1 -1
- package/dist/src/config/types/auth.d.ts +8 -29
- package/dist/src/config/types/auth.d.ts.map +1 -1
- package/dist/src/config/types/cron.d.ts +0 -94
- package/dist/src/config/types/cron.d.ts.map +1 -1
- package/dist/src/config/types/db.d.ts +2 -87
- package/dist/src/config/types/db.d.ts.map +1 -1
- package/dist/src/config/types/operation.d.ts +4 -0
- package/dist/src/config/types/operation.d.ts.map +1 -1
- package/dist/src/config/types/operation.test.d.ts +2 -0
- package/dist/src/config/types/operation.test.d.ts.map +1 -0
- package/dist/src/config/types/queue.d.ts +0 -47
- package/dist/src/config/types/queue.d.ts.map +1 -1
- package/dist/src/config/types/remote-rest.d.ts +0 -9
- package/dist/src/config/types/remote-rest.d.ts.map +1 -1
- package/dist/src/config/types/remote-schema.d.ts +0 -10
- package/dist/src/config/types/remote-schema.d.ts.map +1 -1
- package/dist/src/configuration/crossReferences.d.ts +27 -0
- package/dist/src/configuration/crossReferences.d.ts.map +1 -0
- package/dist/src/configuration/crossReferences.test.d.ts +2 -0
- package/dist/src/configuration/crossReferences.test.d.ts.map +1 -0
- package/dist/src/configuration/getSchemas/index.d.ts +70 -4
- package/dist/src/configuration/getSchemas/index.d.ts.map +1 -1
- package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts +3 -0
- package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts.map +1 -1
- package/dist/src/configuration/gql/handleGraphQLRequestFactory.d.ts +32 -2
- package/dist/src/configuration/gql/handleGraphQLRequestFactory.d.ts.map +1 -1
- package/dist/src/configuration/gql/handleGraphQLSubscriptionFactory.d.ts.map +1 -1
- package/dist/src/configuration/index.d.ts +41 -2
- package/dist/src/configuration/index.d.ts.map +1 -1
- package/dist/src/configuration/rest/handleRESTRequestFactory.d.ts.map +1 -1
- package/dist/src/configuration/rest/index.d.ts.map +1 -1
- package/dist/src/console/api.d.ts +7 -5
- package/dist/src/console/api.d.ts.map +1 -1
- package/dist/src/console/session.d.ts +21 -0
- package/dist/src/console/session.d.ts.map +1 -0
- package/dist/src/console/session.test.d.ts +2 -0
- package/dist/src/console/session.test.d.ts.map +1 -0
- package/dist/src/cron/index.d.ts +1 -1
- package/dist/src/cron/index.d.ts.map +1 -1
- package/dist/src/cron/index.test.d.ts +2 -0
- package/dist/src/cron/index.test.d.ts.map +1 -0
- package/dist/src/databases/common.d.ts +12 -4
- package/dist/src/databases/common.d.ts.map +1 -1
- package/dist/src/databases/core/executor.d.ts +6 -2
- package/dist/src/databases/core/executor.d.ts.map +1 -1
- package/dist/src/databases/core/function-mapping.d.ts +4 -3
- package/dist/src/databases/core/function-mapping.d.ts.map +1 -1
- package/dist/src/databases/core/query-builder.d.ts +7 -1
- package/dist/src/databases/core/query-builder.d.ts.map +1 -1
- package/dist/src/databases/engines/mssql/connection.d.ts +29 -6
- package/dist/src/databases/engines/mssql/connection.d.ts.map +1 -1
- package/dist/src/databases/engines/mssql/connection.test.d.ts +2 -0
- package/dist/src/databases/engines/mssql/connection.test.d.ts.map +1 -0
- package/dist/src/databases/engines/mssql/format.d.ts +2 -1
- package/dist/src/databases/engines/mssql/format.d.ts.map +1 -1
- package/dist/src/databases/engines/mssql/query/index.d.ts +3 -2
- package/dist/src/databases/engines/mssql/query/index.d.ts.map +1 -1
- package/dist/src/databases/engines/mysql/connection.d.ts +16 -0
- package/dist/src/databases/engines/mysql/connection.d.ts.map +1 -1
- package/dist/src/databases/engines/mysql/connection.test.d.ts +2 -0
- package/dist/src/databases/engines/mysql/connection.test.d.ts.map +1 -0
- package/dist/src/databases/engines/mysql/format.d.ts +2 -1
- package/dist/src/databases/engines/mysql/format.d.ts.map +1 -1
- package/dist/src/databases/engines/mysql/query/index.d.ts +3 -2
- package/dist/src/databases/engines/mysql/query/index.d.ts.map +1 -1
- package/dist/src/databases/engines/postgresql/connection.d.ts +25 -6
- package/dist/src/databases/engines/postgresql/connection.d.ts.map +1 -1
- package/dist/src/databases/engines/postgresql/connection.test.d.ts +2 -0
- package/dist/src/databases/engines/postgresql/connection.test.d.ts.map +1 -0
- package/dist/src/databases/engines/postgresql/format.d.ts +2 -1
- package/dist/src/databases/engines/postgresql/format.d.ts.map +1 -1
- package/dist/src/databases/engines/postgresql/query/index.d.ts +3 -2
- package/dist/src/databases/engines/postgresql/query/index.d.ts.map +1 -1
- package/dist/src/databases/high-level-operations.d.ts +2 -0
- package/dist/src/databases/high-level-operations.d.ts.map +1 -1
- package/dist/src/databases/transformers/data-transformers.d.ts +2 -0
- package/dist/src/databases/transformers/data-transformers.d.ts.map +1 -1
- package/dist/src/index.d.ts.map +1 -1
- package/dist/src/singletons/env.d.ts +19 -0
- package/dist/src/singletons/env.d.ts.map +1 -1
- package/dist/src/singletons/queryTimeout.d.ts +3 -0
- package/dist/src/singletons/queryTimeout.d.ts.map +1 -0
- package/dist/src/subscriptions/strategies/database.d.ts.map +1 -1
- package/dist/src/subscriptions/strategies/database.test.d.ts +2 -0
- package/dist/src/subscriptions/strategies/database.test.d.ts.map +1 -0
- package/dist/src/subscriptions/types.d.ts +4 -1
- package/dist/src/subscriptions/types.d.ts.map +1 -1
- package/dist/src/subscriptions/utils/polling.d.ts.map +1 -1
- package/dist/src/types/env.d.ts +73 -0
- package/dist/src/types/env.d.ts.map +1 -1
- package/dist/src/types/zod/auth.d.ts +12 -0
- package/dist/src/types/zod/auth.d.ts.map +1 -1
- package/dist/src/types/zod/configuration.d.ts +28 -0
- package/dist/src/types/zod/configuration.d.ts.map +1 -1
- package/dist/src/utils/rateLimit.d.ts +65 -0
- package/dist/src/utils/rateLimit.d.ts.map +1 -0
- package/dist/src/utils/rateLimit.test.d.ts +2 -0
- package/dist/src/utils/rateLimit.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 +13 -13
- package/playgrounds/console/index.html +7 -7
- package/playgrounds/graphiql/index.html +535 -536
- package/playgrounds/scalar/index.html +1 -1
- package/src/ai/tools/core.ts +30 -6
- package/src/analyzeQuery/costLimit.ts +236 -0
- package/src/analyzeQuery/depthLimit.ts +4 -23
- package/src/analyzeQuery/fragments.ts +18 -0
- package/src/config/types/auth.ts +7 -0
- package/src/config/types/db.ts +7 -0
- package/src/config/types/operation.ts +4 -0
- package/src/configuration/crossReferences.ts +340 -0
- package/src/configuration/getSchemas/type-definition-generator/index.ts +1 -1
- package/src/configuration/gql/buildExecute.ts +1 -1
- package/src/configuration/gql/handleGraphQLRequestFactory.ts +80 -10
- package/src/configuration/gql/handleGraphQLSubscriptionFactory.ts +22 -2
- package/src/configuration/index.ts +24 -6
- package/src/configuration/rest/handleRESTRequestFactory.ts +6 -4
- package/src/configuration/rest/index.ts +11 -2
- package/src/console/api.ts +51 -10
- package/src/console/session.ts +125 -0
- package/src/cron/index.ts +6 -2
- package/src/databases/common.ts +55 -10
- package/src/databases/core/executor.ts +12 -2
- package/src/databases/core/function-mapping.ts +5 -0
- package/src/databases/core/query-builder.ts +7 -0
- package/src/databases/engines/mssql/connection.ts +66 -14
- package/src/databases/engines/mssql/format.ts +3 -1
- package/src/databases/engines/mssql/query/index.ts +12 -2
- package/src/databases/engines/mysql/connection.ts +30 -13
- package/src/databases/engines/mysql/format.ts +3 -1
- package/src/databases/engines/mysql/query/index.ts +25 -4
- package/src/databases/engines/postgresql/connection.ts +76 -15
- package/src/databases/engines/postgresql/format.ts +3 -1
- package/src/databases/engines/postgresql/query/index.ts +12 -3
- package/src/databases/high-level-operations.ts +13 -2
- package/src/index.ts +82 -13
- package/src/singletons/queryTimeout.ts +20 -0
- package/src/subscriptions/strategies/database.ts +19 -3
- package/src/subscriptions/types.ts +4 -1
- package/src/subscriptions/utils/polling.ts +6 -3
- package/src/types/env.ts +21 -1
- package/src/utils/rateLimit.ts +209 -0
- package/src/utils/responses.ts +10 -0
- package/src/ai/README.md +0 -257
package/src/console/api.ts
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
|
+
import type { TokenService } from "../authentication/types";
|
|
1
2
|
import type { AnalyzedConfiguration } from "../configuration";
|
|
2
3
|
import type { Configuration } from "../types/configuration";
|
|
3
4
|
import type { Env } from "../types/env";
|
|
4
|
-
import type {
|
|
5
|
+
import type { RateLimiter } from "../utils/rateLimit";
|
|
6
|
+
import type { BunRequest, SQL } from "bun";
|
|
5
7
|
import type { ConnectionPool } from "mssql";
|
|
6
8
|
|
|
9
|
+
import { createConsoleSessions } from "./session";
|
|
7
10
|
import { getTags } from "../configuration/rest/generateOpenAPI";
|
|
8
11
|
import { getCronJobs } from "../singletons/cron";
|
|
9
12
|
import { databasesConnections } from "../singletons/databases";
|
|
10
13
|
import { queueManager } from "../singletons/queues";
|
|
11
|
-
import {
|
|
14
|
+
import { resolveClientAddress } from "../utils/rateLimit";
|
|
15
|
+
import { S200, S400, S401, S404, S429 } from "../utils/responses";
|
|
12
16
|
|
|
13
17
|
type ConsoleRoutesFactoryOptions = {
|
|
14
18
|
env: Env;
|
|
@@ -16,10 +20,16 @@ type ConsoleRoutesFactoryOptions = {
|
|
|
16
20
|
prefixes: Record<string, string>;
|
|
17
21
|
projectConfiguration: Configuration;
|
|
18
22
|
analyzedConfiguration: AnalyzedConfiguration;
|
|
19
|
-
|
|
23
|
+
tokenService: TokenService;
|
|
24
|
+
rateLimiter?: RateLimiter | undefined;
|
|
20
25
|
};
|
|
21
26
|
|
|
22
|
-
|
|
27
|
+
// Bun hands a route handler the server as its second argument; the parameter is
|
|
28
|
+
// optional only because the type is also used for handlers that ignore it.
|
|
29
|
+
type ConsoleRouteHandler = (
|
|
30
|
+
req: BunRequest,
|
|
31
|
+
server?: Bun.Server<unknown>,
|
|
32
|
+
) => Response | Promise<Response>;
|
|
23
33
|
|
|
24
34
|
const PING_TIMEOUT_MS = 2000;
|
|
25
35
|
|
|
@@ -52,14 +62,16 @@ export const consoleRoutesFactory = ({
|
|
|
52
62
|
prefixes,
|
|
53
63
|
projectConfiguration,
|
|
54
64
|
analyzedConfiguration,
|
|
55
|
-
|
|
65
|
+
tokenService,
|
|
66
|
+
rateLimiter,
|
|
56
67
|
}: ConsoleRoutesFactoryOptions): Record<string, Record<string, ConsoleRouteHandler>> => {
|
|
68
|
+
const sessions = createConsoleSessions({ env, consolePath, tokenService });
|
|
69
|
+
|
|
57
70
|
const guarded =
|
|
58
|
-
(handler: (req:
|
|
59
|
-
async (req:
|
|
71
|
+
(handler: (req: BunRequest) => object | Promise<object>): ConsoleRouteHandler =>
|
|
72
|
+
async (req: BunRequest) => {
|
|
60
73
|
try {
|
|
61
|
-
|
|
62
|
-
if (role !== env.superadmin.role) return new S404({ error: "Not Found" });
|
|
74
|
+
if (!(await sessions.authorize(req))) return new S404({ error: "Not Found" });
|
|
63
75
|
return new S200(await handler(req));
|
|
64
76
|
} catch (error) {
|
|
65
77
|
return new S400({ errors: [{ message: (error as Error)?.message }] });
|
|
@@ -78,10 +90,39 @@ export const consoleRoutesFactory = ({
|
|
|
78
90
|
new S200({
|
|
79
91
|
name: projectConfiguration.name,
|
|
80
92
|
version: projectConfiguration.version,
|
|
81
|
-
adminSecretHeader: env.admin.header,
|
|
82
93
|
}),
|
|
83
94
|
},
|
|
84
95
|
|
|
96
|
+
[`${base}/login`]: {
|
|
97
|
+
...cors,
|
|
98
|
+
POST: async (req, server) => {
|
|
99
|
+
// Keyed by address against the anonymous ceiling: there is no session
|
|
100
|
+
// yet, and the attempt is charged before the secret is compared, so a
|
|
101
|
+
// guesser cannot spend attempts for free.
|
|
102
|
+
const limit = await rateLimiter?.check(
|
|
103
|
+
null,
|
|
104
|
+
resolveClientAddress(req, server, env.rateLimit.trustProxy),
|
|
105
|
+
);
|
|
106
|
+
if (limit && !limit.allowed) return new S429(limit.retryAfterMs);
|
|
107
|
+
|
|
108
|
+
try {
|
|
109
|
+
return new S200(await sessions.login(req));
|
|
110
|
+
} catch {
|
|
111
|
+
// One message for a bad secret and for a malformed body alike: the
|
|
112
|
+
// caller learns nothing about which it was.
|
|
113
|
+
return new S401({ errors: [{ message: "Invalid admin secret" }] });
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
|
|
118
|
+
[`${base}/logout`]: {
|
|
119
|
+
...cors,
|
|
120
|
+
POST: async (req) => {
|
|
121
|
+
await sessions.logout(req);
|
|
122
|
+
return new S200({ ok: true });
|
|
123
|
+
},
|
|
124
|
+
},
|
|
125
|
+
|
|
85
126
|
[`${base}/tables`]: {
|
|
86
127
|
...cors,
|
|
87
128
|
GET: guarded(() => ({
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { timingSafeEqual } from "crypto";
|
|
2
|
+
|
|
3
|
+
import type { BunRequest } from "bun";
|
|
4
|
+
import type { TokenService } from "../authentication/types";
|
|
5
|
+
import type { Env } from "../types/env";
|
|
6
|
+
|
|
7
|
+
import { parseDurationToMs, parseDurationToSeconds } from "../authentication/duration";
|
|
8
|
+
import { logger } from "../logging";
|
|
9
|
+
|
|
10
|
+
export const CONSOLE_SESSION_COOKIE = "graphoria_console_session";
|
|
11
|
+
|
|
12
|
+
// Separates a console session from an access or refresh token signed with the
|
|
13
|
+
// same key: neither can be presented where the other is expected.
|
|
14
|
+
const CONSOLE_AUDIENCE = "console";
|
|
15
|
+
const CONSOLE_SUBJECT = "console";
|
|
16
|
+
|
|
17
|
+
const safeCompare = (a: string, b: string): boolean => {
|
|
18
|
+
try {
|
|
19
|
+
const bufA = Buffer.from(a);
|
|
20
|
+
const bufB = Buffer.from(b);
|
|
21
|
+
if (bufA.length !== bufB.length) return false;
|
|
22
|
+
return timingSafeEqual(bufA, bufB);
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type ConsoleSessions = {
|
|
29
|
+
/** Exchange the admin secret for a session cookie. Throws if it does not match. */
|
|
30
|
+
login(req: BunRequest): Promise<{ expiresIn: number }>;
|
|
31
|
+
/** True when the request carries a live, unrevoked console session cookie. */
|
|
32
|
+
authorize(req: BunRequest): Promise<boolean>;
|
|
33
|
+
logout(req: BunRequest): Promise<void>;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
type ConsoleSessionsOptions = {
|
|
37
|
+
env: Env;
|
|
38
|
+
consolePath: string;
|
|
39
|
+
tokenService: TokenService;
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export const createConsoleSessions = ({
|
|
43
|
+
env,
|
|
44
|
+
consolePath,
|
|
45
|
+
tokenService,
|
|
46
|
+
}: ConsoleSessionsOptions): ConsoleSessions => {
|
|
47
|
+
const log = logger("console");
|
|
48
|
+
const expiresIn = env.console.sessionExpiresIn;
|
|
49
|
+
const sessionMs = parseDurationToMs(expiresIn);
|
|
50
|
+
|
|
51
|
+
// Revoked JTIs, held in this process only: the console mints its own tokens
|
|
52
|
+
// and never writes to the Redis JTI store, so a logout is honoured by the
|
|
53
|
+
// worker that served it and by no other. An entry is dropped once the token
|
|
54
|
+
// it revokes would have expired on its own.
|
|
55
|
+
const revoked = new Map<string, number>();
|
|
56
|
+
|
|
57
|
+
const prune = () => {
|
|
58
|
+
const now = Date.now();
|
|
59
|
+
for (const [jti, expiresAt] of revoked) if (expiresAt <= now) revoked.delete(jti);
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const cookieOptions = {
|
|
63
|
+
httpOnly: true,
|
|
64
|
+
secure: true,
|
|
65
|
+
sameSite: "strict",
|
|
66
|
+
path: consolePath,
|
|
67
|
+
} as const;
|
|
68
|
+
|
|
69
|
+
const readSession = async (req: BunRequest) => {
|
|
70
|
+
const cookie = req.cookies?.get(CONSOLE_SESSION_COOKIE);
|
|
71
|
+
if (!cookie) return null;
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const payload = await tokenService.verifyToken(cookie, { audience: CONSOLE_AUDIENCE });
|
|
75
|
+
if (payload.role !== env.superadmin.role) return null;
|
|
76
|
+
return payload;
|
|
77
|
+
} catch {
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
return {
|
|
83
|
+
login: async (req) => {
|
|
84
|
+
const { secret: submitted } = (await req.json()) as { secret?: string };
|
|
85
|
+
|
|
86
|
+
if (!env.admin.secret || !submitted || !safeCompare(submitted, env.admin.secret)) {
|
|
87
|
+
throw new Error("Invalid admin secret");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const token = await tokenService.createToken(
|
|
91
|
+
{ sub: CONSOLE_SUBJECT, role: env.superadmin.role },
|
|
92
|
+
{ audience: CONSOLE_AUDIENCE, expiresIn },
|
|
93
|
+
);
|
|
94
|
+
|
|
95
|
+
req.cookies.set(CONSOLE_SESSION_COOKIE, token, {
|
|
96
|
+
...cookieOptions,
|
|
97
|
+
maxAge: parseDurationToSeconds(expiresIn),
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
log.info("console session issued");
|
|
101
|
+
|
|
102
|
+
return { expiresIn: parseDurationToSeconds(expiresIn) };
|
|
103
|
+
},
|
|
104
|
+
|
|
105
|
+
authorize: async (req) => {
|
|
106
|
+
const payload = await readSession(req);
|
|
107
|
+
if (!payload) return false;
|
|
108
|
+
|
|
109
|
+
prune();
|
|
110
|
+
return !revoked.has(payload.jti);
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
logout: async (req) => {
|
|
114
|
+
const payload = await readSession(req);
|
|
115
|
+
if (payload) {
|
|
116
|
+
// The token stays valid until it expires, so the revocation record has
|
|
117
|
+
// to outlive it: one full session lifetime is the upper bound.
|
|
118
|
+
revoked.set(payload.jti, Date.now() + sessionMs);
|
|
119
|
+
log.info("console session revoked");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
req.cookies.delete({ name: CONSOLE_SESSION_COOKIE, path: consolePath });
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
};
|
package/src/cron/index.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { logger } from "../logging";
|
|
|
13
13
|
*/
|
|
14
14
|
export interface ScheduledCronJob {
|
|
15
15
|
name: string;
|
|
16
|
-
job: Cron
|
|
16
|
+
job: Cron<NonNullable<CronJob["context"]>>;
|
|
17
17
|
config: CronJob;
|
|
18
18
|
executionCount: number;
|
|
19
19
|
pause: () => boolean;
|
|
@@ -106,7 +106,11 @@ const createScheduledJob = (config: CronJob, gqlQuery: GqlQueryFn<true>): Schedu
|
|
|
106
106
|
name: config.name,
|
|
107
107
|
job,
|
|
108
108
|
config,
|
|
109
|
-
|
|
109
|
+
// Getter, not a copy: the counter lives in the tick closure and callers
|
|
110
|
+
// (console status, getSummary) read it after ticks have happened.
|
|
111
|
+
get executionCount() {
|
|
112
|
+
return executionCount;
|
|
113
|
+
},
|
|
110
114
|
pause: () => {
|
|
111
115
|
job.pause();
|
|
112
116
|
log.info("paused");
|
package/src/databases/common.ts
CHANGED
|
@@ -826,37 +826,82 @@ const toPositionalPlaceholder = (
|
|
|
826
826
|
return null;
|
|
827
827
|
};
|
|
828
828
|
|
|
829
|
+
/**
|
|
830
|
+
* Page bounds applied to a list field. `null` at a call site means the query is
|
|
831
|
+
* operator-authored and exempt; `0` on either field opts that half out.
|
|
832
|
+
*/
|
|
833
|
+
export type PageLimits = {
|
|
834
|
+
defaultPageSize: number;
|
|
835
|
+
maxPageSize: number;
|
|
836
|
+
};
|
|
837
|
+
|
|
838
|
+
// Resolve the requested limit to a number, following a $ref into the runtime
|
|
839
|
+
// values. Returns undefined when there is no limit or it cannot be resolved.
|
|
840
|
+
const resolveLimitValue = (
|
|
841
|
+
value: unknown,
|
|
842
|
+
variables: Record<string, unknown>,
|
|
843
|
+
): number | undefined => {
|
|
844
|
+
const resolved = isString(value) && value.startsWith("$") ? variables[value.substring(1)] : value;
|
|
845
|
+
|
|
846
|
+
return typeof resolved === "number" ? resolved : undefined;
|
|
847
|
+
};
|
|
848
|
+
|
|
829
849
|
// Factory function for pagination clause builders
|
|
830
850
|
export const buildPaginationClauseFp =
|
|
831
851
|
(dbType: DatabaseType = "pg") =>
|
|
832
|
-
(
|
|
852
|
+
(
|
|
853
|
+
field: SelectionAnalysis,
|
|
854
|
+
variablesDefinition: VariableDefinition[] = [],
|
|
855
|
+
variables: Record<string, unknown> = {},
|
|
856
|
+
pageLimits: PageLimits | null = null,
|
|
857
|
+
): string => {
|
|
833
858
|
const args = field.arguments;
|
|
834
859
|
|
|
835
|
-
|
|
836
|
-
|
|
860
|
+
const requested = resolveLimitValue(args?.["limit"], variables);
|
|
861
|
+
|
|
862
|
+
if (pageLimits && pageLimits.maxPageSize > 0 && requested !== undefined) {
|
|
863
|
+
if (requested > pageLimits.maxPageSize) {
|
|
864
|
+
throw new Error(
|
|
865
|
+
`Requested page size of ${requested} exceeds the maximum allowed page size of ${pageLimits.maxPageSize} (field: "${field.alias || field.name}"). Raise MAX_PAGE_SIZE to allow larger pages.`,
|
|
866
|
+
);
|
|
867
|
+
}
|
|
837
868
|
}
|
|
838
869
|
|
|
839
870
|
// Convert limit variable reference to positional placeholder
|
|
840
|
-
const limitPlaceholder =
|
|
841
|
-
|
|
842
|
-
|
|
871
|
+
const limitPlaceholder =
|
|
872
|
+
args?.["limit"] === undefined
|
|
873
|
+
? null
|
|
874
|
+
: toPositionalPlaceholder(args["limit"], variablesDefinition, dbType);
|
|
875
|
+
|
|
876
|
+
// An unbounded list field reads the whole table. Supply the default page
|
|
877
|
+
// size as a literal: minting a variable here would have to push onto the
|
|
878
|
+
// same definition list the driver binds positionally from, and that order
|
|
879
|
+
// is load-bearing. The value is an env-validated integer, never caller text.
|
|
880
|
+
const limitClause =
|
|
881
|
+
limitPlaceholder ??
|
|
882
|
+
(field.isArray && pageLimits && pageLimits.defaultPageSize > 0
|
|
883
|
+
? String(pageLimits.defaultPageSize)
|
|
884
|
+
: null);
|
|
885
|
+
|
|
886
|
+
if (!limitClause) {
|
|
887
|
+
return "";
|
|
843
888
|
}
|
|
844
889
|
|
|
845
890
|
// Convert offset variable reference to positional placeholder (default to 0 if not provided)
|
|
846
|
-
const offsetPlaceholder = args["offset"]
|
|
891
|
+
const offsetPlaceholder = args?.["offset"]
|
|
847
892
|
? toPositionalPlaceholder(args["offset"], variablesDefinition, dbType)
|
|
848
893
|
: "0";
|
|
849
894
|
|
|
850
895
|
if (dbType !== "mssql") {
|
|
851
|
-
return `LIMIT ${
|
|
896
|
+
return `LIMIT ${limitClause} OFFSET ${offsetPlaceholder}`;
|
|
852
897
|
}
|
|
853
898
|
|
|
854
899
|
// T-SQL only accepts OFFSET/FETCH after an ORDER BY. Dropping the limit when
|
|
855
900
|
// the query has none returned the whole table instead, so supply the
|
|
856
901
|
// order-agnostic ORDER BY that T-SQL accepts for exactly this case.
|
|
857
|
-
const orderByFallback = args["orderBy"] === undefined ? "ORDER BY (SELECT NULL) " : "";
|
|
902
|
+
const orderByFallback = args?.["orderBy"] === undefined ? "ORDER BY (SELECT NULL) " : "";
|
|
858
903
|
|
|
859
|
-
return `${orderByFallback}OFFSET ${offsetPlaceholder} ROWS FETCH NEXT ${
|
|
904
|
+
return `${orderByFallback}OFFSET ${offsetPlaceholder} ROWS FETCH NEXT ${limitClause} ROWS ONLY`;
|
|
860
905
|
};
|
|
861
906
|
|
|
862
907
|
export const buildPaginationClausePG = buildPaginationClauseFp("pg");
|
|
@@ -14,6 +14,8 @@ export const executeQuery = async <T>(
|
|
|
14
14
|
db: Database,
|
|
15
15
|
variablesDefinition: VariableDefinition[],
|
|
16
16
|
variables: Record<string, unknown>,
|
|
17
|
+
/** Per-operation override. `undefined` leaves the engine on its own default. */
|
|
18
|
+
timeoutMs?: number,
|
|
17
19
|
) => {
|
|
18
20
|
const adapter = databaseAdapters[db?.type];
|
|
19
21
|
if (!adapter) {
|
|
@@ -24,7 +26,7 @@ export const executeQuery = async <T>(
|
|
|
24
26
|
const startTime = Bun.nanoseconds();
|
|
25
27
|
|
|
26
28
|
try {
|
|
27
|
-
const result = await adapter.execute<T>(query, db, variablesDefinition, variables);
|
|
29
|
+
const result = await adapter.execute<T>(query, db, variablesDefinition, variables, timeoutMs);
|
|
28
30
|
log.debug(
|
|
29
31
|
{ durationMs: (Bun.nanoseconds() - startTime) / 1e6, queryLength: query.length },
|
|
30
32
|
"query executed",
|
|
@@ -41,6 +43,8 @@ export const executeQueryJSON = async <T>(
|
|
|
41
43
|
db: Database,
|
|
42
44
|
variablesDefinition: VariableDefinition[],
|
|
43
45
|
variables: Record<string, unknown>,
|
|
46
|
+
/** Per-operation override. `undefined` leaves the engine on its own default. */
|
|
47
|
+
timeoutMs?: number,
|
|
44
48
|
) => {
|
|
45
49
|
const adapter = databaseAdapters[db.type];
|
|
46
50
|
if (!adapter) {
|
|
@@ -51,7 +55,13 @@ export const executeQueryJSON = async <T>(
|
|
|
51
55
|
const startTime = Bun.nanoseconds();
|
|
52
56
|
|
|
53
57
|
try {
|
|
54
|
-
const result = await adapter.executeJson<T>(
|
|
58
|
+
const result = await adapter.executeJson<T>(
|
|
59
|
+
query,
|
|
60
|
+
db,
|
|
61
|
+
variablesDefinition,
|
|
62
|
+
variables,
|
|
63
|
+
timeoutMs,
|
|
64
|
+
);
|
|
55
65
|
log.debug(
|
|
56
66
|
{ durationMs: (Bun.nanoseconds() - startTime) / 1e6, queryLength: query.length },
|
|
57
67
|
"query executed (json)",
|
|
@@ -3,6 +3,7 @@ import type { MergedEntities } from "../../configuration/getSchemas/mergeEntitie
|
|
|
3
3
|
import type { Auth, Database, DatabaseType } from "../../types/configuration";
|
|
4
4
|
import type { DatabaseStructure, ProcedureResolver, TableResolver, View } from "../../types/db";
|
|
5
5
|
import type { GenerateCreateTableSQLParameters } from "../schemaBuilder/generateCreateTable";
|
|
6
|
+
import type { PageLimits } from "../common";
|
|
6
7
|
|
|
7
8
|
import {
|
|
8
9
|
checkUserCredentials as checkUserCredentialsMSSQL,
|
|
@@ -85,18 +86,22 @@ type DatabaseFunctions = {
|
|
|
85
86
|
operation: OperationAnalysis,
|
|
86
87
|
variables: Record<string, unknown>,
|
|
87
88
|
forHashMethod: boolean,
|
|
89
|
+
pageLimits: PageLimits | null,
|
|
90
|
+
timeoutMs: number | undefined,
|
|
88
91
|
) => string;
|
|
89
92
|
execute: <T>(
|
|
90
93
|
query: string,
|
|
91
94
|
db: Database,
|
|
92
95
|
variablesDefinition: VariableDefinition[],
|
|
93
96
|
values: Record<string, unknown>,
|
|
97
|
+
timeoutMs: number | undefined,
|
|
94
98
|
) => Promise<T[]>;
|
|
95
99
|
executeJson: <T>(
|
|
96
100
|
query: string,
|
|
97
101
|
db: Database,
|
|
98
102
|
variablesDefinition: VariableDefinition[],
|
|
99
103
|
values: Record<string, unknown>,
|
|
104
|
+
timeoutMs: number | undefined,
|
|
100
105
|
) => Promise<T>;
|
|
101
106
|
callStoredProcedure: (
|
|
102
107
|
sp: ProcedureResolver,
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AnalysisResult, SelectionAnalysis } from "../../analyzeQuery/types";
|
|
2
2
|
import type { MergedEntities } from "../../configuration/getSchemas/mergeEntities";
|
|
3
3
|
import type { Database } from "../../types/configuration";
|
|
4
|
+
import type { PageLimits } from "../common";
|
|
4
5
|
|
|
5
6
|
import { databaseAdapters } from "./function-mapping";
|
|
6
7
|
|
|
@@ -9,6 +10,10 @@ export const generateSQL = (
|
|
|
9
10
|
analysisResult: AnalysisResult,
|
|
10
11
|
variables: Record<string, unknown> = {},
|
|
11
12
|
forHashMethod: boolean = false,
|
|
13
|
+
/** `null` exempts the query — operator-authored queries are configuration, not caller input. */
|
|
14
|
+
pageLimits: PageLimits | null = null,
|
|
15
|
+
/** Per-operation override. `undefined` leaves the engine on its own default. */
|
|
16
|
+
timeoutMs?: number,
|
|
12
17
|
) => {
|
|
13
18
|
const operation = analysisResult.operations[0];
|
|
14
19
|
|
|
@@ -40,6 +45,8 @@ export const generateSQL = (
|
|
|
40
45
|
},
|
|
41
46
|
variables,
|
|
42
47
|
forHashMethod,
|
|
48
|
+
pageLimits,
|
|
49
|
+
timeoutMs,
|
|
43
50
|
);
|
|
44
51
|
|
|
45
52
|
return [db, query] as const;
|
|
@@ -6,32 +6,78 @@ import type { VariableDefinition } from "../../../analyzeQuery/types";
|
|
|
6
6
|
import type { Database } from "../../../types/configuration";
|
|
7
7
|
import type { ProcedureResolver } from "../../../types/db";
|
|
8
8
|
|
|
9
|
+
import { MSSQLConnectionOptionsZod } from "../../../config/types/db";
|
|
9
10
|
import { databasesConnections } from "../../../singletons/databases";
|
|
11
|
+
import { getQueryTimeoutMs } from "../../../singletons/queryTimeout";
|
|
10
12
|
import { logger } from "../../../logging";
|
|
11
13
|
|
|
12
|
-
|
|
14
|
+
// Every pool bound comes from the schema, which is the only place they are
|
|
15
|
+
// declared and documented. Repeating them here as `?? n` fallbacks let the
|
|
16
|
+
// documented default and the applied one drift apart, differently per engine.
|
|
17
|
+
//
|
|
18
|
+
// Defaults are overlaid rather than re-parsed: `connectionOptions` is an
|
|
19
|
+
// undiscriminated union of the two engine shapes, and an object carrying
|
|
20
|
+
// nothing MSSQL-specific — `{}` included — validates against the Bun SQL shape
|
|
21
|
+
// even on an MSSQL database. A strict re-parse of one of those would reject the
|
|
22
|
+
// Bun SQL keys and take the server down at boot.
|
|
23
|
+
export const poolOptions = (db: Database, timeoutMs: number = getQueryTimeoutMs()) => {
|
|
13
24
|
const ci = db.connection;
|
|
14
|
-
const
|
|
15
|
-
|
|
25
|
+
const raw = db.connectionOptions as MSSQLConnectionOptions | undefined;
|
|
26
|
+
|
|
27
|
+
const defaults = MSSQLConnectionOptionsZod.parse({});
|
|
28
|
+
const opts = { ...defaults, ...raw, pool: { ...defaults.pool, ...raw?.pool } };
|
|
29
|
+
|
|
30
|
+
return {
|
|
16
31
|
server: ci.host,
|
|
17
32
|
port: ci.port,
|
|
18
33
|
user: ci.user,
|
|
19
34
|
password: ci.password,
|
|
20
35
|
database: ci.database,
|
|
21
|
-
connectionTimeout: opts
|
|
22
|
-
|
|
36
|
+
connectionTimeout: opts.connectionTimeout * 1000,
|
|
37
|
+
// Only an omitted key falls through to the resolved timeout: reading it off
|
|
38
|
+
// `opts` cannot tell an operator who set 30 from Zod defaulting to 30, and
|
|
39
|
+
// silently overriding the first would break the slow report it was raised
|
|
40
|
+
// for. Seconds there, milliseconds here.
|
|
41
|
+
requestTimeout: raw?.requestTimeout !== undefined ? raw.requestTimeout * 1000 : timeoutMs,
|
|
42
|
+
// These three keep their code fallbacks rather than the schema's. Two of
|
|
43
|
+
// them default the opposite way there, so taking the schema value would
|
|
44
|
+
// turn certificate validation on and trusted connections off for every
|
|
45
|
+
// deployment that omits `connectionOptions` — a change to how a connection
|
|
46
|
+
// is secured, not to how the pool is sized.
|
|
23
47
|
options: {
|
|
24
|
-
encrypt:
|
|
25
|
-
trustServerCertificate:
|
|
26
|
-
trustedConnection:
|
|
48
|
+
encrypt: raw?.encrypt ?? false,
|
|
49
|
+
trustServerCertificate: raw?.trustServerCertificate ?? true,
|
|
50
|
+
trustedConnection: raw?.trustedConnection ?? true,
|
|
27
51
|
},
|
|
28
52
|
pool: {
|
|
29
|
-
max: opts
|
|
30
|
-
min: opts
|
|
31
|
-
idleTimeoutMillis:
|
|
53
|
+
max: opts.pool.max,
|
|
54
|
+
min: opts.pool.min,
|
|
55
|
+
idleTimeoutMillis: opts.pool.idleTimeout * 1000,
|
|
56
|
+
acquireTimeoutMillis: opts.pool.acquireTimeout * 1000,
|
|
32
57
|
},
|
|
33
|
-
parseJSON: opts
|
|
34
|
-
}
|
|
58
|
+
parseJSON: opts.parseJSON,
|
|
59
|
+
};
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/** The bound the pool already applies, so the executor can tell an override from the default. */
|
|
63
|
+
export const poolRequestTimeoutMs = (db: Database) => poolOptions(db).requestTimeout;
|
|
64
|
+
|
|
65
|
+
// `ConnectionPool.request` takes per-request overrides from mssql 12.7.0, which
|
|
66
|
+
// is what the workspace pins, but `@types/mssql@12.3.0` still declares it with
|
|
67
|
+
// no parameter. Narrowed to the one key used rather than cast wholesale, so the
|
|
68
|
+
// day the types catch up this stops compiling and can be deleted.
|
|
69
|
+
//
|
|
70
|
+
// Not interchangeable with assigning `request.timeout`: that property does not
|
|
71
|
+
// exist, and the pool default is used instead.
|
|
72
|
+
type PoolWithRequestOverrides = {
|
|
73
|
+
request(overrides: { requestTimeout: number }): ReturnType<ConnectionPool["request"]>;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const requestWithTimeout = (pool: ConnectionPool, timeoutMs: number) =>
|
|
77
|
+
(pool as unknown as PoolWithRequestOverrides).request({ requestTimeout: timeoutMs });
|
|
78
|
+
|
|
79
|
+
export const getPool = async (db: Database) => {
|
|
80
|
+
const pool = new ConnectionPool(poolOptions(db));
|
|
35
81
|
|
|
36
82
|
await pool.connect(); // Connect to the database
|
|
37
83
|
|
|
@@ -52,6 +98,7 @@ export const executeQueryFactory =
|
|
|
52
98
|
db: Database,
|
|
53
99
|
variablesDefinition: VariableDefinition[] = [],
|
|
54
100
|
params: Record<string, unknown> = {},
|
|
101
|
+
timeoutMs?: number,
|
|
55
102
|
) => {
|
|
56
103
|
const pool = singleQuery ? await getPool(db) : await getPoolSingleton(db);
|
|
57
104
|
|
|
@@ -59,7 +106,10 @@ export const executeQueryFactory =
|
|
|
59
106
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
60
107
|
}
|
|
61
108
|
|
|
62
|
-
const request =
|
|
109
|
+
const request =
|
|
110
|
+
timeoutMs !== undefined && timeoutMs !== poolRequestTimeoutMs(db)
|
|
111
|
+
? requestWithTimeout(pool!, timeoutMs)
|
|
112
|
+
: pool!.request();
|
|
63
113
|
|
|
64
114
|
variablesDefinition.forEach((v, i) => {
|
|
65
115
|
const varName = (i + 1).toString();
|
|
@@ -93,12 +143,14 @@ export const executeQueryJSONFactory =
|
|
|
93
143
|
db: Database,
|
|
94
144
|
variablesDefinition: VariableDefinition[] = [],
|
|
95
145
|
params: Record<string, unknown> = {},
|
|
146
|
+
timeoutMs?: number,
|
|
96
147
|
): Promise<T> => {
|
|
97
148
|
const result = await (singleQuery ? executeQuerySingle : executeQuery)(
|
|
98
149
|
query,
|
|
99
150
|
db,
|
|
100
151
|
variablesDefinition,
|
|
101
152
|
params,
|
|
153
|
+
timeoutMs,
|
|
102
154
|
);
|
|
103
155
|
|
|
104
156
|
return result[0] as T;
|
|
@@ -2,6 +2,7 @@ import { format as sqlFormatter } from "sql-formatter";
|
|
|
2
2
|
|
|
3
3
|
import type { AnalysisResult } from "../../../analyzeQuery/types";
|
|
4
4
|
import type { MergedEntities } from "../../../configuration/getSchemas/mergeEntities";
|
|
5
|
+
import type { PageLimits } from "../../common";
|
|
5
6
|
|
|
6
7
|
import { generateSQL } from "../../core/query-builder";
|
|
7
8
|
|
|
@@ -16,8 +17,9 @@ export const genSql = (
|
|
|
16
17
|
query: AnalysisResult,
|
|
17
18
|
variables: Record<string, unknown> = {},
|
|
18
19
|
hash = false,
|
|
20
|
+
pageLimits: PageLimits | null = null,
|
|
19
21
|
) => {
|
|
20
|
-
const [[, sql]] = generateSQL(entitites, query, variables, hash);
|
|
22
|
+
const [[, sql]] = generateSQL(entitites, query, variables, hash, pageLimits);
|
|
21
23
|
|
|
22
24
|
return format(sql);
|
|
23
25
|
};
|
|
@@ -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 } from "../../../common";
|
|
7
|
+
import type { GroupByInfo, PageLimits } from "../../../common";
|
|
8
8
|
|
|
9
9
|
import {
|
|
10
10
|
buildOrderByClauseMSSQL,
|
|
@@ -164,6 +164,7 @@ export const generateSQL = (
|
|
|
164
164
|
operation: OperationAnalysis,
|
|
165
165
|
variables: Record<string, unknown> = {},
|
|
166
166
|
forHashMethod: boolean = false,
|
|
167
|
+
pageLimits: PageLimits | null = null,
|
|
167
168
|
): string => {
|
|
168
169
|
if (forHashMethod) {
|
|
169
170
|
return `SELECT HASHBYTES('MD5', (
|
|
@@ -176,6 +177,7 @@ export const generateSQL = (
|
|
|
176
177
|
null,
|
|
177
178
|
1,
|
|
178
179
|
{},
|
|
180
|
+
pageLimits,
|
|
179
181
|
)})
|
|
180
182
|
) AS ResultHash`;
|
|
181
183
|
}
|
|
@@ -238,6 +240,7 @@ export const generateSQL = (
|
|
|
238
240
|
null,
|
|
239
241
|
index + 1,
|
|
240
242
|
{},
|
|
243
|
+
pageLimits,
|
|
241
244
|
);
|
|
242
245
|
|
|
243
246
|
fieldQueries.push(`(${fieldSQL}) as ${wrapIdentifierMSSQL(field.alias || field.name)}`);
|
|
@@ -261,6 +264,7 @@ export const buildSQLForField = (
|
|
|
261
264
|
parentTableAlias: string | null,
|
|
262
265
|
level: number,
|
|
263
266
|
aliasMap: { [alias: string]: string },
|
|
267
|
+
pageLimits: PageLimits | null,
|
|
264
268
|
): string => {
|
|
265
269
|
const tableAlias = generateTableAlias(level);
|
|
266
270
|
|
|
@@ -325,6 +329,7 @@ export const buildSQLForField = (
|
|
|
325
329
|
tableAlias,
|
|
326
330
|
level,
|
|
327
331
|
aliasMap,
|
|
332
|
+
pageLimits,
|
|
328
333
|
),
|
|
329
334
|
([name, selector]) => `${selector} AS ${wrapIdentifierMSSQL(name)}`,
|
|
330
335
|
);
|
|
@@ -332,7 +337,12 @@ export const buildSQLForField = (
|
|
|
332
337
|
const fromClause = `FROM ${dottedQuotedName} ${tableAlias}`;
|
|
333
338
|
|
|
334
339
|
const orderByClause = buildOrderByClauseMSSQL(entities, field, tableAlias);
|
|
335
|
-
const paginationClause = buildPaginationClauseMSSQL(
|
|
340
|
+
const paginationClause = buildPaginationClauseMSSQL(
|
|
341
|
+
field,
|
|
342
|
+
variablesDefinition,
|
|
343
|
+
variables,
|
|
344
|
+
pageLimits,
|
|
345
|
+
);
|
|
336
346
|
|
|
337
347
|
const isArray = field.isArray;
|
|
338
348
|
const forJson =
|