@memberjunction/server 5.47.0 → 5.49.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 (122) hide show
  1. package/README.md +9 -0
  2. package/dist/auth/magicLink/MagicLinkKeys.d.ts.map +1 -1
  3. package/dist/auth/magicLink/MagicLinkKeys.js +11 -4
  4. package/dist/auth/magicLink/MagicLinkKeys.js.map +1 -1
  5. package/dist/auth/magicLink/MagicLinkRouter.js +2 -2
  6. package/dist/auth/magicLink/MagicLinkRouter.js.map +1 -1
  7. package/dist/auth/magicLink/MagicLinkService.d.ts.map +1 -1
  8. package/dist/auth/magicLink/MagicLinkService.js +4 -0
  9. package/dist/auth/magicLink/MagicLinkService.js.map +1 -1
  10. package/dist/config.d.ts +40 -0
  11. package/dist/config.d.ts.map +1 -1
  12. package/dist/config.js +12 -0
  13. package/dist/config.js.map +1 -1
  14. package/dist/context.d.ts.map +1 -1
  15. package/dist/context.js +65 -3
  16. package/dist/context.js.map +1 -1
  17. package/dist/generated/generated.d.ts +122 -0
  18. package/dist/generated/generated.d.ts.map +1 -1
  19. package/dist/generated/generated.js +629 -0
  20. package/dist/generated/generated.js.map +1 -1
  21. package/dist/generic/ResolverBase.d.ts.map +1 -1
  22. package/dist/generic/ResolverBase.js +4 -2
  23. package/dist/generic/ResolverBase.js.map +1 -1
  24. package/dist/generic/RunViewResolver.d.ts +1 -0
  25. package/dist/generic/RunViewResolver.d.ts.map +1 -1
  26. package/dist/generic/RunViewResolver.js +12 -0
  27. package/dist/generic/RunViewResolver.js.map +1 -1
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/index.js +99 -11
  30. package/dist/index.js.map +1 -1
  31. package/dist/integration/CustomColumnPromoter.d.ts +15 -2
  32. package/dist/integration/CustomColumnPromoter.d.ts.map +1 -1
  33. package/dist/integration/CustomColumnPromoter.js +113 -11
  34. package/dist/integration/CustomColumnPromoter.js.map +1 -1
  35. package/dist/integration/EntityMapLifecycle.d.ts +66 -0
  36. package/dist/integration/EntityMapLifecycle.d.ts.map +1 -0
  37. package/dist/integration/EntityMapLifecycle.js +189 -0
  38. package/dist/integration/EntityMapLifecycle.js.map +1 -0
  39. package/dist/realtimeProxy/RealtimeProxyServer.d.ts +58 -0
  40. package/dist/realtimeProxy/RealtimeProxyServer.d.ts.map +1 -0
  41. package/dist/realtimeProxy/RealtimeProxyServer.js +246 -0
  42. package/dist/realtimeProxy/RealtimeProxyServer.js.map +1 -0
  43. package/dist/realtimeWidget/WidgetRouter.d.ts.map +1 -1
  44. package/dist/realtimeWidget/WidgetRouter.js +6 -2
  45. package/dist/realtimeWidget/WidgetRouter.js.map +1 -1
  46. package/dist/resolvers/AdhocQueryResolver.d.ts +17 -0
  47. package/dist/resolvers/AdhocQueryResolver.d.ts.map +1 -1
  48. package/dist/resolvers/AdhocQueryResolver.js +81 -25
  49. package/dist/resolvers/AdhocQueryResolver.js.map +1 -1
  50. package/dist/resolvers/GetDataResolver.d.ts +16 -0
  51. package/dist/resolvers/GetDataResolver.d.ts.map +1 -1
  52. package/dist/resolvers/GetDataResolver.js +26 -1
  53. package/dist/resolvers/GetDataResolver.js.map +1 -1
  54. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts +53 -1
  55. package/dist/resolvers/IntegrationDiscoveryResolver.d.ts.map +1 -1
  56. package/dist/resolvers/IntegrationDiscoveryResolver.js +522 -60
  57. package/dist/resolvers/IntegrationDiscoveryResolver.js.map +1 -1
  58. package/dist/resolvers/QueryResolver.d.ts.map +1 -1
  59. package/dist/resolvers/QueryResolver.js +0 -6
  60. package/dist/resolvers/QueryResolver.js.map +1 -1
  61. package/dist/resolvers/RSUResolver.d.ts +10 -0
  62. package/dist/resolvers/RSUResolver.d.ts.map +1 -1
  63. package/dist/resolvers/RSUResolver.js +28 -0
  64. package/dist/resolvers/RSUResolver.js.map +1 -1
  65. package/dist/resolvers/RunAIAgentResolver.d.ts +9 -6
  66. package/dist/resolvers/RunAIAgentResolver.d.ts.map +1 -1
  67. package/dist/resolvers/RunAIAgentResolver.js +135 -132
  68. package/dist/resolvers/RunAIAgentResolver.js.map +1 -1
  69. package/dist/resolvers/TestQuerySQLResolver.d.ts +1 -0
  70. package/dist/resolvers/TestQuerySQLResolver.d.ts.map +1 -1
  71. package/dist/resolvers/TestQuerySQLResolver.js +5 -0
  72. package/dist/resolvers/TestQuerySQLResolver.js.map +1 -1
  73. package/dist/resolvers/TransactionGroupResolver.d.ts +8 -1
  74. package/dist/resolvers/TransactionGroupResolver.d.ts.map +1 -1
  75. package/dist/resolvers/TransactionGroupResolver.js +29 -1
  76. package/dist/resolvers/TransactionGroupResolver.js.map +1 -1
  77. package/dist/resolvers/adhoc-query-helpers.d.ts +27 -0
  78. package/dist/resolvers/adhoc-query-helpers.d.ts.map +1 -0
  79. package/dist/resolvers/adhoc-query-helpers.js +42 -0
  80. package/dist/resolvers/adhoc-query-helpers.js.map +1 -0
  81. package/dist/rest/OAuthCallbackHandler.d.ts +9 -0
  82. package/dist/rest/OAuthCallbackHandler.d.ts.map +1 -1
  83. package/dist/rest/OAuthCallbackHandler.js +21 -4
  84. package/dist/rest/OAuthCallbackHandler.js.map +1 -1
  85. package/dist/telephony/media-upgrade-router.d.ts +14 -0
  86. package/dist/telephony/media-upgrade-router.d.ts.map +1 -1
  87. package/dist/telephony/media-upgrade-router.js +17 -1
  88. package/dist/telephony/media-upgrade-router.js.map +1 -1
  89. package/package.json +88 -88
  90. package/src/__tests__/AdhocQueryResolver.pagination.test.ts +114 -0
  91. package/src/__tests__/EntityMapLifecycle.dagWarnings.test.ts +91 -0
  92. package/src/__tests__/OAuthCallbackHandler.xss.test.ts +83 -0
  93. package/src/__tests__/RealtimeProxyServer.test.ts +122 -0
  94. package/src/__tests__/adhoc-query-helpers.test.ts +50 -0
  95. package/src/__tests__/magicLinkService.provisionLog.test.ts +106 -0
  96. package/src/__tests__/media-upgrade-router.test.ts +43 -0
  97. package/src/__tests__/unifiedAuth.test.ts +18 -3
  98. package/src/auth/magicLink/MagicLinkKeys.ts +12 -6
  99. package/src/auth/magicLink/MagicLinkRouter.ts +2 -2
  100. package/src/auth/magicLink/MagicLinkService.ts +4 -0
  101. package/src/config.ts +14 -0
  102. package/src/context.ts +74 -3
  103. package/src/generated/generated.ts +446 -1
  104. package/src/generic/ResolverBase.ts +5 -2
  105. package/src/generic/RunViewResolver.ts +11 -0
  106. package/src/index.ts +100 -11
  107. package/src/integration/CustomColumnPromoter.ts +123 -6
  108. package/src/integration/EntityMapLifecycle.ts +221 -0
  109. package/src/realtimeProxy/RealtimeProxyServer.ts +265 -0
  110. package/src/realtimeWidget/WidgetRouter.ts +6 -2
  111. package/src/resolvers/AdhocQueryResolver.ts +97 -30
  112. package/src/resolvers/GetDataResolver.ts +30 -1
  113. package/src/resolvers/IntegrationDiscoveryResolver.ts +514 -60
  114. package/src/resolvers/QueryResolver.ts +1 -7
  115. package/src/resolvers/RSUResolver.ts +28 -0
  116. package/src/resolvers/RunAIAgentResolver.ts +74 -70
  117. package/src/resolvers/TestQuerySQLResolver.ts +4 -0
  118. package/src/resolvers/TransactionGroupResolver.ts +36 -2
  119. package/src/resolvers/__tests__/GetDataResolver.tokens.test.ts +138 -0
  120. package/src/resolvers/adhoc-query-helpers.ts +50 -0
  121. package/src/rest/OAuthCallbackHandler.ts +22 -4
  122. package/src/telephony/media-upgrade-router.ts +18 -1
@@ -0,0 +1,265 @@
1
+ import type { IncomingMessage } from 'node:http';
2
+ import type { Duplex } from 'node:stream';
3
+ import { WebSocket as WsClient, WebSocketServer, type RawData } from 'ws';
4
+ import { BaseSingleton, ShutdownRegistry, type IShutdownable } from '@memberjunction/global';
5
+ import { RealtimeProxyRegistry, REALTIME_PROXY_PATH, type RealtimeProxyTicketEntry } from '@memberjunction/ai';
6
+
7
+ /**
8
+ * MJAPI's realtime websocket **proxy** — the transport half of the self-hosted realtime provider story.
9
+ *
10
+ * A provider driver (e.g. `HuggingFaceRealtime`) mints a one-time ticket into the shared
11
+ * {@link RealtimeProxyRegistry} pointing at an INTERNAL realtime endpoint, and hands the browser a
12
+ * `wss://<mjapi-public>{REALTIME_PROXY_PATH}?ticket=<id>` URL. The browser opens its socket HERE; this
13
+ * proxy consumes the ticket, opens the authenticated upstream leg (injecting any auth server-side), and
14
+ * pumps frames transparently in both directions. The internal endpoint + auth never reach the browser,
15
+ * and the internal box needs no browser-facing ingress — MJAPI stays the single ingress point.
16
+ *
17
+ * Deliberately provider-agnostic: it does no protocol translation (the same-keyed client driver owns the
18
+ * wire vocabulary) and knows nothing about any specific provider — it is a pure authenticated byte tunnel,
19
+ * reusable by any future self-hosted realtime provider that mints a ticket.
20
+ *
21
+ * A {@link BaseSingleton} + {@link IShutdownable}: it tracks live tunnels and closes them on graceful
22
+ * shutdown (drained by MJServer's `ShutdownRegistry` before `httpServer.close()`).
23
+ */
24
+ export class RealtimeProxyServer extends BaseSingleton<RealtimeProxyServer> implements IShutdownable {
25
+ public readonly ShutdownName = 'RealtimeProxyServer';
26
+
27
+ /** `noServer` so THIS server never binds its own upgrade listener — MJServer routes upgrades to it by path. */
28
+ private readonly wss = new WebSocketServer({ noServer: true });
29
+
30
+ /** Live browser↔upstream tunnels, tracked for shutdown teardown. */
31
+ private readonly tunnels = new Set<RealtimeProxyTunnel>();
32
+
33
+ private registered = false;
34
+
35
+ protected constructor() {
36
+ super();
37
+ }
38
+
39
+ /** Process-wide singleton accessor. */
40
+ public static get Instance(): RealtimeProxyServer {
41
+ return super.getInstance<RealtimeProxyServer>();
42
+ }
43
+
44
+ /**
45
+ * Routes an HTTP `upgrade` for {@link REALTIME_PROXY_PATH} to the proxy. Returns `true` when it OWNS
46
+ * (handled/rejected) the request, `false` when the path is not the proxy's — so the caller leaves the
47
+ * socket for the GraphQL websocket server's own upgrade listener. NEVER destroys a socket it doesn't own.
48
+ */
49
+ public TryHandleUpgrade(request: IncomingMessage, socket: Duplex, head: Buffer): boolean {
50
+ const url = RealtimeProxyServer.parseUrl(request.url);
51
+ if (!url || url.pathname !== REALTIME_PROXY_PATH) {
52
+ return false; // not ours — leave it for the GraphQL WS server
53
+ }
54
+ this.ensureRegistered();
55
+ // Optional Origin allowlist (MJ_REALTIME_PROXY_ALLOWED_ORIGINS: comma-separated origins).
56
+ // Unset ⇒ prior behavior (the single-use short-TTL ticket remains the primary guard);
57
+ // set ⇒ a browser page on a foreign origin cannot ride a leaked ticket id.
58
+ if (!RealtimeProxyServer.originAllowed(request.headers?.origin)) {
59
+ RealtimeProxyServer.rejectUpgrade(socket, 403, 'Forbidden');
60
+ return true;
61
+ }
62
+ const ticketId = url.searchParams.get('ticket') ?? '';
63
+ const entry = RealtimeProxyRegistry.Instance.Consume(ticketId);
64
+ if (!entry) {
65
+ RealtimeProxyServer.rejectUpgrade(socket, 401, 'Unauthorized');
66
+ return true;
67
+ }
68
+ this.wss.handleUpgrade(request, socket, head, (browserWs) => this.openTunnel(browserWs, entry));
69
+ return true;
70
+ }
71
+
72
+ /**
73
+ * Evaluates the optional Origin allowlist. Comparison is exact on the normalized origin
74
+ * (scheme://host[:port], lowercased, no trailing slash). An upgrade WITHOUT an Origin header
75
+ * (non-browser client, e.g. a native app or test rig) passes — the allowlist targets
76
+ * cross-origin BROWSER pages, which always send the header.
77
+ *
78
+ * @param origin The upgrade request's Origin header, if any.
79
+ * @returns True when allowed (or no allowlist is configured).
80
+ */
81
+ private static originAllowed(origin: string | undefined): boolean {
82
+ const raw = process.env['MJ_REALTIME_PROXY_ALLOWED_ORIGINS'];
83
+ if (!raw || raw.trim().length === 0) {
84
+ return true; // no allowlist configured — prior behavior
85
+ }
86
+ if (!origin) {
87
+ return true; // non-browser upgrade (no Origin header) — tickets remain the guard
88
+ }
89
+ const normalize = (value: string): string => value.trim().toLowerCase().replace(/\/+$/, '');
90
+ const allowed = raw.split(',').map(normalize).filter((v) => v.length > 0);
91
+ return allowed.includes(normalize(origin));
92
+ }
93
+
94
+ /** Opens the upstream leg and wires a bidirectional pump between the browser socket and it. */
95
+ private openTunnel(browserWs: WsClient, entry: RealtimeProxyTicketEntry): void {
96
+ const tunnel = new RealtimeProxyTunnel(browserWs, entry, () => this.tunnels.delete(tunnel));
97
+ this.tunnels.add(tunnel);
98
+ tunnel.Start();
99
+ }
100
+
101
+ /** Registers for graceful-shutdown draining exactly once (lazily, on first real use). */
102
+ private ensureRegistered(): void {
103
+ if (!this.registered) {
104
+ ShutdownRegistry.Instance.Register(this);
105
+ this.registered = true;
106
+ }
107
+ }
108
+
109
+ /** Closes every live tunnel and the proxy server. Idempotent; never throws. */
110
+ public Shutdown(): void {
111
+ for (const tunnel of [...this.tunnels]) {
112
+ tunnel.Close();
113
+ }
114
+ this.tunnels.clear();
115
+ try {
116
+ this.wss.close();
117
+ } catch {
118
+ /* already closing */
119
+ }
120
+ }
121
+
122
+ /** Parses `request.url` (a path+query) into a URL, or `null` when absent/unparseable. */
123
+ private static parseUrl(rawUrl: string | undefined): URL | null {
124
+ if (!rawUrl) {
125
+ return null;
126
+ }
127
+ try {
128
+ return new URL(rawUrl, 'http://internal'); // base is only for parsing path+query
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ /** Writes a minimal HTTP error response and destroys the socket (used for a rejected upgrade). */
135
+ private static rejectUpgrade(socket: Duplex, code: number, reason: string): void {
136
+ try {
137
+ socket.write(`HTTP/1.1 ${code} ${reason}\r\nConnection: close\r\n\r\n`);
138
+ } catch {
139
+ /* socket already gone */
140
+ }
141
+ socket.destroy();
142
+ }
143
+ }
144
+
145
+ /**
146
+ * One live browser↔upstream tunnel. Buffers browser→upstream frames until the upstream socket is open,
147
+ * then pumps both directions byte-transparently (text and binary alike). Closing either side closes the other.
148
+ */
149
+ class RealtimeProxyTunnel {
150
+ /** How long the upstream websocket may take to OPEN before the tunnel is torn down (ms). */
151
+ private static readonly UPSTREAM_OPEN_TIMEOUT_MS = 15_000;
152
+ /**
153
+ * Cap on frames buffered browser→upstream before the upstream opens. A half-connected
154
+ * upstream (TCP accepted, WS handshake never completes) must not let a browser streaming PCM
155
+ * grow an unbounded in-memory array on MJAPI; past the cap the OLDEST frames are dropped
156
+ * (voice frames are perishable — late delivery of stale audio is worthless anyway).
157
+ */
158
+ private static readonly MAX_PENDING_FRAMES = 512;
159
+
160
+ private upstream: WsClient | null = null;
161
+ /** Frames the browser sent before the upstream opened; flushed in order once it's ready. */
162
+ private readonly pending: Array<{ data: RawData; isBinary: boolean }> = [];
163
+ private openDeadline: ReturnType<typeof setTimeout> | null = null;
164
+ private droppedPendingFrames = 0;
165
+ private closed = false;
166
+
167
+ constructor(
168
+ private readonly browser: WsClient,
169
+ private readonly entry: RealtimeProxyTicketEntry,
170
+ private readonly onClosed: () => void
171
+ ) {}
172
+
173
+ /** Opens the upstream socket and wires both legs. */
174
+ public Start(): void {
175
+ const headers = this.entry.UpstreamAuthHeader ? { Authorization: this.entry.UpstreamAuthHeader } : undefined;
176
+ const upstream = new WsClient(this.entry.UpstreamUrl, { headers });
177
+ this.upstream = upstream;
178
+
179
+ // Deadline: an upstream that accepts TCP but never completes the WS handshake would
180
+ // otherwise hold the tunnel (and its buffer) open forever.
181
+ this.openDeadline = setTimeout(() => {
182
+ this.openDeadline = null;
183
+ if (!this.closed && upstream.readyState !== WsClient.OPEN) {
184
+ console.warn(`[RealtimeProxy] upstream did not open within ${RealtimeProxyTunnel.UPSTREAM_OPEN_TIMEOUT_MS}ms — closing tunnel`);
185
+ this.Close();
186
+ }
187
+ }, RealtimeProxyTunnel.UPSTREAM_OPEN_TIMEOUT_MS);
188
+ (this.openDeadline as { unref?: () => void }).unref?.();
189
+
190
+ upstream.on('open', () => {
191
+ if (this.openDeadline) {
192
+ clearTimeout(this.openDeadline);
193
+ this.openDeadline = null;
194
+ }
195
+ this.flushPending();
196
+ });
197
+ upstream.on('message', (data: RawData, isBinary: boolean) => this.forward(this.browser, data, isBinary));
198
+ upstream.on('close', () => this.Close());
199
+ upstream.on('error', () => this.Close());
200
+
201
+ this.browser.on('message', (data: RawData, isBinary: boolean) => this.fromBrowser(data, isBinary));
202
+ this.browser.on('close', () => this.Close());
203
+ this.browser.on('error', () => this.Close());
204
+ }
205
+
206
+ /** Browser→upstream: forward immediately when the upstream is open, else queue until it is. */
207
+ private fromBrowser(data: RawData, isBinary: boolean): void {
208
+ if (this.upstream && this.upstream.readyState === WsClient.OPEN) {
209
+ this.forward(this.upstream, data, isBinary);
210
+ } else {
211
+ this.pending.push({ data, isBinary });
212
+ if (this.pending.length > RealtimeProxyTunnel.MAX_PENDING_FRAMES) {
213
+ this.pending.shift(); // drop-oldest: stale voice frames are worthless anyway
214
+ this.droppedPendingFrames++;
215
+ if (this.droppedPendingFrames === 1 || this.droppedPendingFrames % 100 === 0) {
216
+ console.warn(`[RealtimeProxy] pre-open buffer cap hit — dropped ${this.droppedPendingFrames} oldest frame(s) awaiting upstream open`);
217
+ }
218
+ }
219
+ }
220
+ }
221
+
222
+ /** Drains any frames queued before the upstream opened. */
223
+ private flushPending(): void {
224
+ if (!this.upstream) {
225
+ return;
226
+ }
227
+ for (const frame of this.pending) {
228
+ this.forward(this.upstream, frame.data, frame.isBinary);
229
+ }
230
+ this.pending.length = 0;
231
+ }
232
+
233
+ /** Sends one frame on a socket, preserving the text/binary distinction; failures close the tunnel. */
234
+ private forward(target: WsClient, data: RawData, isBinary: boolean): void {
235
+ if (target.readyState !== WsClient.OPEN) {
236
+ return;
237
+ }
238
+ try {
239
+ target.send(data, { binary: isBinary });
240
+ } catch {
241
+ this.Close();
242
+ }
243
+ }
244
+
245
+ /** Closes both legs and detaches the tunnel from the server. Idempotent. */
246
+ public Close(): void {
247
+ if (this.openDeadline) {
248
+ clearTimeout(this.openDeadline);
249
+ this.openDeadline = null;
250
+ }
251
+ if (this.closed) {
252
+ return;
253
+ }
254
+ this.closed = true;
255
+ this.pending.length = 0;
256
+ for (const sock of [this.browser, this.upstream]) {
257
+ try {
258
+ sock?.close();
259
+ } catch {
260
+ /* already closing */
261
+ }
262
+ }
263
+ this.onClosed();
264
+ }
265
+ }
@@ -11,7 +11,7 @@
11
11
  */
12
12
 
13
13
  import { Router, json, type Request, type Response } from 'express';
14
- import { rateLimit } from 'express-rate-limit';
14
+ import { rateLimit, ipKeyGenerator } from 'express-rate-limit';
15
15
  import { LogStatus } from '@memberjunction/core';
16
16
  import { configInfo, type WidgetConfig } from '../config.js';
17
17
  import { MagicLinkKeyManager } from '../auth/magicLink/MagicLinkKeys.js';
@@ -65,7 +65,11 @@ export function createWidgetHandler(publicUrl: string, config: WidgetConfig): {
65
65
  windowMs: config.rateLimitWindowMs,
66
66
  standardHeaders: 'draft-7',
67
67
  legacyHeaders: false,
68
- keyGenerator: (req: Request) => `${readWidgetKey(req)}:${req.ip ?? 'noip'}`,
68
+ // ipKeyGenerator collapses IPv6 addresses to their /56 subnet before keying. Raw `req.ip`
69
+ // would give every IPv6 client a practically unlimited supply of distinct keys (one per
70
+ // address in their delegated prefix), letting them sidestep the per-IP bucket entirely —
71
+ // express-rate-limit v8 refuses to start with a keyGenerator that reads req.ip without it.
72
+ keyGenerator: (req: Request) => `${readWidgetKey(req)}:${req.ip ? ipKeyGenerator(req.ip) : 'noip'}`,
69
73
  limit: async (req: Request) => service.ResolvePerInstanceRateLimit(readWidgetKey(req)),
70
74
  message: { success: false, errorCode: 'rate_limited', error: 'Too many widget session requests. Try again later.' },
71
75
  });
@@ -6,6 +6,7 @@ import { AppContext } from '../types.js';
6
6
  import { GetReadOnlyDataSource, GetReadOnlyProvider } from '../util.js';
7
7
  import { ResolverBase } from '../generic/ResolverBase.js';
8
8
  import { RunQueryResultType } from './QueryResolver.js';
9
+ import { exactTotalFromPage, resolveAdhocTotalRowCount } from './adhoc-query-helpers.js';
9
10
  import sql from 'mssql';
10
11
 
11
12
  /**
@@ -23,7 +24,7 @@ class AdhocQueryInput {
23
24
  @Field(() => Int, { nullable: true, description: 'Maximum number of rows to return; applied at the database via the render pipeline.' })
24
25
  MaxRows?: number;
25
26
 
26
- @Field(() => Int, { nullable: true, description: 'Zero-based offset for pagination. When > 0, the row cap switches to OFFSET/FETCH pagination.' })
27
+ @Field(() => Int, { nullable: true, description: 'Zero-based offset for pagination. Whenever MaxRows > 0 the query is paged via OFFSET/FETCH — including the first page (StartRow 0).' })
27
28
  StartRow?: number;
28
29
  }
29
30
 
@@ -76,55 +77,53 @@ export class AdhocQueryResolver extends ResolverBase {
76
77
  // 4. Route the SQL through RenderPipeline so composition tokens
77
78
  // resolve, comments and templates are processed, and the row cap
78
79
  // is applied at the database (via TOP / LIMIT / OFFSET-FETCH).
79
- const startRow = input.StartRow ?? 0;
80
+ //
81
+ // Page whenever a positive MaxRows is requested — INCLUDING the first
82
+ // page (StartRow 0). This yields OFFSET/FETCH data SQL *and* a
83
+ // COUNT(*) query so the response reports the true total row count
84
+ // rather than just the returned page size. It mirrors the saved-query
85
+ // path (GenericDatabaseProvider.InternalRunQuery → WrapWithPaging).
86
+ // (Previously paging was gated on StartRow > 0, so page 1 fell back to
87
+ // a TOP-N cap with TotalRowCount = page size — hiding the pager.)
88
+ // Clamp StartRow to a non-negative integer. A negative offset must not slip
89
+ // past the paging gate (which would then run the query with no row cap at all).
90
+ const startRow = Math.max(0, Number.isInteger(input.StartRow) ? input.StartRow! : 0);
80
91
  const maxRows = input.MaxRows;
81
- const usePaging =
82
- maxRows != null &&
83
- Number.isInteger(maxRows) &&
84
- maxRows > 0 &&
85
- Number.isInteger(startRow) &&
86
- startRow > 0;
87
- let executableSql: string;
92
+ const usePaging = maxRows != null && Number.isInteger(maxRows) && maxRows > 0;
93
+
94
+ let dataSQL: string;
95
+ let countSQL: string | null = null;
88
96
  try {
89
97
  const rendered = RenderPipeline.Run(input.SQL, {
90
98
  Platform: platform,
91
99
  ContextUser: contextUser,
92
- ...(usePaging
93
- ? { Paging: { StartRow: startRow, MaxRows: maxRows! } }
94
- : maxRows != null && maxRows > 0
95
- ? { MaxRows: maxRows }
96
- : {}),
100
+ ...(usePaging ? { Paging: { StartRow: startRow, MaxRows: maxRows! } } : {}),
97
101
  });
98
- executableSql = rendered.FinalSQL;
102
+ dataSQL = rendered.FinalSQL;
103
+ countSQL = rendered.PagingResult?.CountSQL ?? null;
99
104
  } catch (renderErr) {
100
105
  const renderMsg = renderErr instanceof Error ? renderErr.message : String(renderErr);
101
106
  return this.buildErrorResult(`Ad-hoc query rendering failed: ${renderMsg}`);
102
107
  }
103
108
 
104
- // 5. Execute with timeout
105
- const timeoutMs = (input.TimeoutSeconds ?? 30) * 1000;
106
- const request = new sql.Request(readOnlyDS);
107
-
108
- const result = await Promise.race([
109
- request.query(executableSql),
110
- new Promise<never>((_, reject) =>
111
- setTimeout(() => reject(new Error('Query timeout exceeded')), timeoutMs)
112
- )
113
- ]);
109
+ // 5. Execute the page (and, only when a full page needs it, the count) under
110
+ // a shared wall-clock deadline derived from the request's timeout budget.
111
+ const deadline = startTime + (input.TimeoutSeconds ?? 30) * 1000;
112
+ const { recordset, totalRowCount } = await this.executeDataAndCount(
113
+ readOnlyDS, dataSQL, countSQL, startRow, usePaging ? maxRows! : null, deadline
114
+ );
114
115
  const executionTimeMs = Date.now() - startTime;
115
116
 
116
117
  // 6. Return as RunQueryResultType
117
- const recordset = result.recordset ?? [];
118
-
119
118
  return {
120
119
  QueryID: '',
121
120
  QueryName: 'Ad-Hoc Query',
122
121
  Success: true,
123
122
  Results: JSON.stringify(recordset),
124
123
  RowCount: recordset.length,
125
- TotalRowCount: recordset.length,
126
- PageNumber: maxRows != null && maxRows > 0 ? Math.floor(startRow / maxRows) + 1 : undefined,
127
- PageSize: maxRows ?? undefined,
124
+ TotalRowCount: totalRowCount,
125
+ PageNumber: usePaging ? Math.floor(startRow / maxRows!) + 1 : undefined,
126
+ PageSize: usePaging ? maxRows! : undefined,
128
127
  ExecutionTime: executionTimeMs,
129
128
  ErrorMessage: ''
130
129
  };
@@ -164,6 +163,74 @@ export class AdhocQueryResolver extends ResolverBase {
164
163
  }
165
164
  }
166
165
 
166
+ /**
167
+ * Runs the page's data SQL, then — only when the page is FULL (so more rows may
168
+ * exist) — a COUNT(*) for the true total. A short (or unpaged) page needs no
169
+ * count: the exact total is `startRow + rowsReturned` (see {@link exactTotalFromPage}).
170
+ *
171
+ * The count is NON-FATAL: some queries page fine but cannot be counted — e.g.
172
+ * duplicate column names are legal in a result set but rejected inside the COUNT
173
+ * CTE wrap. A count failure must never sink the whole result, so we log it and
174
+ * report a lower-bound total (`startRow + rowsReturned`) and let the data render.
175
+ */
176
+ private async executeDataAndCount(
177
+ ds: sql.ConnectionPool,
178
+ dataSQL: string,
179
+ countSQL: string | null,
180
+ startRow: number,
181
+ maxRows: number | null,
182
+ deadline: number,
183
+ ): Promise<{ recordset: Record<string, unknown>[]; totalRowCount: number }> {
184
+ const dataResult = await this.runSqlWithDeadline<Record<string, unknown>>(ds, dataSQL, deadline);
185
+ const recordset = (dataResult.recordset ?? []) as Record<string, unknown>[];
186
+
187
+ // Total already known from the page alone (unpaged, or a short page)? Skip the count.
188
+ const exact = exactTotalFromPage(startRow, recordset.length, maxRows);
189
+ if (exact != null || !countSQL) {
190
+ return { recordset, totalRowCount: exact ?? recordset.length };
191
+ }
192
+
193
+ // Full page — a COUNT(*) is required to know the true total.
194
+ const lowerBound = startRow + recordset.length;
195
+ try {
196
+ const countResult = await this.runSqlWithDeadline<{ TotalRowCount: number }>(ds, countSQL, deadline);
197
+ return { recordset, totalRowCount: resolveAdhocTotalRowCount(countResult.recordset, lowerBound) };
198
+ } catch (countErr) {
199
+ const msg = countErr instanceof Error ? countErr.message : String(countErr);
200
+ LogError(`Ad-hoc query row-count failed; reporting a lower-bound total (${lowerBound}). ${msg}`);
201
+ return { recordset, totalRowCount: lowerBound };
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Executes one SQL statement on the read-only pool, racing it against the shared
207
+ * wall-clock `deadline`. The timer is always cleared on completion so a settled
208
+ * query never leaves a dangling timeout armed.
209
+ */
210
+ private async runSqlWithDeadline<T>(
211
+ ds: sql.ConnectionPool,
212
+ sqlText: string,
213
+ deadline: number,
214
+ ): Promise<sql.IResult<T>> {
215
+ const remaining = deadline - Date.now();
216
+ if (remaining <= 0) {
217
+ throw new Error('Query timeout exceeded');
218
+ }
219
+ let timer: ReturnType<typeof setTimeout> | undefined;
220
+ try {
221
+ return await Promise.race([
222
+ new sql.Request(ds).query<T>(sqlText),
223
+ new Promise<never>((_, reject) => {
224
+ timer = setTimeout(() => reject(new Error('Query timeout exceeded')), remaining);
225
+ }),
226
+ ]);
227
+ } finally {
228
+ if (timer) {
229
+ clearTimeout(timer);
230
+ }
231
+ }
232
+ }
233
+
167
234
  private buildErrorResult(errorMessage: string): RunQueryResultType {
168
235
  return {
169
236
  QueryID: '',
@@ -281,8 +281,37 @@ export class GetDataAccessToken {
281
281
  TokenUses: TokenUseLog[];
282
282
  }
283
283
  const __accessTokens: GetDataAccessToken[] = [];
284
- const __defaultTokenLifeSpan = 1000 * 60 * 5; // 5 minutes
284
+ const __defaultTokenLifeSpan = 1000 * 60 * 5; // 5 minutes
285
+
286
+ /**
287
+ * Removes every token whose `ExpiresAt` has already passed (and its `TokenUses`
288
+ * log along with it). Without this sweep `__accessTokens` grows forever — nothing
289
+ * else in this module ever removes an expired token, `deleteAccessToken()` has no
290
+ * production caller (tokens are consumed implicitly via short-lived `GetData`
291
+ * calls, never explicitly released by the requesting system), and `isTokenValid()`
292
+ * only checks the expiry timestamp without pruning it. Called from
293
+ * `registerAccessToken()` — the natural "allocation" moment — so the array
294
+ * self-bounds to roughly "tokens registered within one lifespan window" instead
295
+ * of growing with total lifetime registrations (Memory Leak Audit Round 7,
296
+ * Critical finding).
297
+ * @param now injectable for tests; defaults to the real current time
298
+ */
299
+ export function pruneExpiredTokens(now: Date = new Date()): void {
300
+ for (let i = __accessTokens.length - 1; i >= 0; i--) {
301
+ if (__accessTokens[i].ExpiresAt <= now) {
302
+ __accessTokens.splice(i, 1);
303
+ }
304
+ }
305
+ }
306
+
307
+ /** Number of tokens currently tracked (post-sweep, at last mutation). Exposed for tests/diagnostics. */
308
+ export function getAccessTokenCount(): number {
309
+ return __accessTokens.length;
310
+ }
311
+
285
312
  export function registerAccessToken(token?: string, lifeSpan: number = __defaultTokenLifeSpan, requestorPayload?: any): GetDataAccessToken {
313
+ pruneExpiredTokens();
314
+
286
315
  const tokenToUse = token || uuidv4();
287
316
 
288
317
  if (tokenExists(tokenToUse)) {