@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
@@ -1,5 +1,6 @@
1
1
  import type { GivenValue, LogMessage } from "@malloydata/malloy";
2
2
  import { MalloyError, Runtime } from "@malloydata/malloy";
3
+ import { metrics } from "@opentelemetry/api";
3
4
  import { Mutex } from "async-mutex";
4
5
  import crypto from "crypto";
5
6
  import * as fs from "fs";
@@ -69,6 +70,49 @@ type RetiredConnectionGeneration = {
69
70
 
70
71
  const RETIRED_CONNECTION_DRAIN_MS = 30_000;
71
72
 
73
+ /**
74
+ * Module-scoped admission-rejection counters. Lazy-initialized so
75
+ * the OTel JS `ProxyMeter` cannot strand them on a NoOp instrument
76
+ * created before the SDK MeterProvider was registered (a real risk
77
+ * in unit tests; see comment in `query_timeout.ts`). Environment
78
+ * name is attached as a label so dashboards can identify hot
79
+ * environments without grepping logs.
80
+ */
81
+ import { type Counter } from "@opentelemetry/api";
82
+ let queryAdmissionRejectionsCounter: Counter | null = null;
83
+ let packageAdmissionRejectionsCounter: Counter | null = null;
84
+ function getQueryAdmissionRejectionsCounter(): Counter {
85
+ if (queryAdmissionRejectionsCounter) return queryAdmissionRejectionsCounter;
86
+ queryAdmissionRejectionsCounter = metrics
87
+ .getMeter("publisher")
88
+ .createCounter("publisher_query_admission_rejections_total", {
89
+ description:
90
+ "Queries rejected with 503 because Environment.assertCanAdmitQuery() observed memory back-pressure",
91
+ });
92
+ return queryAdmissionRejectionsCounter;
93
+ }
94
+ function getPackageAdmissionRejectionsCounter(): Counter {
95
+ if (packageAdmissionRejectionsCounter) {
96
+ return packageAdmissionRejectionsCounter;
97
+ }
98
+ packageAdmissionRejectionsCounter = metrics
99
+ .getMeter("publisher")
100
+ .createCounter("publisher_package_admission_rejections_total", {
101
+ description:
102
+ "Package loads rejected with 503 because Environment.assertCanAdmitNewPackage() observed memory back-pressure",
103
+ });
104
+ return packageAdmissionRejectionsCounter;
105
+ }
106
+
107
+ /**
108
+ * Visible for tests; production code never calls this. Resets the
109
+ * lazy caches so a fresh MeterProvider can capture future writes.
110
+ */
111
+ export function resetAdmissionTelemetryForTesting(): void {
112
+ queryAdmissionRejectionsCounter = null;
113
+ packageAdmissionRejectionsCounter = null;
114
+ }
115
+
72
116
  export class Environment {
73
117
  private packages: Map<string, Package> = new Map();
74
118
  // Lock ordering: connectionMutex (environment) MUST be acquired before any
@@ -176,6 +220,7 @@ export class Environment {
176
220
  environmentPath: string,
177
221
  connections: ApiConnection[],
178
222
  ): Promise<Environment> {
223
+ assertSafeEnvironmentPath(environmentPath);
179
224
  if (!(await fs.promises.stat(environmentPath))?.isDirectory()) {
180
225
  throw new EnvironmentNotFoundError(
181
226
  `Environment path ${environmentPath} not found`,
@@ -191,7 +236,10 @@ export class Environment {
191
236
  logger.info(
192
237
  `Loaded ${malloyConfig.apiConnections.length} connections for environment ${environmentName}`,
193
238
  {
194
- apiConnections: malloyConfig.apiConnections,
239
+ connections: malloyConfig.apiConnections.map((c) => ({
240
+ name: c.name,
241
+ type: c.type,
242
+ })),
195
243
  },
196
244
  );
197
245
 
@@ -218,7 +266,7 @@ export class Environment {
218
266
  try {
219
267
  readme = (
220
268
  await fs.promises.readFile(
221
- path.join(this.environmentPath, README_NAME),
269
+ safeJoinUnderRoot(this.environmentPath, README_NAME),
222
270
  )
223
271
  ).toString();
224
272
  } catch {
@@ -579,8 +627,43 @@ export class Environment {
579
627
  ): void {
580
628
  if (allowAdmission) return;
581
629
  if (!this.memoryGovernor?.isBackpressured()) return;
630
+ // Increment *before* throwing so the metric ticks even on
631
+ // the not-uncommon "caught and swallowed" path. The label
632
+ // shape mirrors `assertCanAdmitQuery` so a dashboard panel
633
+ // can sum both rejection kinds by environment.
634
+ getPackageAdmissionRejectionsCounter().add(1, {
635
+ environment: this.environmentName,
636
+ reason,
637
+ });
582
638
  throw new ServiceUnavailableError(
583
- `Publisher is under memory pressure and cannot ${reason} (package "${packageName}", environment "${this.environmentName}"). Retry after the server's memory usage drops below the configured low-water mark.`,
639
+ `Publisher is under memory pressure and cannot ${reason} (package "${packageName}", environment "${this.environmentName}"). Retry after the server's memory usage drops below the low-water mark (PUBLISHER_MEMORY_LOW_WATER_FRACTION of PUBLISHER_MAX_MEMORY_BYTES), or raise PUBLISHER_MAX_MEMORY_BYTES if you have headroom.`,
640
+ );
641
+ }
642
+
643
+ /**
644
+ * Reject incoming queries with HTTP 503 when the memory governor
645
+ * has tripped its high-water mark. Used by every query controller
646
+ * (connection SQL, model query, notebook cell, MCP `execute_query`)
647
+ * to shed load before the query runs — complementing
648
+ * {@link assertCanAdmitNewPackage}, which only fires on cache-miss
649
+ * package loads and so leaves already-loaded packages fully
650
+ * queryable under pressure. With this in place, "back-pressured"
651
+ * means "no new work of any kind" until the governor's low-water
652
+ * mark is crossed.
653
+ *
654
+ * Cheap O(1) boolean read; no allocation when happy.
655
+ */
656
+ public assertCanAdmitQuery(): void {
657
+ if (!this.memoryGovernor?.isBackpressured()) return;
658
+ // Tick first so the counter reflects every rejection even
659
+ // when the controller's catch block swallows the error (e.g.
660
+ // an MCP tool surfaces it as a content payload rather than
661
+ // letting it bubble to the HTTP error mapper).
662
+ getQueryAdmissionRejectionsCounter().add(1, {
663
+ environment: this.environmentName,
664
+ });
665
+ throw new ServiceUnavailableError(
666
+ `Publisher is under memory pressure and cannot accept new queries (environment "${this.environmentName}"). Retry after the server's memory usage drops below the low-water mark (PUBLISHER_MEMORY_LOW_WATER_FRACTION of PUBLISHER_MAX_MEMORY_BYTES), or raise PUBLISHER_MAX_MEMORY_BYTES if you have headroom.`,
584
667
  );
585
668
  }
586
669
 
@@ -696,7 +779,6 @@ export class Environment {
696
779
  `Adding package ${packageName} to environment ${this.environmentName}`,
697
780
  {
698
781
  packagePath,
699
- malloyConfig: this.malloyConfig.malloyConfig,
700
782
  },
701
783
  );
702
784
 
@@ -762,6 +844,12 @@ export class Environment {
762
844
  const stagingPath = this.allocateStagingPath(packageName);
763
845
  await fs.promises.mkdir(path.dirname(stagingPath), { recursive: true });
764
846
 
847
+ logger.debug("install.phase1.download.started", {
848
+ environmentName: this.environmentName,
849
+ packageName,
850
+ stagingPath,
851
+ });
852
+ const downloadStartedAt = performance.now();
765
853
  try {
766
854
  await downloader(stagingPath);
767
855
  } catch (err) {
@@ -770,8 +858,17 @@ export class Environment {
770
858
  .catch(() => {});
771
859
  throw err;
772
860
  }
861
+ logger.debug("install.phase1.download.completed", {
862
+ environmentName: this.environmentName,
863
+ packageName,
864
+ durationMs: performance.now() - downloadStartedAt,
865
+ });
773
866
 
774
867
  return this.withPackageLock(packageName, async () => {
868
+ logger.debug("install.phase2.swap.started", {
869
+ environmentName: this.environmentName,
870
+ packageName,
871
+ });
775
872
  const canonicalPath = safeJoinUnderRoot(
776
873
  this.environmentPath,
777
874
  packageName,
@@ -790,6 +887,11 @@ export class Environment {
790
887
  recursive: true,
791
888
  });
792
889
  await fs.promises.rename(canonicalPath, retiredPath);
890
+ logger.debug("install.phase2.retired_old", {
891
+ environmentName: this.environmentName,
892
+ packageName,
893
+ retiredPath,
894
+ });
793
895
  }
794
896
 
795
897
  let newPackage: Package;
@@ -803,6 +905,11 @@ export class Environment {
803
905
  canonicalPath,
804
906
  () => this.malloyConfig.malloyConfig,
805
907
  );
908
+ logger.debug("install.phase2.committed", {
909
+ environmentName: this.environmentName,
910
+ packageName,
911
+ canonicalPath,
912
+ });
806
913
  } catch (err) {
807
914
  // Rollback: clobber whatever (partial) content sits at canonical
808
915
  // — Package.create's own failure-cleanup may have already rm'd
@@ -814,9 +921,11 @@ export class Environment {
814
921
  await fs.promises
815
922
  .rm(canonicalPath, { recursive: true, force: true })
816
923
  .catch(() => {});
924
+ let restored = false;
817
925
  if (retiredPath) {
818
926
  try {
819
927
  await fs.promises.rename(retiredPath, canonicalPath);
928
+ restored = true;
820
929
  } catch (restoreErr) {
821
930
  logger.error(
822
931
  "Failed to restore retired package after install rollback",
@@ -832,6 +941,12 @@ export class Environment {
832
941
  .rm(stagingPath, { recursive: true, force: true })
833
942
  .catch(() => {});
834
943
  this.deletePackageStatus(packageName);
944
+ logger.debug("install.phase2.rollback", {
945
+ environmentName: this.environmentName,
946
+ packageName,
947
+ restored,
948
+ errorName: err instanceof Error ? err.name : "Unknown",
949
+ });
835
950
  throw err;
836
951
  }
837
952
 
@@ -847,6 +962,11 @@ export class Environment {
847
962
  if (retiredPath) {
848
963
  const pathToClean = retiredPath;
849
964
  setImmediate(() => {
965
+ logger.debug("install.phase3.retired_cleanup", {
966
+ environmentName: this.environmentName,
967
+ packageName,
968
+ retiredPath: pathToClean,
969
+ });
850
970
  void fs.promises
851
971
  .rm(pathToClean, { recursive: true, force: true })
852
972
  .catch((err) => {
@@ -4,8 +4,12 @@ import * as os from "os";
4
4
  import * as path from "path";
5
5
 
6
6
  import { ServiceUnavailableError } from "../errors";
7
+ import {
8
+ startMetricsHarness,
9
+ type MetricsHarness,
10
+ } from "../test_helpers/metrics_harness";
7
11
  import { buildEnvironmentMalloyConfig } from "./connection";
8
- import { Environment } from "./environment";
12
+ import { Environment, resetAdmissionTelemetryForTesting } from "./environment";
9
13
  import type { PackageMemoryGovernor } from "./package_memory_governor";
10
14
 
11
15
  /**
@@ -178,3 +182,163 @@ describe("Environment admission gate (memory governor choke point)", () => {
178
182
  expect(caught).not.toBeInstanceOf(ServiceUnavailableError);
179
183
  });
180
184
  });
185
+
186
+ describe("Environment.assertCanAdmitQuery (query-path back-pressure)", () => {
187
+ let envDir: string;
188
+
189
+ beforeEach(() => {
190
+ envDir = fs.mkdtempSync(
191
+ path.join(os.tmpdir(), "publisher-env-query-admission-"),
192
+ );
193
+ });
194
+
195
+ afterEach(() => {
196
+ fs.rmSync(envDir, { recursive: true, force: true });
197
+ });
198
+
199
+ it("is a no-op when no governor is attached", () => {
200
+ const env = makeEnvironment(envDir);
201
+ // No governor — must not throw. Equivalent of an OSS / non-Docker
202
+ // deployment that never opted into PUBLISHER_MAX_MEMORY_BYTES.
203
+ expect(() => env.assertCanAdmitQuery()).not.toThrow();
204
+ });
205
+
206
+ it("is a no-op when the governor is happy (under high-water mark)", () => {
207
+ const env = makeEnvironment(envDir);
208
+ const governor = new StubGovernor();
209
+ env.setMemoryGovernor(governor as unknown as PackageMemoryGovernor);
210
+ governor.backpressured = false;
211
+
212
+ expect(() => env.assertCanAdmitQuery()).not.toThrow();
213
+ });
214
+
215
+ it("throws ServiceUnavailableError (→503) when back-pressured", () => {
216
+ const env = makeEnvironment(envDir);
217
+ const governor = new StubGovernor();
218
+ env.setMemoryGovernor(governor as unknown as PackageMemoryGovernor);
219
+ governor.backpressured = true;
220
+
221
+ expect(() => env.assertCanAdmitQuery()).toThrow(ServiceUnavailableError);
222
+ });
223
+
224
+ it("error message names the environment so operators can pinpoint the hot pod's load", () => {
225
+ const env = makeEnvironment(envDir);
226
+ const governor = new StubGovernor();
227
+ env.setMemoryGovernor(governor as unknown as PackageMemoryGovernor);
228
+ governor.backpressured = true;
229
+
230
+ let caught: unknown;
231
+ try {
232
+ env.assertCanAdmitQuery();
233
+ } catch (err) {
234
+ caught = err;
235
+ }
236
+ expect(caught).toBeInstanceOf(ServiceUnavailableError);
237
+ expect((caught as Error).message).toContain('environment "test-env"');
238
+ expect((caught as Error).message).toContain("memory pressure");
239
+ });
240
+
241
+ it("clearing back-pressure immediately re-admits queries (matches governor hysteresis)", () => {
242
+ const env = makeEnvironment(envDir);
243
+ const governor = new StubGovernor();
244
+ env.setMemoryGovernor(governor as unknown as PackageMemoryGovernor);
245
+
246
+ governor.backpressured = true;
247
+ expect(() => env.assertCanAdmitQuery()).toThrow(ServiceUnavailableError);
248
+
249
+ governor.backpressured = false;
250
+ expect(() => env.assertCanAdmitQuery()).not.toThrow();
251
+ });
252
+
253
+ it("detaching the governor reverts to legacy admit-everything", () => {
254
+ const env = makeEnvironment(envDir);
255
+ const governor = new StubGovernor();
256
+ env.setMemoryGovernor(governor as unknown as PackageMemoryGovernor);
257
+ governor.backpressured = true;
258
+
259
+ env.setMemoryGovernor(null);
260
+ // Even with the stub still claiming back-pressure, a detached
261
+ // governor leaves nothing to consult. Mirrors the package-admission
262
+ // detach behavior.
263
+ expect(() => env.assertCanAdmitQuery()).not.toThrow();
264
+ });
265
+ });
266
+
267
+ describe("Environment admission telemetry", () => {
268
+ let envDir: string;
269
+ let harness: MetricsHarness;
270
+
271
+ beforeEach(async () => {
272
+ envDir = fs.mkdtempSync(
273
+ path.join(os.tmpdir(), "publisher-env-admission-telemetry-"),
274
+ );
275
+ harness = await startMetricsHarness();
276
+ resetAdmissionTelemetryForTesting();
277
+ });
278
+
279
+ afterEach(async () => {
280
+ fs.rmSync(envDir, { recursive: true, force: true });
281
+ resetAdmissionTelemetryForTesting();
282
+ await harness.shutdown();
283
+ });
284
+
285
+ it("publisher_query_admission_rejections_total ticks per query rejection, labeled by environment", async () => {
286
+ const env = makeEnvironment(envDir);
287
+ const governor = new StubGovernor();
288
+ env.setMemoryGovernor(governor as unknown as PackageMemoryGovernor);
289
+ governor.backpressured = true;
290
+
291
+ // Drive three rejections so the counter is unambiguous (a
292
+ // single-tick assertion can pass against a leaked counter
293
+ // from a different test; three is harder to fake).
294
+ for (let i = 0; i < 3; i++) {
295
+ expect(() => env.assertCanAdmitQuery()).toThrow(
296
+ ServiceUnavailableError,
297
+ );
298
+ }
299
+ expect(
300
+ await harness.collectCounter(
301
+ "publisher_query_admission_rejections_total",
302
+ { environment: "test-env" },
303
+ ),
304
+ ).toBe(3);
305
+ });
306
+
307
+ it("counter stays at zero when the governor is happy (no spurious ticks)", async () => {
308
+ const env = makeEnvironment(envDir);
309
+ const governor = new StubGovernor();
310
+ env.setMemoryGovernor(governor as unknown as PackageMemoryGovernor);
311
+ // Not back-pressured.
312
+ env.assertCanAdmitQuery();
313
+ env.assertCanAdmitQuery();
314
+ // No rejection should have been recorded; verifies that the
315
+ // counter doesn't tick on the happy-path admission either.
316
+ expect(
317
+ await harness.collectCounter(
318
+ "publisher_query_admission_rejections_total",
319
+ ),
320
+ ).toBe(0);
321
+ });
322
+
323
+ it("publisher_package_admission_rejections_total ticks per package-load rejection", async () => {
324
+ // Ensure the package directory exists so the 404 doesn't
325
+ // short-circuit ahead of the back-pressure gate.
326
+ const pkgName = "real-pkg";
327
+ fs.mkdirSync(path.join(envDir, pkgName));
328
+
329
+ const env = makeEnvironment(envDir);
330
+ const governor = new StubGovernor();
331
+ env.setMemoryGovernor(governor as unknown as PackageMemoryGovernor);
332
+ governor.backpressured = true;
333
+
334
+ await expect(env.addPackage(pkgName)).rejects.toBeInstanceOf(
335
+ ServiceUnavailableError,
336
+ );
337
+ expect(
338
+ await harness.collectCounter(
339
+ "publisher_package_admission_rejections_total",
340
+ { environment: "test-env", reason: "add a new package" },
341
+ ),
342
+ ).toBe(1);
343
+ });
344
+ });
@@ -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
+ });