@malloy-publisher/server 0.0.199 → 0.0.201

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 (64) hide show
  1. package/dist/app/api-doc.yaml +110 -118
  2. package/dist/app/assets/{EnvironmentPage-Dpee_Kn6.js → EnvironmentPage-KoP4wt8H.js} +1 -1
  3. package/dist/app/assets/HomePage-HbPwKL84.js +1 -0
  4. package/dist/app/assets/MainPage-DfK4zDYO.js +2 -0
  5. package/dist/app/assets/{ModelPage-AwAugZ37.js → ModelPage-CUgSwGXg.js} +1 -1
  6. package/dist/app/assets/{PackagePage-XQ-EWGTC.js → PackagePage-CUDQNL5k.js} +1 -1
  7. package/dist/app/assets/{RouteError-3Mv8JQw7.js → RouteError-sgmtBdg8.js} +1 -1
  8. package/dist/app/assets/{WorkbookPage-DHYYpcYc.js → WorkbookPage-tnWmLcrW.js} +1 -1
  9. package/dist/app/assets/{core-DfcpQGVP.es-DQggNOdX.js → core-B3IQNPBD.es-foBNuT8L.js} +10 -10
  10. package/dist/app/assets/{index-D1pdwrUW.js → index-B5We8x8r.js} +1 -1
  11. package/dist/app/assets/{index-BUp81Qdm.js → index-KIvi9k3F.js} +1 -1
  12. package/dist/app/assets/index-PNYovl3E.js +452 -0
  13. package/dist/app/assets/{index.umd-CQH4LZU8.js → index.umd-BXcsl2XW.js} +1 -1
  14. package/dist/app/index.html +1 -1
  15. package/dist/package_load_worker.mjs +1 -1
  16. package/dist/server.mjs +1556 -1018
  17. package/package.json +1 -1
  18. package/publisher.config.json +4 -0
  19. package/src/config.spec.ts +246 -0
  20. package/src/config.ts +121 -1
  21. package/src/constants.ts +84 -1
  22. package/src/controller/connection.controller.spec.ts +803 -0
  23. package/src/controller/connection.controller.ts +207 -20
  24. package/src/controller/model.controller.ts +16 -5
  25. package/src/controller/query.controller.ts +20 -7
  26. package/src/controller/watch-mode.controller.ts +11 -2
  27. package/src/errors.spec.ts +44 -0
  28. package/src/errors.ts +34 -0
  29. package/src/filter_deprecation.spec.ts +64 -0
  30. package/src/filter_deprecation.ts +42 -0
  31. package/src/heap_check.spec.ts +144 -0
  32. package/src/heap_check.ts +144 -0
  33. package/src/mcp/handler_utils.ts +14 -0
  34. package/src/mcp/tools/execute_query_tool.ts +44 -14
  35. package/src/oom_guards.integration.spec.ts +261 -0
  36. package/src/path_safety.ts +9 -3
  37. package/src/query_cap_metrics.spec.ts +89 -0
  38. package/src/query_cap_metrics.ts +115 -0
  39. package/src/query_concurrency.spec.ts +247 -0
  40. package/src/query_concurrency.ts +236 -0
  41. package/src/query_timeout.spec.ts +224 -0
  42. package/src/query_timeout.ts +178 -0
  43. package/src/server-old.ts +20 -0
  44. package/src/server.ts +57 -72
  45. package/src/service/connection.spec.ts +244 -0
  46. package/src/service/connection.ts +14 -4
  47. package/src/service/environment.ts +124 -4
  48. package/src/service/environment_admission.spec.ts +165 -1
  49. package/src/service/environment_store.spec.ts +103 -0
  50. package/src/service/environment_store.ts +74 -23
  51. package/src/service/filter_integration.spec.ts +69 -0
  52. package/src/service/model.spec.ts +193 -3
  53. package/src/service/model.ts +95 -14
  54. package/src/service/model_limits.spec.ts +181 -0
  55. package/src/service/model_limits.ts +110 -0
  56. package/src/service/package.spec.ts +2 -6
  57. package/src/service/package.ts +6 -1
  58. package/src/service/path_injection.spec.ts +39 -0
  59. package/src/stream_helpers.spec.ts +280 -0
  60. package/src/stream_helpers.ts +162 -0
  61. package/src/test_helpers/metrics_harness.ts +126 -0
  62. package/dist/app/assets/HomePage-DLRWTNoL.js +0 -1
  63. package/dist/app/assets/MainPage-DsVt5QGM.js +0 -2
  64. package/dist/app/assets/index-Dv5bF4Ii.js +0 -451
@@ -27,6 +27,11 @@ import {
27
27
  } from "../errors";
28
28
  import { getOperationalState, markNotReady, markReady } from "../health";
29
29
  import { formatDuration, logger } from "../logger";
30
+ import {
31
+ assertSafeEnvironmentPath,
32
+ assertSafePackageName,
33
+ safeJoinUnderRoot,
34
+ } from "../path_safety";
30
35
  import { Connection } from "../storage/DatabaseInterface";
31
36
  import { StorageConfig, StorageManager } from "../storage/StorageManager";
32
37
  import { Environment, PackageStatus } from "./environment";
@@ -756,6 +761,7 @@ export class EnvironmentStore {
756
761
  reload: boolean = false,
757
762
  ): Promise<Environment> {
758
763
  await this.finishedInitialization;
764
+ assertSafePackageName(environmentName);
759
765
 
760
766
  // Check if environment is already loaded first
761
767
  const environment = this.environments.get(environmentName);
@@ -816,9 +822,10 @@ export class EnvironmentStore {
816
822
  if (!skipInitialization && this.publisherConfigIsFrozen) {
817
823
  throw new FrozenConfigError();
818
824
  }
825
+ assertSafePackageName(environment.name);
819
826
  const environmentName = environment.name;
820
- if (!environmentName) {
821
- throw new Error("Environment name is required");
827
+ for (const _package of environment.packages || []) {
828
+ assertSafePackageName(_package.name);
822
829
  }
823
830
  // Check if environment already exists and update it instead of creating a new one
824
831
  const existingEnvironment = this.environments.get(environmentName);
@@ -884,6 +891,7 @@ export class EnvironmentStore {
884
891
  }
885
892
 
886
893
  public async unzipEnvironment(absoluteEnvironmentPath: string) {
894
+ assertSafeEnvironmentPath(absoluteEnvironmentPath);
887
895
  const startedAt = Date.now();
888
896
  logger.info(
889
897
  `Detected zip file at "${absoluteEnvironmentPath}". Unzipping...`,
@@ -930,9 +938,10 @@ export class EnvironmentStore {
930
938
  throw new FrozenConfigError();
931
939
  }
932
940
  validateEnvironmentAzureUrls(environment);
941
+ assertSafePackageName(environment.name);
933
942
  const environmentName = environment.name;
934
- if (!environmentName) {
935
- throw new Error("Environment name is required");
943
+ for (const _package of environment.packages || []) {
944
+ assertSafePackageName(_package.name);
936
945
  }
937
946
  const existingEnvironment = this.environments.get(environmentName);
938
947
  if (!existingEnvironment) {
@@ -953,6 +962,7 @@ export class EnvironmentStore {
953
962
  if (this.publisherConfigIsFrozen) {
954
963
  throw new FrozenConfigError();
955
964
  }
965
+ assertSafePackageName(environmentName);
956
966
  const environment = this.environments.get(environmentName);
957
967
  if (!environment) {
958
968
  return;
@@ -1027,15 +1037,17 @@ export class EnvironmentStore {
1027
1037
  }
1028
1038
 
1029
1039
  private async scaffoldEnvironment(environment: ApiEnvironment) {
1040
+ assertSafePackageName(environment.name);
1030
1041
  const environmentName = environment.name;
1031
- if (!environmentName) {
1032
- throw new Error("Environment name is required");
1033
- }
1034
- const absoluteEnvironmentPath = `${this.serverRootPath}/${PUBLISHER_DATA_DIR}/${environmentName}`;
1042
+ const absoluteEnvironmentPath = safeJoinUnderRoot(
1043
+ this.serverRootPath,
1044
+ PUBLISHER_DATA_DIR,
1045
+ environmentName,
1046
+ );
1035
1047
  await fs.promises.mkdir(absoluteEnvironmentPath, { recursive: true });
1036
1048
  if (environment.readme) {
1037
1049
  await fs.promises.writeFile(
1038
- path.join(absoluteEnvironmentPath, "README.md"),
1050
+ safeJoinUnderRoot(absoluteEnvironmentPath, "README.md"),
1039
1051
  environment.readme,
1040
1052
  );
1041
1053
  }
@@ -1071,7 +1083,12 @@ export class EnvironmentStore {
1071
1083
  environmentName: string,
1072
1084
  packages: ApiEnvironment["packages"],
1073
1085
  ) {
1074
- const absoluteTargetPath = `${this.serverRootPath}/${PUBLISHER_DATA_DIR}/${environmentName}`;
1086
+ assertSafePackageName(environmentName);
1087
+ const absoluteTargetPath = safeJoinUnderRoot(
1088
+ this.serverRootPath,
1089
+ PUBLISHER_DATA_DIR,
1090
+ environmentName,
1091
+ );
1075
1092
 
1076
1093
  await fs.promises.mkdir(absoluteTargetPath, { recursive: true });
1077
1094
 
@@ -1126,7 +1143,10 @@ export class EnvironmentStore {
1126
1143
  .update(groupedLocation)
1127
1144
  .digest("hex")
1128
1145
  .substring(0, 16); // Use first 16 chars for shorter paths
1129
- const tempDownloadPath = `${absoluteTargetPath}/.temp_${locationHash}`;
1146
+ const tempDownloadPath = safeJoinUnderRoot(
1147
+ absoluteTargetPath,
1148
+ `.temp_${locationHash}`,
1149
+ );
1130
1150
  await fs.promises.mkdir(tempDownloadPath, { recursive: true });
1131
1151
  logger.info(`Created temporary directory: ${tempDownloadPath}`);
1132
1152
  try {
@@ -1140,7 +1160,11 @@ export class EnvironmentStore {
1140
1160
  // Extract each package from the downloaded content
1141
1161
  for (const _package of packagesForLocation) {
1142
1162
  const packageDir = _package.name;
1143
- const absolutePackagePath = `${absoluteTargetPath}/${packageDir}`;
1163
+ assertSafePackageName(packageDir);
1164
+ const absolutePackagePath = safeJoinUnderRoot(
1165
+ absoluteTargetPath,
1166
+ packageDir,
1167
+ );
1144
1168
  // For GitHub URLs, extract the subdirectory path from the original location
1145
1169
  let sourcePath: string;
1146
1170
  if (this.isGitHubURL(_package.location)) {
@@ -1151,7 +1175,7 @@ export class EnvironmentStore {
1151
1175
  const subPathMatch =
1152
1176
  _package.location.match(/\/tree\/[^/]+\/(.+)$/);
1153
1177
  if (subPathMatch) {
1154
- sourcePath = path.join(
1178
+ sourcePath = safeJoinUnderRoot(
1155
1179
  tempDownloadPath,
1156
1180
  subPathMatch[1],
1157
1181
  );
@@ -1168,7 +1192,10 @@ export class EnvironmentStore {
1168
1192
  if (this.isLocalPath(_package.location)) {
1169
1193
  sourcePath = _package.location;
1170
1194
  } else {
1171
- sourcePath = path.join(tempDownloadPath, groupedLocation);
1195
+ sourcePath = safeJoinUnderRoot(
1196
+ tempDownloadPath,
1197
+ groupedLocation,
1198
+ );
1172
1199
  }
1173
1200
  }
1174
1201
 
@@ -1347,6 +1374,10 @@ export class EnvironmentStore {
1347
1374
  environmentName: string,
1348
1375
  packageName: string,
1349
1376
  ) {
1377
+ // `environmentPath` is the operator-supplied mount source and may
1378
+ // legitimately be a relative path that resolves outside the
1379
+ // server root; only the target is asserted.
1380
+ assertSafeEnvironmentPath(absoluteTargetPath);
1350
1381
  if (environmentPath.endsWith(".zip")) {
1351
1382
  environmentPath = await this.unzipEnvironment(environmentPath);
1352
1383
  }
@@ -1374,6 +1405,7 @@ export class EnvironmentStore {
1374
1405
  absoluteDirPath: string,
1375
1406
  isCompressedFile: boolean,
1376
1407
  ) {
1408
+ assertSafeEnvironmentPath(absoluteDirPath);
1377
1409
  const trimmedPath = gcsPath.slice(5);
1378
1410
  const [bucketName, ...prefixParts] = trimmedPath.split("/");
1379
1411
  const prefix = prefixParts.join("/");
@@ -1396,10 +1428,15 @@ export class EnvironmentStore {
1396
1428
  }
1397
1429
  await Promise.all(
1398
1430
  files.map(async (file) => {
1399
- const relativeFilePath = file.name.replace(prefix, "");
1431
+ // Strip leading `/` left over from prefix removal when the
1432
+ // GCS prefix lacked a trailing slash — otherwise
1433
+ // `safeJoinUnderRoot` treats it as absolute and rejects.
1434
+ const relativeFilePath = file.name
1435
+ .replace(prefix, "")
1436
+ .replace(/^\/+/, "");
1400
1437
  const absoluteFilePath = isCompressedFile
1401
1438
  ? absoluteDirPath
1402
- : path.join(absoluteDirPath, relativeFilePath);
1439
+ : safeJoinUnderRoot(absoluteDirPath, relativeFilePath);
1403
1440
  if (file.name.endsWith("/")) {
1404
1441
  return;
1405
1442
  }
@@ -1424,6 +1461,7 @@ export class EnvironmentStore {
1424
1461
  absoluteDirPath: string,
1425
1462
  isCompressedFile: boolean = false,
1426
1463
  ) {
1464
+ assertSafeEnvironmentPath(absoluteDirPath);
1427
1465
  const trimmedPath = s3Path.slice(5);
1428
1466
  const [bucketName, ...prefixParts] = trimmedPath.split("/");
1429
1467
  const prefix = prefixParts.join("/");
@@ -1477,11 +1515,16 @@ export class EnvironmentStore {
1477
1515
  if (!key) {
1478
1516
  return;
1479
1517
  }
1480
- const relativeFilePath = key.replace(prefix, "");
1518
+ // Strip leading `/` left over from prefix removal when the
1519
+ // S3 prefix lacked a trailing slash — otherwise
1520
+ // `safeJoinUnderRoot` treats it as absolute and rejects.
1521
+ const relativeFilePath = key
1522
+ .replace(prefix, "")
1523
+ .replace(/^\/+/, "");
1481
1524
  if (!relativeFilePath || relativeFilePath.endsWith("/")) {
1482
1525
  return;
1483
1526
  }
1484
- const absoluteFilePath = path.join(
1527
+ const absoluteFilePath = safeJoinUnderRoot(
1485
1528
  absoluteDirPath,
1486
1529
  relativeFilePath,
1487
1530
  );
@@ -1532,6 +1575,7 @@ export class EnvironmentStore {
1532
1575
  }
1533
1576
 
1534
1577
  async downloadGitHubDirectory(githubUrl: string, absoluteDirPath: string) {
1578
+ assertSafeEnvironmentPath(absoluteDirPath);
1535
1579
  // First we'll clone the repo without the additional path
1536
1580
  // E.g. we're removing `/tree/main/imdb` from https://github.com/credibledata/malloy-samples/tree/main/imdb
1537
1581
  const githubInfo = this.parseGitHubUrl(githubUrl);
@@ -1539,7 +1583,11 @@ export class EnvironmentStore {
1539
1583
  throw new Error(`Invalid GitHub URL: ${githubUrl}`);
1540
1584
  }
1541
1585
  const { owner, repoName, packagePath } = githubInfo;
1542
- const cleanPackagePath = packagePath?.replace("/tree/main", "") || "";
1586
+ // `packagePath` is captured with a leading `/`; strip it so the
1587
+ // value is a relative segment usable with `safeJoinUnderRoot`.
1588
+ const cleanPackagePath = (
1589
+ packagePath?.replace("/tree/main", "") || ""
1590
+ ).replace(/^\/+/, "");
1543
1591
 
1544
1592
  // We'll make sure whatever was in absoluteDirPath is removed,
1545
1593
  // so we have a nice a clean directory where we can clone the repo
@@ -1579,7 +1627,10 @@ export class EnvironmentStore {
1579
1627
 
1580
1628
  // Remove all contents of absoluteDirPath (/var/publisher/asd123)
1581
1629
  // except for the cleanPackagePath directory (/var/publisher/asd123/imdb)
1582
- const packageFullPath = path.join(absoluteDirPath, cleanPackagePath);
1630
+ const packageFullPath = safeJoinUnderRoot(
1631
+ absoluteDirPath,
1632
+ cleanPackagePath,
1633
+ );
1583
1634
 
1584
1635
  // Check if the cleanPackagePath (/var/publisher/asd123/imdb) exists
1585
1636
  const packageExists = await fs.promises
@@ -1598,7 +1649,7 @@ export class EnvironmentStore {
1598
1649
  for (const entry of dirContents) {
1599
1650
  // Don't remove the cleanPackagePath directory itself (/var/publisher/asd123/imdb)
1600
1651
  if (entry !== cleanPackagePath.replace(/^\/+/, "").split("/")[0]) {
1601
- await fs.promises.rm(path.join(absoluteDirPath, entry), {
1652
+ await fs.promises.rm(safeJoinUnderRoot(absoluteDirPath, entry), {
1602
1653
  recursive: true,
1603
1654
  force: true,
1604
1655
  });
@@ -1609,8 +1660,8 @@ export class EnvironmentStore {
1609
1660
  const packageContents = await fs.promises.readdir(packageFullPath);
1610
1661
  for (const entry of packageContents) {
1611
1662
  await fs.promises.rename(
1612
- path.join(packageFullPath, entry),
1613
- path.join(absoluteDirPath, entry),
1663
+ safeJoinUnderRoot(packageFullPath, entry),
1664
+ safeJoinUnderRoot(absoluteDirPath, entry),
1614
1665
  );
1615
1666
  }
1616
1667
 
@@ -485,6 +485,75 @@ describe("filter integration", () => {
485
485
  });
486
486
  });
487
487
 
488
+ // -----------------------------------------------------------------------
489
+ // Mixed givens + #(filter) composition on the query-results path
490
+ //
491
+ // PR 5/6 of the givens migration deprecates `#(filter)` but keeps both
492
+ // injection paths working independently and in combination. These tests
493
+ // assert that `model.getQueryResults` (the path behind POST /…/query)
494
+ // composes a `given:` substitution with a `filterParams` WHERE-injection
495
+ // — same model, same call, both effects apply.
496
+ // -----------------------------------------------------------------------
497
+ describe("givens + filterParams composition", () => {
498
+ it("composes a given override with filterParams on getQueryResults", async () => {
499
+ await writeFile(
500
+ "orders_givens_filter.malloy",
501
+ MODEL_WITH_GIVENS_AND_FILTER,
502
+ );
503
+ const model = await Model.create(
504
+ "test-pkg",
505
+ TEST_PKG_DIR,
506
+ "orders_givens_filter.malloy",
507
+ getConnections(),
508
+ );
509
+
510
+ // given restricts to APAC; filterParam restricts to active.
511
+ // APAC + active: only (5,'APAC','active',300) → order_count=1, total_amount=300.
512
+ const { compactResult } = await model.getQueryResults(
513
+ "orders",
514
+ "by_given_region",
515
+ undefined,
516
+ { status: "active" },
517
+ undefined,
518
+ { target_region: "APAC" },
519
+ );
520
+
521
+ const r = asRows(compactResult);
522
+ expect(r.length).toBe(1);
523
+ expect(Number(r[0].order_count)).toBe(1);
524
+ expect(Number(r[0].total_amount)).toBe(300);
525
+ });
526
+
527
+ it("falls back to the given default when no override is supplied", async () => {
528
+ await writeFile(
529
+ "orders_givens_filter.malloy",
530
+ MODEL_WITH_GIVENS_AND_FILTER,
531
+ );
532
+ const model = await Model.create(
533
+ "test-pkg",
534
+ TEST_PKG_DIR,
535
+ "orders_givens_filter.malloy",
536
+ getConnections(),
537
+ );
538
+
539
+ // Default given target_region='US'; filterParam restricts to active.
540
+ // US + active: (1,'US','active',100) and (2,'US','active',200) → order_count=2, total_amount=300.
541
+ const { compactResult } = await model.getQueryResults(
542
+ "orders",
543
+ "by_given_region",
544
+ undefined,
545
+ { status: "active" },
546
+ undefined,
547
+ undefined,
548
+ );
549
+
550
+ const r = asRows(compactResult);
551
+ expect(r.length).toBe(1);
552
+ expect(Number(r[0].order_count)).toBe(2);
553
+ expect(Number(r[0].total_amount)).toBe(300);
554
+ });
555
+ });
556
+
488
557
  // -----------------------------------------------------------------------
489
558
  // Required filter enforcement
490
559
  // -----------------------------------------------------------------------
@@ -1,9 +1,13 @@
1
- import { MalloyError, Runtime } from "@malloydata/malloy";
2
- import { describe, expect, it } from "bun:test";
1
+ import { API, MalloyError, Runtime } from "@malloydata/malloy";
2
+ import { afterEach, describe, expect, it } from "bun:test";
3
3
  import fs from "fs/promises";
4
4
  import sinon from "sinon";
5
5
 
6
- import { BadRequestError, ModelNotFoundError } from "../errors";
6
+ import {
7
+ BadRequestError,
8
+ ModelNotFoundError,
9
+ PayloadTooLargeError,
10
+ } from "../errors";
7
11
  import { Model, ModelType } from "./model";
8
12
 
9
13
  describe("service/model", () => {
@@ -287,6 +291,192 @@ describe("service/model", () => {
287
291
 
288
292
  sinon.restore();
289
293
  });
294
+
295
+ /**
296
+ * The row/byte caps live in `model_limits.ts` (unit-tested in
297
+ * `model_limits.spec.ts`); these tests just confirm the wiring —
298
+ * that `Model.getQueryResults` calls the helpers with the right
299
+ * values and that an overflow propagates as `PayloadTooLargeError`
300
+ * (HTTP 413), not the generic `BadRequestError` (HTTP 400).
301
+ */
302
+ describe("response caps", () => {
303
+ const originalRowsEnv = process.env.PUBLISHER_MAX_QUERY_ROWS;
304
+ const originalBytesEnv = process.env.PUBLISHER_MAX_RESPONSE_BYTES;
305
+ const originalDefaultEnv =
306
+ process.env.PUBLISHER_DEFAULT_QUERY_ROW_LIMIT;
307
+
308
+ afterEach(() => {
309
+ sinon.restore();
310
+ for (const [name, original] of [
311
+ ["PUBLISHER_MAX_QUERY_ROWS", originalRowsEnv],
312
+ ["PUBLISHER_MAX_RESPONSE_BYTES", originalBytesEnv],
313
+ ["PUBLISHER_DEFAULT_QUERY_ROW_LIMIT", originalDefaultEnv],
314
+ ] as const) {
315
+ if (original === undefined) {
316
+ delete process.env[name];
317
+ } else {
318
+ process.env[name] = original;
319
+ }
320
+ }
321
+ });
322
+
323
+ /**
324
+ * Build a Model whose `runnable.run` resolves to a fake Result
325
+ * with the given totalRows; stub `API.util.wrapResult` so we
326
+ * don't need to construct a real Malloy schema/queryResult.
327
+ */
328
+ function buildModelWithFakeRun(opts: {
329
+ userLimit?: number;
330
+ totalRows: number;
331
+ wrappedJson: object;
332
+ }): { model: Model; runStub: sinon.SinonStub } {
333
+ const preparedResultStub = sinon
334
+ .stub()
335
+ .resolves({ resultExplore: { limit: opts.userLimit ?? 0 } });
336
+ const fakeResult = {
337
+ _queryResult: { data: { rawData: [] } },
338
+ totalRows: opts.totalRows,
339
+ data: { value: [] },
340
+ connectionName: "fake",
341
+ };
342
+ const runStub = sinon.stub().resolves(fakeResult);
343
+ sinon
344
+ .stub(API.util, "wrapResult")
345
+ .returns(
346
+ opts.wrappedJson as unknown as ReturnType<
347
+ typeof API.util.wrapResult
348
+ >,
349
+ );
350
+ const modelMaterializer = {
351
+ loadQuery: sinon.stub().returns({
352
+ getPreparedResult: preparedResultStub,
353
+ run: runStub,
354
+ }),
355
+ };
356
+ const model = new Model(
357
+ packageName,
358
+ mockModelPath,
359
+ {},
360
+ "model",
361
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
362
+ modelMaterializer as any,
363
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
364
+ { contents: {}, exports: [], queryList: [] } as any,
365
+ undefined,
366
+ undefined,
367
+ undefined,
368
+ undefined,
369
+ undefined,
370
+ );
371
+ return { model, runStub };
372
+ }
373
+
374
+ it("clamps user LIMIT to maxRows + 1 when the user requested more than the cap", async () => {
375
+ process.env.PUBLISHER_MAX_QUERY_ROWS = "100";
376
+ const { model, runStub } = buildModelWithFakeRun({
377
+ userLimit: 1_000_000,
378
+ totalRows: 10,
379
+ wrappedJson: { rows: [] },
380
+ });
381
+
382
+ await model.getQueryResults(
383
+ undefined,
384
+ undefined,
385
+ "run: orders -> summary",
386
+ );
387
+
388
+ expect(runStub.firstCall.args[0].rowLimit).toBe(101);
389
+ });
390
+
391
+ it("passes user LIMIT through when below maxRows", async () => {
392
+ process.env.PUBLISHER_MAX_QUERY_ROWS = "100";
393
+ const { model, runStub } = buildModelWithFakeRun({
394
+ userLimit: 50,
395
+ totalRows: 10,
396
+ wrappedJson: { rows: [] },
397
+ });
398
+
399
+ await model.getQueryResults(
400
+ undefined,
401
+ undefined,
402
+ "run: orders -> summary",
403
+ );
404
+
405
+ expect(runStub.firstCall.args[0].rowLimit).toBe(50);
406
+ });
407
+
408
+ it("falls back to PUBLISHER_DEFAULT_QUERY_ROW_LIMIT when the user query has no LIMIT", async () => {
409
+ process.env.PUBLISHER_DEFAULT_QUERY_ROW_LIMIT = "42";
410
+ delete process.env.PUBLISHER_MAX_QUERY_ROWS;
411
+ const { model, runStub } = buildModelWithFakeRun({
412
+ userLimit: 0,
413
+ totalRows: 10,
414
+ wrappedJson: { rows: [] },
415
+ });
416
+
417
+ await model.getQueryResults(
418
+ undefined,
419
+ undefined,
420
+ "run: orders -> summary",
421
+ );
422
+
423
+ expect(runStub.firstCall.args[0].rowLimit).toBe(42);
424
+ });
425
+
426
+ it("throws PayloadTooLargeError (not BadRequestError) when totalRows exceeds the cap", async () => {
427
+ process.env.PUBLISHER_MAX_QUERY_ROWS = "100";
428
+ const { model } = buildModelWithFakeRun({
429
+ userLimit: 1000,
430
+ totalRows: 101,
431
+ wrappedJson: { rows: [] },
432
+ });
433
+
434
+ await expect(
435
+ model.getQueryResults(
436
+ undefined,
437
+ undefined,
438
+ "run: orders -> summary",
439
+ ),
440
+ ).rejects.toBeInstanceOf(PayloadTooLargeError);
441
+ });
442
+
443
+ it("throws PayloadTooLargeError when the wrapped response exceeds the byte cap", async () => {
444
+ process.env.PUBLISHER_MAX_QUERY_ROWS = "1000";
445
+ process.env.PUBLISHER_MAX_RESPONSE_BYTES = "100";
446
+ const huge = "x".repeat(500);
447
+ const { model } = buildModelWithFakeRun({
448
+ userLimit: 10,
449
+ totalRows: 10,
450
+ wrappedJson: { rows: [{ s: huge }] },
451
+ });
452
+
453
+ await expect(
454
+ model.getQueryResults(
455
+ undefined,
456
+ undefined,
457
+ "run: orders -> summary",
458
+ ),
459
+ ).rejects.toBeInstanceOf(PayloadTooLargeError);
460
+ });
461
+
462
+ it("does not throw when both counts are within their caps", async () => {
463
+ process.env.PUBLISHER_MAX_QUERY_ROWS = "1000";
464
+ process.env.PUBLISHER_MAX_RESPONSE_BYTES = "10000";
465
+ const { model } = buildModelWithFakeRun({
466
+ userLimit: 10,
467
+ totalRows: 10,
468
+ wrappedJson: { rows: [{ a: 1 }] },
469
+ });
470
+
471
+ await expect(
472
+ model.getQueryResults(
473
+ undefined,
474
+ undefined,
475
+ "run: orders -> summary",
476
+ ),
477
+ ).resolves.toBeDefined();
478
+ });
479
+ });
290
480
  });
291
481
 
292
482
  describe("executeNotebookCell", () => {