@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.
- package/dist/app/api-doc.yaml +110 -118
- package/dist/app/assets/{EnvironmentPage-Dpee_Kn6.js → EnvironmentPage-KoP4wt8H.js} +1 -1
- package/dist/app/assets/HomePage-HbPwKL84.js +1 -0
- package/dist/app/assets/MainPage-DfK4zDYO.js +2 -0
- package/dist/app/assets/{ModelPage-AwAugZ37.js → ModelPage-CUgSwGXg.js} +1 -1
- package/dist/app/assets/{PackagePage-XQ-EWGTC.js → PackagePage-CUDQNL5k.js} +1 -1
- package/dist/app/assets/{RouteError-3Mv8JQw7.js → RouteError-sgmtBdg8.js} +1 -1
- package/dist/app/assets/{WorkbookPage-DHYYpcYc.js → WorkbookPage-tnWmLcrW.js} +1 -1
- package/dist/app/assets/{core-DfcpQGVP.es-DQggNOdX.js → core-B3IQNPBD.es-foBNuT8L.js} +10 -10
- package/dist/app/assets/{index-D1pdwrUW.js → index-B5We8x8r.js} +1 -1
- package/dist/app/assets/{index-BUp81Qdm.js → index-KIvi9k3F.js} +1 -1
- package/dist/app/assets/index-PNYovl3E.js +452 -0
- package/dist/app/assets/{index.umd-CQH4LZU8.js → index.umd-BXcsl2XW.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/package_load_worker.mjs +1 -1
- package/dist/server.mjs +1556 -1018
- package/package.json +1 -1
- package/publisher.config.json +4 -0
- package/src/config.spec.ts +246 -0
- package/src/config.ts +121 -1
- package/src/constants.ts +84 -1
- package/src/controller/connection.controller.spec.ts +803 -0
- package/src/controller/connection.controller.ts +207 -20
- package/src/controller/model.controller.ts +16 -5
- package/src/controller/query.controller.ts +20 -7
- package/src/controller/watch-mode.controller.ts +11 -2
- package/src/errors.spec.ts +44 -0
- package/src/errors.ts +34 -0
- package/src/filter_deprecation.spec.ts +64 -0
- package/src/filter_deprecation.ts +42 -0
- package/src/heap_check.spec.ts +144 -0
- package/src/heap_check.ts +144 -0
- package/src/mcp/handler_utils.ts +14 -0
- package/src/mcp/tools/execute_query_tool.ts +44 -14
- package/src/oom_guards.integration.spec.ts +261 -0
- package/src/path_safety.ts +9 -3
- package/src/query_cap_metrics.spec.ts +89 -0
- package/src/query_cap_metrics.ts +115 -0
- package/src/query_concurrency.spec.ts +247 -0
- package/src/query_concurrency.ts +236 -0
- package/src/query_timeout.spec.ts +224 -0
- package/src/query_timeout.ts +178 -0
- package/src/server-old.ts +20 -0
- package/src/server.ts +57 -72
- package/src/service/connection.spec.ts +244 -0
- package/src/service/connection.ts +14 -4
- package/src/service/environment.ts +124 -4
- package/src/service/environment_admission.spec.ts +165 -1
- package/src/service/environment_store.spec.ts +103 -0
- package/src/service/environment_store.ts +74 -23
- package/src/service/filter_integration.spec.ts +69 -0
- package/src/service/model.spec.ts +193 -3
- package/src/service/model.ts +95 -14
- package/src/service/model_limits.spec.ts +181 -0
- package/src/service/model_limits.ts +110 -0
- package/src/service/package.spec.ts +2 -6
- package/src/service/package.ts +6 -1
- package/src/service/path_injection.spec.ts +39 -0
- package/src/stream_helpers.spec.ts +280 -0
- package/src/stream_helpers.ts +162 -0
- package/src/test_helpers/metrics_harness.ts +126 -0
- package/dist/app/assets/HomePage-DLRWTNoL.js +0 -1
- package/dist/app/assets/MainPage-DsVt5QGM.js +0 -2
- 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
|
-
|
|
821
|
-
|
|
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
|
-
|
|
935
|
-
|
|
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
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
1613
|
-
|
|
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 {
|
|
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", () => {
|