@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.
Files changed (160) hide show
  1. package/README.md +25 -20
  2. package/dist/src/ai/tools/core.d.ts +6 -1
  3. package/dist/src/ai/tools/core.d.ts.map +1 -1
  4. package/dist/src/ai/tools/core.test.d.ts +2 -0
  5. package/dist/src/ai/tools/core.test.d.ts.map +1 -0
  6. package/dist/src/analyzeQuery/costLimit.d.ts +30 -0
  7. package/dist/src/analyzeQuery/costLimit.d.ts.map +1 -0
  8. package/dist/src/analyzeQuery/costLimit.test.d.ts +2 -0
  9. package/dist/src/analyzeQuery/costLimit.test.d.ts.map +1 -0
  10. package/dist/src/analyzeQuery/depthLimit.d.ts.map +1 -1
  11. package/dist/src/analyzeQuery/fragments.d.ts +6 -0
  12. package/dist/src/analyzeQuery/fragments.d.ts.map +1 -0
  13. package/dist/src/config/types/ai.d.ts +0 -6
  14. package/dist/src/config/types/ai.d.ts.map +1 -1
  15. package/dist/src/config/types/auth.d.ts +8 -29
  16. package/dist/src/config/types/auth.d.ts.map +1 -1
  17. package/dist/src/config/types/cron.d.ts +0 -94
  18. package/dist/src/config/types/cron.d.ts.map +1 -1
  19. package/dist/src/config/types/db.d.ts +2 -87
  20. package/dist/src/config/types/db.d.ts.map +1 -1
  21. package/dist/src/config/types/operation.d.ts +4 -0
  22. package/dist/src/config/types/operation.d.ts.map +1 -1
  23. package/dist/src/config/types/operation.test.d.ts +2 -0
  24. package/dist/src/config/types/operation.test.d.ts.map +1 -0
  25. package/dist/src/config/types/queue.d.ts +0 -47
  26. package/dist/src/config/types/queue.d.ts.map +1 -1
  27. package/dist/src/config/types/remote-rest.d.ts +0 -9
  28. package/dist/src/config/types/remote-rest.d.ts.map +1 -1
  29. package/dist/src/config/types/remote-schema.d.ts +0 -10
  30. package/dist/src/config/types/remote-schema.d.ts.map +1 -1
  31. package/dist/src/configuration/crossReferences.d.ts +27 -0
  32. package/dist/src/configuration/crossReferences.d.ts.map +1 -0
  33. package/dist/src/configuration/crossReferences.test.d.ts +2 -0
  34. package/dist/src/configuration/crossReferences.test.d.ts.map +1 -0
  35. package/dist/src/configuration/getSchemas/index.d.ts +70 -4
  36. package/dist/src/configuration/getSchemas/index.d.ts.map +1 -1
  37. package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts +3 -0
  38. package/dist/src/configuration/getSchemas/mergeEntities/index.d.ts.map +1 -1
  39. package/dist/src/configuration/gql/handleGraphQLRequestFactory.d.ts +32 -2
  40. package/dist/src/configuration/gql/handleGraphQLRequestFactory.d.ts.map +1 -1
  41. package/dist/src/configuration/gql/handleGraphQLSubscriptionFactory.d.ts.map +1 -1
  42. package/dist/src/configuration/index.d.ts +41 -2
  43. package/dist/src/configuration/index.d.ts.map +1 -1
  44. package/dist/src/configuration/rest/handleRESTRequestFactory.d.ts.map +1 -1
  45. package/dist/src/configuration/rest/index.d.ts.map +1 -1
  46. package/dist/src/console/api.d.ts +7 -5
  47. package/dist/src/console/api.d.ts.map +1 -1
  48. package/dist/src/console/session.d.ts +21 -0
  49. package/dist/src/console/session.d.ts.map +1 -0
  50. package/dist/src/console/session.test.d.ts +2 -0
  51. package/dist/src/console/session.test.d.ts.map +1 -0
  52. package/dist/src/cron/index.d.ts +1 -1
  53. package/dist/src/cron/index.d.ts.map +1 -1
  54. package/dist/src/cron/index.test.d.ts +2 -0
  55. package/dist/src/cron/index.test.d.ts.map +1 -0
  56. package/dist/src/databases/common.d.ts +12 -4
  57. package/dist/src/databases/common.d.ts.map +1 -1
  58. package/dist/src/databases/core/executor.d.ts +6 -2
  59. package/dist/src/databases/core/executor.d.ts.map +1 -1
  60. package/dist/src/databases/core/function-mapping.d.ts +4 -3
  61. package/dist/src/databases/core/function-mapping.d.ts.map +1 -1
  62. package/dist/src/databases/core/query-builder.d.ts +7 -1
  63. package/dist/src/databases/core/query-builder.d.ts.map +1 -1
  64. package/dist/src/databases/engines/mssql/connection.d.ts +29 -6
  65. package/dist/src/databases/engines/mssql/connection.d.ts.map +1 -1
  66. package/dist/src/databases/engines/mssql/connection.test.d.ts +2 -0
  67. package/dist/src/databases/engines/mssql/connection.test.d.ts.map +1 -0
  68. package/dist/src/databases/engines/mssql/format.d.ts +2 -1
  69. package/dist/src/databases/engines/mssql/format.d.ts.map +1 -1
  70. package/dist/src/databases/engines/mssql/query/index.d.ts +3 -2
  71. package/dist/src/databases/engines/mssql/query/index.d.ts.map +1 -1
  72. package/dist/src/databases/engines/mysql/connection.d.ts +16 -0
  73. package/dist/src/databases/engines/mysql/connection.d.ts.map +1 -1
  74. package/dist/src/databases/engines/mysql/connection.test.d.ts +2 -0
  75. package/dist/src/databases/engines/mysql/connection.test.d.ts.map +1 -0
  76. package/dist/src/databases/engines/mysql/format.d.ts +2 -1
  77. package/dist/src/databases/engines/mysql/format.d.ts.map +1 -1
  78. package/dist/src/databases/engines/mysql/query/index.d.ts +3 -2
  79. package/dist/src/databases/engines/mysql/query/index.d.ts.map +1 -1
  80. package/dist/src/databases/engines/postgresql/connection.d.ts +25 -6
  81. package/dist/src/databases/engines/postgresql/connection.d.ts.map +1 -1
  82. package/dist/src/databases/engines/postgresql/connection.test.d.ts +2 -0
  83. package/dist/src/databases/engines/postgresql/connection.test.d.ts.map +1 -0
  84. package/dist/src/databases/engines/postgresql/format.d.ts +2 -1
  85. package/dist/src/databases/engines/postgresql/format.d.ts.map +1 -1
  86. package/dist/src/databases/engines/postgresql/query/index.d.ts +3 -2
  87. package/dist/src/databases/engines/postgresql/query/index.d.ts.map +1 -1
  88. package/dist/src/databases/high-level-operations.d.ts +2 -0
  89. package/dist/src/databases/high-level-operations.d.ts.map +1 -1
  90. package/dist/src/databases/transformers/data-transformers.d.ts +2 -0
  91. package/dist/src/databases/transformers/data-transformers.d.ts.map +1 -1
  92. package/dist/src/index.d.ts.map +1 -1
  93. package/dist/src/singletons/env.d.ts +19 -0
  94. package/dist/src/singletons/env.d.ts.map +1 -1
  95. package/dist/src/singletons/queryTimeout.d.ts +3 -0
  96. package/dist/src/singletons/queryTimeout.d.ts.map +1 -0
  97. package/dist/src/subscriptions/strategies/database.d.ts.map +1 -1
  98. package/dist/src/subscriptions/strategies/database.test.d.ts +2 -0
  99. package/dist/src/subscriptions/strategies/database.test.d.ts.map +1 -0
  100. package/dist/src/subscriptions/types.d.ts +4 -1
  101. package/dist/src/subscriptions/types.d.ts.map +1 -1
  102. package/dist/src/subscriptions/utils/polling.d.ts.map +1 -1
  103. package/dist/src/types/env.d.ts +73 -0
  104. package/dist/src/types/env.d.ts.map +1 -1
  105. package/dist/src/types/zod/auth.d.ts +12 -0
  106. package/dist/src/types/zod/auth.d.ts.map +1 -1
  107. package/dist/src/types/zod/configuration.d.ts +28 -0
  108. package/dist/src/types/zod/configuration.d.ts.map +1 -1
  109. package/dist/src/utils/rateLimit.d.ts +65 -0
  110. package/dist/src/utils/rateLimit.d.ts.map +1 -0
  111. package/dist/src/utils/rateLimit.test.d.ts +2 -0
  112. package/dist/src/utils/rateLimit.test.d.ts.map +1 -0
  113. package/dist/src/utils/responses.d.ts +3 -0
  114. package/dist/src/utils/responses.d.ts.map +1 -1
  115. package/dist/tsconfig.tsbuildinfo +1 -1
  116. package/package.json +13 -13
  117. package/playgrounds/console/index.html +7 -7
  118. package/playgrounds/graphiql/index.html +535 -536
  119. package/playgrounds/scalar/index.html +1 -1
  120. package/src/ai/tools/core.ts +30 -6
  121. package/src/analyzeQuery/costLimit.ts +236 -0
  122. package/src/analyzeQuery/depthLimit.ts +4 -23
  123. package/src/analyzeQuery/fragments.ts +18 -0
  124. package/src/config/types/auth.ts +7 -0
  125. package/src/config/types/db.ts +7 -0
  126. package/src/config/types/operation.ts +4 -0
  127. package/src/configuration/crossReferences.ts +340 -0
  128. package/src/configuration/getSchemas/type-definition-generator/index.ts +1 -1
  129. package/src/configuration/gql/buildExecute.ts +1 -1
  130. package/src/configuration/gql/handleGraphQLRequestFactory.ts +80 -10
  131. package/src/configuration/gql/handleGraphQLSubscriptionFactory.ts +22 -2
  132. package/src/configuration/index.ts +24 -6
  133. package/src/configuration/rest/handleRESTRequestFactory.ts +6 -4
  134. package/src/configuration/rest/index.ts +11 -2
  135. package/src/console/api.ts +51 -10
  136. package/src/console/session.ts +125 -0
  137. package/src/cron/index.ts +6 -2
  138. package/src/databases/common.ts +55 -10
  139. package/src/databases/core/executor.ts +12 -2
  140. package/src/databases/core/function-mapping.ts +5 -0
  141. package/src/databases/core/query-builder.ts +7 -0
  142. package/src/databases/engines/mssql/connection.ts +66 -14
  143. package/src/databases/engines/mssql/format.ts +3 -1
  144. package/src/databases/engines/mssql/query/index.ts +12 -2
  145. package/src/databases/engines/mysql/connection.ts +30 -13
  146. package/src/databases/engines/mysql/format.ts +3 -1
  147. package/src/databases/engines/mysql/query/index.ts +25 -4
  148. package/src/databases/engines/postgresql/connection.ts +76 -15
  149. package/src/databases/engines/postgresql/format.ts +3 -1
  150. package/src/databases/engines/postgresql/query/index.ts +12 -3
  151. package/src/databases/high-level-operations.ts +13 -2
  152. package/src/index.ts +82 -13
  153. package/src/singletons/queryTimeout.ts +20 -0
  154. package/src/subscriptions/strategies/database.ts +19 -3
  155. package/src/subscriptions/types.ts +4 -1
  156. package/src/subscriptions/utils/polling.ts +6 -3
  157. package/src/types/env.ts +21 -1
  158. package/src/utils/rateLimit.ts +209 -0
  159. package/src/utils/responses.ts +10 -0
  160. package/src/ai/README.md +0 -257
@@ -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 { SQL } from "bun";
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 { S200, S400, S404 } from "../utils/responses";
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
- getRoleHandlers: (req: Request) => Promise<{ role: string }>;
23
+ tokenService: TokenService;
24
+ rateLimiter?: RateLimiter | undefined;
20
25
  };
21
26
 
22
- type ConsoleRouteHandler = (req: Request) => Response | Promise<Response>;
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
- getRoleHandlers,
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: Request) => object | Promise<object>): ConsoleRouteHandler =>
59
- async (req: Request) => {
71
+ (handler: (req: BunRequest) => object | Promise<object>): ConsoleRouteHandler =>
72
+ async (req: BunRequest) => {
60
73
  try {
61
- const { role } = await getRoleHandlers(req);
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
- executionCount,
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");
@@ -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
- (field: SelectionAnalysis, variablesDefinition: VariableDefinition[] = []): string => {
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
- if (!args || args["limit"] === undefined) {
836
- return "";
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 = toPositionalPlaceholder(args["limit"], variablesDefinition, dbType);
841
- if (!limitPlaceholder) {
842
- return ""; // Limit must be a variable reference
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 ${limitPlaceholder} OFFSET ${offsetPlaceholder}`;
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 ${limitPlaceholder} ROWS ONLY`;
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>(query, db, variablesDefinition, variables);
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
- export const getPool = async (db: Database) => {
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 opts = db.connectionOptions as MSSQLConnectionOptions | undefined;
15
- const pool = new ConnectionPool({
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?.connectionTimeout ? opts.connectionTimeout * 1000 : undefined,
22
- requestTimeout: opts?.requestTimeout ? opts.requestTimeout * 1000 : undefined,
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: opts?.encrypt ?? false,
25
- trustServerCertificate: opts?.trustServerCertificate ?? true,
26
- trustedConnection: opts?.trustedConnection ?? true,
48
+ encrypt: raw?.encrypt ?? false,
49
+ trustServerCertificate: raw?.trustServerCertificate ?? true,
50
+ trustedConnection: raw?.trustedConnection ?? true,
27
51
  },
28
52
  pool: {
29
- max: opts?.pool?.max ?? 50,
30
- min: opts?.pool?.min ?? 1,
31
- idleTimeoutMillis: (opts?.pool?.idleTimeout ?? 30) * 1000,
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?.parseJSON ?? true,
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 = pool!.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(field, variablesDefinition);
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 =