@malloy-publisher/server 0.0.198 → 0.0.200

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 (75) hide show
  1. package/build.ts +30 -1
  2. package/dist/app/api-doc.yaml +127 -111
  3. package/dist/app/assets/{EnvironmentPage-C7rtH4mC.js → EnvironmentPage-CgKNjySu.js} +1 -1
  4. package/dist/app/assets/HomePage-BPIpMBjW.js +1 -0
  5. package/dist/app/assets/{MainPage-D38LtZDV.js → MainPage-CAwb8U82.js} +2 -2
  6. package/dist/app/assets/{ModelPage-DOol8Mz7.js → ModelPage-C0Uevsw9.js} +1 -1
  7. package/dist/app/assets/{PackagePage-0tgzA_kO.js → PackagePage-Cu-u9k1g.js} +1 -1
  8. package/dist/app/assets/{RouteError-BaMsOSly.js → RouteError-DVwPh2Ql.js} +1 -1
  9. package/dist/app/assets/{WorkbookPage-Cx4SePkx.js → WorkbookPage-DW38R2Zv.js} +1 -1
  10. package/dist/app/assets/{core-CbsC6R_Y.es-Cwf6asf3.js → core-C0vCMRDQ.es-D_ytHhjS.js} +10 -10
  11. package/dist/app/assets/{index-DL6BZTuw.js → index-BGdcKsFF.js} +1 -1
  12. package/dist/app/assets/{index-DNofXMxi.js → index-CTx4v4_3.js} +1 -1
  13. package/dist/app/assets/index-DE6d5jEy.js +452 -0
  14. package/dist/app/assets/{index.umd-B68wGGkM.js → index.umd-C1Mi1uRm.js} +1 -1
  15. package/dist/app/index.html +1 -1
  16. package/dist/instrumentation.mjs +57 -36
  17. package/dist/package_load_worker.mjs +12213 -0
  18. package/dist/server.mjs +4198 -3648
  19. package/package.json +2 -3
  20. package/src/config.spec.ts +246 -0
  21. package/src/config.ts +121 -1
  22. package/src/constants.ts +84 -1
  23. package/src/controller/compile.controller.ts +3 -1
  24. package/src/controller/connection.controller.spec.ts +803 -0
  25. package/src/controller/connection.controller.ts +207 -20
  26. package/src/controller/model.controller.ts +19 -1
  27. package/src/controller/query.controller.ts +22 -6
  28. package/src/controller/watch-mode.controller.ts +11 -2
  29. package/src/errors.spec.ts +44 -0
  30. package/src/errors.ts +34 -0
  31. package/src/health.spec.ts +90 -0
  32. package/src/health.ts +88 -45
  33. package/src/heap_check.spec.ts +144 -0
  34. package/src/heap_check.ts +144 -0
  35. package/src/instrumentation.ts +50 -0
  36. package/src/mcp/handler_utils.ts +14 -0
  37. package/src/mcp/tools/execute_query_tool.ts +52 -10
  38. package/src/oom_guards.integration.spec.ts +261 -0
  39. package/src/package_load/package_load_pool.spec.ts +252 -0
  40. package/src/package_load/package_load_pool.ts +920 -0
  41. package/src/package_load/package_load_worker.ts +980 -0
  42. package/src/package_load/protocol.ts +336 -0
  43. package/src/path_safety.ts +9 -3
  44. package/src/query_cap_metrics.spec.ts +89 -0
  45. package/src/query_cap_metrics.ts +115 -0
  46. package/src/query_concurrency.spec.ts +247 -0
  47. package/src/query_concurrency.ts +236 -0
  48. package/src/query_param_utils.ts +18 -0
  49. package/src/query_timeout.spec.ts +224 -0
  50. package/src/query_timeout.ts +178 -0
  51. package/src/server-old.ts +21 -1
  52. package/src/server.ts +61 -57
  53. package/src/service/connection.ts +8 -2
  54. package/src/service/db_utils.spec.ts +1 -1
  55. package/src/service/environment.ts +85 -4
  56. package/src/service/environment_admission.spec.ts +165 -1
  57. package/src/service/environment_store.spec.ts +103 -0
  58. package/src/service/environment_store.ts +98 -26
  59. package/src/service/filter_integration.spec.ts +110 -0
  60. package/src/service/given.ts +80 -0
  61. package/src/service/givens_integration.spec.ts +192 -0
  62. package/src/service/model.spec.ts +298 -3
  63. package/src/service/model.ts +362 -23
  64. package/src/service/model_limits.spec.ts +181 -0
  65. package/src/service/model_limits.ts +110 -0
  66. package/src/service/package.spec.ts +12 -6
  67. package/src/service/package.ts +263 -146
  68. package/src/service/package_worker_path.spec.ts +196 -0
  69. package/src/service/path_injection.spec.ts +39 -0
  70. package/src/stream_helpers.spec.ts +280 -0
  71. package/src/stream_helpers.ts +162 -0
  72. package/src/test_helpers/metrics_harness.ts +126 -0
  73. package/tests/integration/concurrent_package/concurrent_package.integration.spec.ts +280 -0
  74. package/dist/app/assets/HomePage-DwkH7OrS.js +0 -1
  75. package/dist/app/assets/index-U38AyjJL.js +0 -451
@@ -5,6 +5,7 @@ import * as sinon from "sinon";
5
5
  import { components } from "../api";
6
6
  import { isPublisherConfigFrozen } from "../config";
7
7
  import { TEMP_DIR_PATH } from "../constants";
8
+ import { BadRequestError } from "../errors";
8
9
  import { Environment } from "./environment";
9
10
  import { EnvironmentStore } from "./environment_store";
10
11
 
@@ -1020,3 +1021,105 @@ describe("Project Service Error Recovery", () => {
1020
1021
  );
1021
1022
  });
1022
1023
  });
1024
+
1025
+ const TRAVERSAL_NAMES: ReadonlyArray<readonly [string, string]> = [
1026
+ ["leading traversal", "../etc"],
1027
+ ["embedded traversal", "foo/../../bar"],
1028
+ ["slash in name", "foo/bar"],
1029
+ ["backslash in name", "foo\\bar"],
1030
+ ["leading dot", ".staging"],
1031
+ ["bare dot-dot", ".."],
1032
+ ["bare dot", "."],
1033
+ ["empty", ""],
1034
+ ["NUL byte", "foo\0bar"],
1035
+ ["oversized", "a".repeat(256)],
1036
+ ["absolute", "/etc/passwd"],
1037
+ ] as const;
1038
+
1039
+ describe("EnvironmentStore path-injection guards", () => {
1040
+ let environmentStore: EnvironmentStore;
1041
+
1042
+ beforeEach(async () => {
1043
+ if (existsSync(serverRootPath)) {
1044
+ rmSync(serverRootPath, { recursive: true, force: true });
1045
+ }
1046
+ mkdirSync(serverRootPath);
1047
+ mock(isPublisherConfigFrozen).mockReturnValue(false);
1048
+ mock.module("../config", () => ({
1049
+ isPublisherConfigFrozen: () => false,
1050
+ }));
1051
+ environmentStore = new EnvironmentStore(serverRootPath);
1052
+ await environmentStore.finishedInitialization;
1053
+ });
1054
+
1055
+ afterEach(() => {
1056
+ if (existsSync(serverRootPath)) {
1057
+ rmSync(serverRootPath, { recursive: true, force: true });
1058
+ }
1059
+ mkdirSync(serverRootPath);
1060
+ });
1061
+
1062
+ describe("addEnvironment", () => {
1063
+ it.each(TRAVERSAL_NAMES)(
1064
+ "rejects %s as environment.name (%p)",
1065
+ async (_label, name) => {
1066
+ await expect(
1067
+ environmentStore.addEnvironment({ name } as never, true),
1068
+ ).rejects.toBeInstanceOf(BadRequestError);
1069
+ },
1070
+ );
1071
+
1072
+ it.each(TRAVERSAL_NAMES)(
1073
+ "rejects %s as packages[].name (%p)",
1074
+ async (_label, packageName) => {
1075
+ await expect(
1076
+ environmentStore.addEnvironment(
1077
+ {
1078
+ name: "ok-env",
1079
+ packages: [
1080
+ {
1081
+ name: packageName,
1082
+ location: "https://github.com/example/repo",
1083
+ },
1084
+ ],
1085
+ } as never,
1086
+ true,
1087
+ ),
1088
+ ).rejects.toBeInstanceOf(BadRequestError);
1089
+ },
1090
+ );
1091
+ });
1092
+
1093
+ describe("updateEnvironment", () => {
1094
+ it.each(TRAVERSAL_NAMES)(
1095
+ "rejects %s as environment.name (%p)",
1096
+ async (_label, name) => {
1097
+ await expect(
1098
+ environmentStore.updateEnvironment({ name } as never),
1099
+ ).rejects.toBeInstanceOf(BadRequestError);
1100
+ },
1101
+ );
1102
+ });
1103
+
1104
+ describe("deleteEnvironment", () => {
1105
+ it.each(TRAVERSAL_NAMES)(
1106
+ "rejects %s as environmentName (%p)",
1107
+ async (_label, name) => {
1108
+ await expect(
1109
+ environmentStore.deleteEnvironment(name),
1110
+ ).rejects.toBeInstanceOf(BadRequestError);
1111
+ },
1112
+ );
1113
+ });
1114
+
1115
+ describe("getEnvironment", () => {
1116
+ it.each(TRAVERSAL_NAMES)(
1117
+ "rejects %s as environmentName (%p)",
1118
+ async (_label, name) => {
1119
+ await expect(
1120
+ environmentStore.getEnvironment(name),
1121
+ ).rejects.toBeInstanceOf(BadRequestError);
1122
+ },
1123
+ );
1124
+ });
1125
+ });
@@ -1,8 +1,8 @@
1
1
  import { GetObjectCommand, S3 } from "@aws-sdk/client-s3";
2
2
  import { Storage } from "@google-cloud/storage";
3
- import AdmZip from "adm-zip";
4
3
  import { Mutex } from "async-mutex";
5
4
  import crypto from "crypto";
5
+ import extract from "extract-zip";
6
6
  import * as fs from "fs";
7
7
  import * as path from "path";
8
8
  import simpleGit from "simple-git";
@@ -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,8 @@ export class EnvironmentStore {
884
891
  }
885
892
 
886
893
  public async unzipEnvironment(absoluteEnvironmentPath: string) {
894
+ assertSafeEnvironmentPath(absoluteEnvironmentPath);
895
+ const startedAt = Date.now();
887
896
  logger.info(
888
897
  `Detected zip file at "${absoluteEnvironmentPath}". Unzipping...`,
889
898
  );
@@ -897,8 +906,28 @@ export class EnvironmentStore {
897
906
  });
898
907
  await fs.promises.mkdir(unzippedEnvironmentPath, { recursive: true });
899
908
 
900
- const zip = new AdmZip(absoluteEnvironmentPath);
901
- zip.extractAllTo(unzippedEnvironmentPath, true);
909
+ // Stream-extract via yauzl (wrapped by extract-zip). Each entry's
910
+ // inflate and write are dispatched to the libuv thread pool, so the
911
+ // main event loop stays responsive even for very large archives.
912
+ // The previous adm-zip path used fs.readFileSync + zlib.inflateRawSync
913
+ // on the main thread, which parked the loop long enough on multi-
914
+ // hundred-MB packages to fail Kubernetes liveness probes mid-extract.
915
+ let entryCount = 0;
916
+ let totalUncompressedBytes = 0;
917
+ await extract(absoluteEnvironmentPath, {
918
+ dir: path.resolve(unzippedEnvironmentPath),
919
+ onEntry: (entry) => {
920
+ entryCount += 1;
921
+ totalUncompressedBytes += entry.uncompressedSize ?? 0;
922
+ },
923
+ });
924
+
925
+ const mib = (totalUncompressedBytes / (1024 * 1024)).toFixed(1);
926
+ logger.info(
927
+ `Unzipped "${absoluteEnvironmentPath}" -> "${unzippedEnvironmentPath}" ` +
928
+ `(${entryCount} entries, ${mib} MiB uncompressed) in ` +
929
+ `${formatDuration(Date.now() - startedAt)}`,
930
+ );
902
931
 
903
932
  return unzippedEnvironmentPath;
904
933
  }
@@ -909,9 +938,10 @@ export class EnvironmentStore {
909
938
  throw new FrozenConfigError();
910
939
  }
911
940
  validateEnvironmentAzureUrls(environment);
941
+ assertSafePackageName(environment.name);
912
942
  const environmentName = environment.name;
913
- if (!environmentName) {
914
- throw new Error("Environment name is required");
943
+ for (const _package of environment.packages || []) {
944
+ assertSafePackageName(_package.name);
915
945
  }
916
946
  const existingEnvironment = this.environments.get(environmentName);
917
947
  if (!existingEnvironment) {
@@ -932,6 +962,7 @@ export class EnvironmentStore {
932
962
  if (this.publisherConfigIsFrozen) {
933
963
  throw new FrozenConfigError();
934
964
  }
965
+ assertSafePackageName(environmentName);
935
966
  const environment = this.environments.get(environmentName);
936
967
  if (!environment) {
937
968
  return;
@@ -1006,15 +1037,17 @@ export class EnvironmentStore {
1006
1037
  }
1007
1038
 
1008
1039
  private async scaffoldEnvironment(environment: ApiEnvironment) {
1040
+ assertSafePackageName(environment.name);
1009
1041
  const environmentName = environment.name;
1010
- if (!environmentName) {
1011
- throw new Error("Environment name is required");
1012
- }
1013
- const absoluteEnvironmentPath = `${this.serverRootPath}/${PUBLISHER_DATA_DIR}/${environmentName}`;
1042
+ const absoluteEnvironmentPath = safeJoinUnderRoot(
1043
+ this.serverRootPath,
1044
+ PUBLISHER_DATA_DIR,
1045
+ environmentName,
1046
+ );
1014
1047
  await fs.promises.mkdir(absoluteEnvironmentPath, { recursive: true });
1015
1048
  if (environment.readme) {
1016
1049
  await fs.promises.writeFile(
1017
- path.join(absoluteEnvironmentPath, "README.md"),
1050
+ safeJoinUnderRoot(absoluteEnvironmentPath, "README.md"),
1018
1051
  environment.readme,
1019
1052
  );
1020
1053
  }
@@ -1050,7 +1083,12 @@ export class EnvironmentStore {
1050
1083
  environmentName: string,
1051
1084
  packages: ApiEnvironment["packages"],
1052
1085
  ) {
1053
- 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
+ );
1054
1092
 
1055
1093
  await fs.promises.mkdir(absoluteTargetPath, { recursive: true });
1056
1094
 
@@ -1105,7 +1143,10 @@ export class EnvironmentStore {
1105
1143
  .update(groupedLocation)
1106
1144
  .digest("hex")
1107
1145
  .substring(0, 16); // Use first 16 chars for shorter paths
1108
- const tempDownloadPath = `${absoluteTargetPath}/.temp_${locationHash}`;
1146
+ const tempDownloadPath = safeJoinUnderRoot(
1147
+ absoluteTargetPath,
1148
+ `.temp_${locationHash}`,
1149
+ );
1109
1150
  await fs.promises.mkdir(tempDownloadPath, { recursive: true });
1110
1151
  logger.info(`Created temporary directory: ${tempDownloadPath}`);
1111
1152
  try {
@@ -1119,7 +1160,11 @@ export class EnvironmentStore {
1119
1160
  // Extract each package from the downloaded content
1120
1161
  for (const _package of packagesForLocation) {
1121
1162
  const packageDir = _package.name;
1122
- const absolutePackagePath = `${absoluteTargetPath}/${packageDir}`;
1163
+ assertSafePackageName(packageDir);
1164
+ const absolutePackagePath = safeJoinUnderRoot(
1165
+ absoluteTargetPath,
1166
+ packageDir,
1167
+ );
1123
1168
  // For GitHub URLs, extract the subdirectory path from the original location
1124
1169
  let sourcePath: string;
1125
1170
  if (this.isGitHubURL(_package.location)) {
@@ -1130,7 +1175,7 @@ export class EnvironmentStore {
1130
1175
  const subPathMatch =
1131
1176
  _package.location.match(/\/tree\/[^/]+\/(.+)$/);
1132
1177
  if (subPathMatch) {
1133
- sourcePath = path.join(
1178
+ sourcePath = safeJoinUnderRoot(
1134
1179
  tempDownloadPath,
1135
1180
  subPathMatch[1],
1136
1181
  );
@@ -1147,7 +1192,10 @@ export class EnvironmentStore {
1147
1192
  if (this.isLocalPath(_package.location)) {
1148
1193
  sourcePath = _package.location;
1149
1194
  } else {
1150
- sourcePath = path.join(tempDownloadPath, groupedLocation);
1195
+ sourcePath = safeJoinUnderRoot(
1196
+ tempDownloadPath,
1197
+ groupedLocation,
1198
+ );
1151
1199
  }
1152
1200
  }
1153
1201
 
@@ -1326,6 +1374,10 @@ export class EnvironmentStore {
1326
1374
  environmentName: string,
1327
1375
  packageName: string,
1328
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);
1329
1381
  if (environmentPath.endsWith(".zip")) {
1330
1382
  environmentPath = await this.unzipEnvironment(environmentPath);
1331
1383
  }
@@ -1353,6 +1405,7 @@ export class EnvironmentStore {
1353
1405
  absoluteDirPath: string,
1354
1406
  isCompressedFile: boolean,
1355
1407
  ) {
1408
+ assertSafeEnvironmentPath(absoluteDirPath);
1356
1409
  const trimmedPath = gcsPath.slice(5);
1357
1410
  const [bucketName, ...prefixParts] = trimmedPath.split("/");
1358
1411
  const prefix = prefixParts.join("/");
@@ -1375,10 +1428,15 @@ export class EnvironmentStore {
1375
1428
  }
1376
1429
  await Promise.all(
1377
1430
  files.map(async (file) => {
1378
- 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(/^\/+/, "");
1379
1437
  const absoluteFilePath = isCompressedFile
1380
1438
  ? absoluteDirPath
1381
- : path.join(absoluteDirPath, relativeFilePath);
1439
+ : safeJoinUnderRoot(absoluteDirPath, relativeFilePath);
1382
1440
  if (file.name.endsWith("/")) {
1383
1441
  return;
1384
1442
  }
@@ -1403,6 +1461,7 @@ export class EnvironmentStore {
1403
1461
  absoluteDirPath: string,
1404
1462
  isCompressedFile: boolean = false,
1405
1463
  ) {
1464
+ assertSafeEnvironmentPath(absoluteDirPath);
1406
1465
  const trimmedPath = s3Path.slice(5);
1407
1466
  const [bucketName, ...prefixParts] = trimmedPath.split("/");
1408
1467
  const prefix = prefixParts.join("/");
@@ -1456,11 +1515,16 @@ export class EnvironmentStore {
1456
1515
  if (!key) {
1457
1516
  return;
1458
1517
  }
1459
- 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(/^\/+/, "");
1460
1524
  if (!relativeFilePath || relativeFilePath.endsWith("/")) {
1461
1525
  return;
1462
1526
  }
1463
- const absoluteFilePath = path.join(
1527
+ const absoluteFilePath = safeJoinUnderRoot(
1464
1528
  absoluteDirPath,
1465
1529
  relativeFilePath,
1466
1530
  );
@@ -1511,6 +1575,7 @@ export class EnvironmentStore {
1511
1575
  }
1512
1576
 
1513
1577
  async downloadGitHubDirectory(githubUrl: string, absoluteDirPath: string) {
1578
+ assertSafeEnvironmentPath(absoluteDirPath);
1514
1579
  // First we'll clone the repo without the additional path
1515
1580
  // E.g. we're removing `/tree/main/imdb` from https://github.com/credibledata/malloy-samples/tree/main/imdb
1516
1581
  const githubInfo = this.parseGitHubUrl(githubUrl);
@@ -1518,7 +1583,11 @@ export class EnvironmentStore {
1518
1583
  throw new Error(`Invalid GitHub URL: ${githubUrl}`);
1519
1584
  }
1520
1585
  const { owner, repoName, packagePath } = githubInfo;
1521
- 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(/^\/+/, "");
1522
1591
 
1523
1592
  // We'll make sure whatever was in absoluteDirPath is removed,
1524
1593
  // so we have a nice a clean directory where we can clone the repo
@@ -1558,7 +1627,10 @@ export class EnvironmentStore {
1558
1627
 
1559
1628
  // Remove all contents of absoluteDirPath (/var/publisher/asd123)
1560
1629
  // except for the cleanPackagePath directory (/var/publisher/asd123/imdb)
1561
- const packageFullPath = path.join(absoluteDirPath, cleanPackagePath);
1630
+ const packageFullPath = safeJoinUnderRoot(
1631
+ absoluteDirPath,
1632
+ cleanPackagePath,
1633
+ );
1562
1634
 
1563
1635
  // Check if the cleanPackagePath (/var/publisher/asd123/imdb) exists
1564
1636
  const packageExists = await fs.promises
@@ -1577,7 +1649,7 @@ export class EnvironmentStore {
1577
1649
  for (const entry of dirContents) {
1578
1650
  // Don't remove the cleanPackagePath directory itself (/var/publisher/asd123/imdb)
1579
1651
  if (entry !== cleanPackagePath.replace(/^\/+/, "").split("/")[0]) {
1580
- await fs.promises.rm(path.join(absoluteDirPath, entry), {
1652
+ await fs.promises.rm(safeJoinUnderRoot(absoluteDirPath, entry), {
1581
1653
  recursive: true,
1582
1654
  force: true,
1583
1655
  });
@@ -1588,8 +1660,8 @@ export class EnvironmentStore {
1588
1660
  const packageContents = await fs.promises.readdir(packageFullPath);
1589
1661
  for (const entry of packageContents) {
1590
1662
  await fs.promises.rename(
1591
- path.join(packageFullPath, entry),
1592
- path.join(absoluteDirPath, entry),
1663
+ safeJoinUnderRoot(packageFullPath, entry),
1664
+ safeJoinUnderRoot(absoluteDirPath, entry),
1593
1665
  );
1594
1666
  }
1595
1667
 
@@ -133,6 +133,55 @@ import "child_orders.malloy"
133
133
  run: child_orders -> summary
134
134
  `;
135
135
 
136
+ // Model with a given: declaration — view filters rows by the given value
137
+ const MODEL_WITH_GIVENS = `##! experimental.givens
138
+
139
+ given: target_region :: string is 'US'
140
+
141
+ source: orders is duckdb.table('orders') extend {
142
+ primary_key: order_id
143
+
144
+ measure:
145
+ order_count is count()
146
+ total_amount is sum(amount)
147
+
148
+ view: by_given_region is {
149
+ where: region = $target_region
150
+ aggregate: order_count, total_amount
151
+ }
152
+ }
153
+ `;
154
+
155
+ // Model with both a #(filter) annotation and a given: declaration to verify composition
156
+ const MODEL_WITH_GIVENS_AND_FILTER = `##! experimental.givens
157
+
158
+ given: target_region :: string is 'US'
159
+
160
+ #(filter) dimension=status type=equal
161
+ source: orders is duckdb.table('orders') extend {
162
+ primary_key: order_id
163
+
164
+ measure:
165
+ order_count is count()
166
+ total_amount is sum(amount)
167
+
168
+ view: by_given_region is {
169
+ where: region = $target_region
170
+ aggregate: order_count, total_amount
171
+ }
172
+ }
173
+ `;
174
+
175
+ const NOTEBOOK_GIVENS = `>>>markdown
176
+ # Givens Test
177
+
178
+ >>>malloy
179
+ import "orders_givens.malloy"
180
+
181
+ >>>malloy
182
+ run: orders -> by_given_region
183
+ `;
184
+
136
185
  beforeAll(async () => {
137
186
  await fs.mkdir(TEST_DB_DIR, { recursive: true });
138
187
  await fs.mkdir(TEST_PKG_DIR, { recursive: true });
@@ -657,6 +706,67 @@ describe("filter integration", () => {
657
706
  expect(markdownCell.type).toBe("markdown");
658
707
  expect(markdownCell.text).toContain("Test Notebook");
659
708
  });
709
+
710
+ it("applies givens to notebook cell execution", async () => {
711
+ await writeFile("orders_givens.malloy", MODEL_WITH_GIVENS);
712
+ await writeFile("givens_notebook.malloynb", NOTEBOOK_GIVENS);
713
+ const model = await Model.create(
714
+ "test-pkg",
715
+ TEST_PKG_DIR,
716
+ "givens_notebook.malloynb",
717
+ getConnections(),
718
+ );
719
+
720
+ // Cell 2: run: orders -> by_given_region with target_region overridden to 'EU'
721
+ // EU rows: (3,'EU','active',150) and (4,'EU','cancelled',75) → order_count=2, total_amount=225
722
+ const codeCell = await model.executeNotebookCell(
723
+ 2,
724
+ undefined,
725
+ undefined,
726
+ { target_region: "EU" },
727
+ );
728
+ expect(codeCell.result).toBeDefined();
729
+
730
+ const notebookRows = parseNotebookResult(codeCell.result!);
731
+ expect(notebookRows.length).toBe(1);
732
+ expect(Number(notebookRows[0].order_count)).toBe(2);
733
+ expect(Number(notebookRows[0].total_amount)).toBe(225);
734
+ });
735
+
736
+ it("composes givens and filterParams in notebook cell execution", async () => {
737
+ await writeFile(
738
+ "orders_givens_filter.malloy",
739
+ MODEL_WITH_GIVENS_AND_FILTER,
740
+ );
741
+ await writeFile(
742
+ "givens_filter_notebook.malloynb",
743
+ NOTEBOOK_GIVENS.replace(
744
+ "orders_givens.malloy",
745
+ "orders_givens_filter.malloy",
746
+ ),
747
+ );
748
+ const model = await Model.create(
749
+ "test-pkg",
750
+ TEST_PKG_DIR,
751
+ "givens_filter_notebook.malloynb",
752
+ getConnections(),
753
+ );
754
+
755
+ // given restricts to APAC; filterParam restricts to active
756
+ // APAC + active: only (5,'APAC','active',300) → order_count=1, total_amount=300
757
+ const codeCell = await model.executeNotebookCell(
758
+ 2,
759
+ { status: "active" },
760
+ undefined,
761
+ { target_region: "APAC" },
762
+ );
763
+ expect(codeCell.result).toBeDefined();
764
+
765
+ const notebookRows = parseNotebookResult(codeCell.result!);
766
+ expect(notebookRows.length).toBe(1);
767
+ expect(Number(notebookRows[0].order_count)).toBe(1);
768
+ expect(Number(notebookRows[0].total_amount)).toBe(300);
769
+ });
660
770
  });
661
771
 
662
772
  // -----------------------------------------------------------------------
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Shared utilities for surfacing Malloy `Given` declarations on
3
+ * compiled models.
4
+ *
5
+ * The Malloy SDK's `Given` class is declared in
6
+ * `@malloydata/malloy/dist/api/foundation/core.d.ts` but is not
7
+ * re-exported from the package root, so we duck-type against the
8
+ * surface we actually use and don't pull in the private type.
9
+ *
10
+ * Lives here so both the main-thread `Model` constructor and the
11
+ * package-load worker can use the same conversion. The worker
12
+ * imports this file directly (it's pure TypeScript with no native
13
+ * deps, so it's safe to bundle into the worker entry).
14
+ */
15
+
16
+ /**
17
+ * Duck-typed shape of a Malloy SDK `Given` instance (the value type
18
+ * of `Model.givens`).
19
+ */
20
+ export interface MalloyGiven {
21
+ readonly name: string;
22
+ readonly type: { type: string; filterType?: string };
23
+ getTaglines(prefix?: RegExp): string[];
24
+ }
25
+
26
+ /**
27
+ * Wire/API shape of a given. Structurally identical to the
28
+ * `components["schemas"]["Given"]` shape from the OpenAPI spec —
29
+ * callers can cast freely.
30
+ */
31
+ export interface MalloyGivenApi {
32
+ name: string;
33
+ type: string;
34
+ annotations?: string[];
35
+ }
36
+
37
+ /**
38
+ * Convert a Malloy SDK `Given` to the wire/API shape.
39
+ *
40
+ * Two fields are deliberately not surfaced:
41
+ *
42
+ * - `location` — Malloy's `DocumentLocation.url` is an absolute
43
+ * `file://` path on the publisher's filesystem. Surfacing it
44
+ * would leak the OS user, install directory, and internal
45
+ * layout. Existing `Filter` introspection does not expose
46
+ * location either; matching that floor. A future PR can add a
47
+ * sanitised package-relative path if a client needs it.
48
+ *
49
+ * - `default` / `defaultText` — Malloy's API only exposes the
50
+ * parsed `ConstantExpr` AST, not a rendered source string.
51
+ * Rendering it here would duplicate the Malloy printer. Add
52
+ * when Malloy surfaces a stringified accessor.
53
+ *
54
+ * `annotations` is restricted to `#(...)` declaration annotations
55
+ * (the caller-facing kind, e.g. `#(doc)`). `getTaglines()` with no
56
+ * prefix would also return `##` doc-comment lines and the
57
+ * model-level `##!` pragma, which aren't part of the given's
58
+ * surface contract.
59
+ *
60
+ * Type rendering: `GivenTypeDef` is typed as `AtomicTypeDef |
61
+ * FilterExpressionParamTypeDef`, but Malloy's grammar only emits
62
+ * the scalar parameter types (`string` | `number` | `boolean` |
63
+ * `date` | `timestamp` | `timestamptz` | `filter expression` |
64
+ * `error`) for given declarations today. If the grammar expands
65
+ * to allow array or record givens, the bare `type.type`
66
+ * discriminator (`'array'`, `'record'`) will land in the wire
67
+ * response with no element info — revisit when that happens.
68
+ */
69
+ export function malloyGivenToApi(given: MalloyGiven): MalloyGivenApi {
70
+ const type = given.type;
71
+ const renderedType =
72
+ type.type === "filter expression"
73
+ ? `filter<${type.filterType}>`
74
+ : type.type;
75
+ return {
76
+ name: given.name,
77
+ type: renderedType,
78
+ annotations: given.getTaglines(/^#\(/),
79
+ };
80
+ }