@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.
- package/build.ts +30 -1
- package/dist/app/api-doc.yaml +127 -111
- package/dist/app/assets/{EnvironmentPage-C7rtH4mC.js → EnvironmentPage-CgKNjySu.js} +1 -1
- package/dist/app/assets/HomePage-BPIpMBjW.js +1 -0
- package/dist/app/assets/{MainPage-D38LtZDV.js → MainPage-CAwb8U82.js} +2 -2
- package/dist/app/assets/{ModelPage-DOol8Mz7.js → ModelPage-C0Uevsw9.js} +1 -1
- package/dist/app/assets/{PackagePage-0tgzA_kO.js → PackagePage-Cu-u9k1g.js} +1 -1
- package/dist/app/assets/{RouteError-BaMsOSly.js → RouteError-DVwPh2Ql.js} +1 -1
- package/dist/app/assets/{WorkbookPage-Cx4SePkx.js → WorkbookPage-DW38R2Zv.js} +1 -1
- package/dist/app/assets/{core-CbsC6R_Y.es-Cwf6asf3.js → core-C0vCMRDQ.es-D_ytHhjS.js} +10 -10
- package/dist/app/assets/{index-DL6BZTuw.js → index-BGdcKsFF.js} +1 -1
- package/dist/app/assets/{index-DNofXMxi.js → index-CTx4v4_3.js} +1 -1
- package/dist/app/assets/index-DE6d5jEy.js +452 -0
- package/dist/app/assets/{index.umd-B68wGGkM.js → index.umd-C1Mi1uRm.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/instrumentation.mjs +57 -36
- package/dist/package_load_worker.mjs +12213 -0
- package/dist/server.mjs +4198 -3648
- package/package.json +2 -3
- package/src/config.spec.ts +246 -0
- package/src/config.ts +121 -1
- package/src/constants.ts +84 -1
- package/src/controller/compile.controller.ts +3 -1
- package/src/controller/connection.controller.spec.ts +803 -0
- package/src/controller/connection.controller.ts +207 -20
- package/src/controller/model.controller.ts +19 -1
- package/src/controller/query.controller.ts +22 -6
- package/src/controller/watch-mode.controller.ts +11 -2
- package/src/errors.spec.ts +44 -0
- package/src/errors.ts +34 -0
- package/src/health.spec.ts +90 -0
- package/src/health.ts +88 -45
- package/src/heap_check.spec.ts +144 -0
- package/src/heap_check.ts +144 -0
- package/src/instrumentation.ts +50 -0
- package/src/mcp/handler_utils.ts +14 -0
- package/src/mcp/tools/execute_query_tool.ts +52 -10
- package/src/oom_guards.integration.spec.ts +261 -0
- package/src/package_load/package_load_pool.spec.ts +252 -0
- package/src/package_load/package_load_pool.ts +920 -0
- package/src/package_load/package_load_worker.ts +980 -0
- package/src/package_load/protocol.ts +336 -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_param_utils.ts +18 -0
- package/src/query_timeout.spec.ts +224 -0
- package/src/query_timeout.ts +178 -0
- package/src/server-old.ts +21 -1
- package/src/server.ts +61 -57
- package/src/service/connection.ts +8 -2
- package/src/service/db_utils.spec.ts +1 -1
- package/src/service/environment.ts +85 -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 +98 -26
- package/src/service/filter_integration.spec.ts +110 -0
- package/src/service/given.ts +80 -0
- package/src/service/givens_integration.spec.ts +192 -0
- package/src/service/model.spec.ts +298 -3
- package/src/service/model.ts +362 -23
- package/src/service/model_limits.spec.ts +181 -0
- package/src/service/model_limits.ts +110 -0
- package/src/service/package.spec.ts +12 -6
- package/src/service/package.ts +263 -146
- package/src/service/package_worker_path.spec.ts +196 -0
- 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/tests/integration/concurrent_package/concurrent_package.integration.spec.ts +280 -0
- package/dist/app/assets/HomePage-DwkH7OrS.js +0 -1
- 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
|
-
|
|
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,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
|
-
|
|
901
|
-
|
|
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
|
-
|
|
914
|
-
|
|
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
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
:
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
1592
|
-
|
|
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
|
+
}
|