@malloy-publisher/server 0.0.209 → 0.0.211

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 (34) hide show
  1. package/dist/app/api-doc.yaml +20 -3
  2. package/dist/app/assets/{EnvironmentPage-BRMCY9d8.js → EnvironmentPage-CuO6sty5.js} +1 -1
  3. package/dist/app/assets/HomePage-u3vxROIF.js +1 -0
  4. package/dist/app/assets/{MainPage-sZdUjUcu.js → MainPage-Bt2sYLbr.js} +2 -2
  5. package/dist/app/assets/{MaterializationsPage-C_jQQUCJ.js → MaterializationsPage-B1rj61zs.js} +1 -1
  6. package/dist/app/assets/{ModelPage-Bh62OIEq.js → ModelPage-BpvONvSR.js} +1 -1
  7. package/dist/app/assets/{PackagePage-DJW4xjLp.js → PackagePage-DHNcwboW.js} +1 -1
  8. package/dist/app/assets/{RouteError-Vi5yGs_F.js → RouteError-D1vhLJvr.js} +1 -1
  9. package/dist/app/assets/{WorkbookPage-BvJMi21d.js → WorkbookPage-CZmud-yI.js} +1 -1
  10. package/dist/app/assets/{core-BbW0t3RQ.es-L-mZcOk3.js → core-XtBSnW2U.es-DE88sVsZ.js} +1 -1
  11. package/dist/app/assets/{index-CCcHdeew.js → index-BQdOB6m9.js} +1 -1
  12. package/dist/app/assets/{index-DuqTjxM_.js → index-BlnQtDZj.js} +137 -137
  13. package/dist/app/assets/{index-CNFX-CGL.js → index-CYf2akGH.js} +1 -1
  14. package/dist/app/assets/{index.umd-GgEb4WfT.js → index.umd-BdQ0R4hx.js} +1 -1
  15. package/dist/app/index.html +1 -1
  16. package/dist/package_load_worker.mjs +3 -0
  17. package/dist/server.mjs +314 -306
  18. package/package.json +1 -1
  19. package/src/materialization_metrics.spec.ts +84 -0
  20. package/src/materialization_metrics.ts +78 -119
  21. package/src/service/build_plan.spec.ts +84 -0
  22. package/src/service/build_plan.ts +53 -2
  23. package/src/service/materialization_service.spec.ts +126 -8
  24. package/src/service/materialization_service.ts +96 -83
  25. package/src/service/materialization_test_fixtures.ts +2 -0
  26. package/src/service/model.ts +25 -25
  27. package/src/service/package.ts +19 -7
  28. package/src/service/package_worker_path.spec.ts +65 -55
  29. package/src/service/persist_annotation_validation.spec.ts +77 -0
  30. package/src/service/persist_annotation_validation.ts +54 -0
  31. package/src/service/quoting.spec.ts +50 -0
  32. package/src/service/quoting.ts +37 -16
  33. package/src/utils.ts +5 -0
  34. package/dist/app/assets/HomePage-DQzgkiMI.js +0 -1
@@ -28,10 +28,12 @@ import {
28
28
  describe,
29
29
  expect,
30
30
  it,
31
+ spyOn,
31
32
  } from "bun:test";
32
33
  import * as fs from "fs";
33
34
  import * as os from "os";
34
35
  import * as path from "path";
36
+ import { logger } from "../logger";
35
37
  import {
36
38
  PackageLoadPool,
37
39
  __setPackageLoadPoolForTests,
@@ -280,14 +282,13 @@ source: gated is duckdb.sql("select 1 as id")`,
280
282
  }
281
283
  });
282
284
 
283
- it("rejects a package whose view carries an invalid renderer tag", async () => {
285
+ it("logs a warning for a package whose view carries an invalid renderer tag", async () => {
284
286
  writeManifest();
285
287
  // `# big_value { sparkline=... }` is a child-only renderer config placed on
286
288
  // the view itself, with no activating big_value. The renderer declines to
287
- // match it; without compile-time validation it renders as "[object Object]"
288
- // at query time. Render-tag validation runs on the main thread after the
289
- // worker hydrates the model, so the load must reject with a 424
290
- // ModelCompilationError naming the offending view.
289
+ // match it. Render-tag validation runs on the main thread after the worker
290
+ // hydrates the model. The misconfiguration is logged as a warning naming
291
+ // the offending view; it does not fail the package load.
291
292
  fs.writeFileSync(
292
293
  path.join(tempDir, "bad_render.malloy"),
293
294
  `source: nums is duckdb.sql("select 1 as a, 2 as b") extend {
@@ -302,21 +303,21 @@ source: gated is duckdb.sql("select 1 as id")`,
302
303
  }`,
303
304
  );
304
305
 
305
- const { ModelCompilationError } = await import("../errors");
306
+ const warnSpy = spyOn(logger, "warn");
306
307
  const { malloyConfig, duckdb } = await makeMalloyConfig();
307
308
  try {
308
- let caught: unknown;
309
- try {
310
- await Package.create("env", "pkg", tempDir, malloyConfig);
311
- } catch (err) {
312
- caught = err;
313
- }
314
- expect(caught).toBeInstanceOf(ModelCompilationError);
315
- expect((caught as Error).message).toContain(
316
- "Invalid renderer configuration",
317
- );
318
- expect((caught as Error).message).toContain("nums -> card");
309
+ const pkg = await Package.create("env", "pkg", tempDir, malloyConfig);
310
+ expect(pkg.getModelPaths()).toEqual(["bad_render.malloy"]);
311
+ const warnings = warnSpy.mock.calls.map((c) => String(c[0]));
312
+ expect(
313
+ warnings.some(
314
+ (m) =>
315
+ m.includes("Invalid renderer configuration") &&
316
+ m.includes("nums -> card"),
317
+ ),
318
+ ).toBe(true);
319
319
  } finally {
320
+ warnSpy.mockRestore();
320
321
  await duckdb.close();
321
322
  }
322
323
  });
@@ -341,7 +342,7 @@ source: gated is duckdb.sql("select 1 as id")`,
341
342
  }
342
343
  });
343
344
 
344
- it("rejects a package whose backtick-quoted (hyphenated) source has a bad view render tag", async () => {
345
+ it("logs a warning for a backtick-quoted (hyphenated) source with a bad view render tag", async () => {
345
346
  writeManifest();
346
347
  // A source whose name needs Malloy backtick-quoting (here, a hyphen). The
347
348
  // validation target must quote the identifier; otherwise `run: bad-source
@@ -363,26 +364,26 @@ source: gated is duckdb.sql("select 1 as id")`,
363
364
  }`,
364
365
  );
365
366
 
366
- const { ModelCompilationError } = await import("../errors");
367
+ const warnSpy = spyOn(logger, "warn");
367
368
  const { malloyConfig, duckdb } = await makeMalloyConfig();
368
369
  try {
369
- let caught: unknown;
370
- try {
371
- await Package.create("env", "pkg", tempDir, malloyConfig);
372
- } catch (err) {
373
- caught = err;
374
- }
375
- expect(caught).toBeInstanceOf(ModelCompilationError);
376
- expect((caught as Error).message).toContain(
377
- "Invalid renderer configuration",
378
- );
379
- expect((caught as Error).message).toContain("bad-source -> card");
370
+ const pkg = await Package.create("env", "pkg", tempDir, malloyConfig);
371
+ expect(pkg.getModelPaths()).toEqual(["hyphen_render.malloy"]);
372
+ const warnings = warnSpy.mock.calls.map((c) => String(c[0]));
373
+ expect(
374
+ warnings.some(
375
+ (m) =>
376
+ m.includes("Invalid renderer configuration") &&
377
+ m.includes("bad-source -> card"),
378
+ ),
379
+ ).toBe(true);
380
380
  } finally {
381
+ warnSpy.mockRestore();
381
382
  await duckdb.close();
382
383
  }
383
384
  });
384
385
 
385
- it("rejects a package whose source name contains an escaped backtick with a bad view render tag", async () => {
386
+ it("logs a warning for a source name containing an escaped backtick with a bad view render tag", async () => {
386
387
  writeManifest();
387
388
  // Source name with a literal backtick (written escaped in Malloy as
388
389
  // `wei\`rd`). The validation target must re-escape it; a bare wrap would
@@ -403,26 +404,26 @@ source: gated is duckdb.sql("select 1 as id")`,
403
404
  }`,
404
405
  );
405
406
 
406
- const { ModelCompilationError } = await import("../errors");
407
+ const warnSpy = spyOn(logger, "warn");
407
408
  const { malloyConfig, duckdb } = await makeMalloyConfig();
408
409
  try {
409
- let caught: unknown;
410
- try {
411
- await Package.create("env", "pkg", tempDir, malloyConfig);
412
- } catch (err) {
413
- caught = err;
414
- }
415
- expect(caught).toBeInstanceOf(ModelCompilationError);
416
- expect((caught as Error).message).toContain(
417
- "Invalid renderer configuration",
418
- );
419
- expect((caught as Error).message).toContain("card");
410
+ const pkg = await Package.create("env", "pkg", tempDir, malloyConfig);
411
+ expect(pkg.getModelPaths()).toEqual(["backtick_render.malloy"]);
412
+ const warnings = warnSpy.mock.calls.map((c) => String(c[0]));
413
+ expect(
414
+ warnings.some(
415
+ (m) =>
416
+ m.includes("Invalid renderer configuration") &&
417
+ m.includes("card"),
418
+ ),
419
+ ).toBe(true);
420
420
  } finally {
421
+ warnSpy.mockRestore();
421
422
  await duckdb.close();
422
423
  }
423
424
  });
424
425
 
425
- it("rejects a .malloynb notebook whose source view carries an invalid renderer tag", async () => {
426
+ it("logs a warning for a .malloynb notebook whose source view carries an invalid renderer tag", async () => {
426
427
  writeManifest();
427
428
  fs.writeFileSync(
428
429
  path.join(tempDir, "bad_render.malloynb"),
@@ -439,18 +440,22 @@ source: nums is duckdb.sql("select 1 as a, 2 as b") extend {
439
440
  }`,
440
441
  );
441
442
 
442
- const { ModelCompilationError } = await import("../errors");
443
+ const warnSpy = spyOn(logger, "warn");
443
444
  const { malloyConfig, duckdb } = await makeMalloyConfig();
444
445
  try {
445
- await expect(
446
- Package.create("env", "pkg", tempDir, malloyConfig),
447
- ).rejects.toBeInstanceOf(ModelCompilationError);
446
+ const pkg = await Package.create("env", "pkg", tempDir, malloyConfig);
447
+ expect(pkg.getModelPaths()).toEqual(["bad_render.malloynb"]);
448
+ const warnings = warnSpy.mock.calls.map((c) => String(c[0]));
449
+ expect(
450
+ warnings.some((m) => m.includes("Invalid renderer configuration")),
451
+ ).toBe(true);
448
452
  } finally {
453
+ warnSpy.mockRestore();
449
454
  await duckdb.close();
450
455
  }
451
456
  });
452
457
 
453
- it("re-validates renderer tags on reload, recording a model that develops a bad tag as a per-model error", async () => {
458
+ it("re-validates renderer tags on reload, logging a warning for a model that develops a bad tag", async () => {
454
459
  writeManifest();
455
460
  // Start valid so Package.create succeeds.
456
461
  fs.writeFileSync(
@@ -463,13 +468,15 @@ source: nums is duckdb.sql("select 1 as a, 2 as b") extend {
463
468
  );
464
469
 
465
470
  const { malloyConfig, duckdb } = await makeMalloyConfig();
471
+ const warnSpy = spyOn(logger, "warn");
466
472
  try {
467
473
  const pkg = await Package.create("env", "pkg", tempDir, malloyConfig);
468
474
  expect(pkg.getModel("m.malloy")).toBeDefined();
475
+ warnSpy.mockClear();
469
476
 
470
- // The model now develops a misconfigured render tag. Reload must catch
471
- // it (not just Package.create) and surface it as the model's error,
472
- // without aborting the whole reload.
477
+ // The model now develops a misconfigured render tag. Reload re-validates
478
+ // (not just Package.create) and logs a warning for it, without aborting
479
+ // the reload or failing the model.
473
480
  fs.writeFileSync(
474
481
  path.join(tempDir, "m.malloy"),
475
482
  `source: nums is duckdb.sql("select 1 as a, 2 as b") extend {
@@ -487,10 +494,13 @@ source: nums is duckdb.sql("select 1 as a, 2 as b") extend {
487
494
 
488
495
  const reloaded = pkg.getModel("m.malloy");
489
496
  expect(reloaded).toBeDefined();
490
- await expect(reloaded!.getModel()).rejects.toThrow(
491
- "Invalid renderer configuration",
492
- );
497
+ await expect(reloaded!.getModel()).resolves.toBeDefined();
498
+ const warnings = warnSpy.mock.calls.map((c) => String(c[0]));
499
+ expect(
500
+ warnings.some((m) => m.includes("Invalid renderer configuration")),
501
+ ).toBe(true);
493
502
  } finally {
503
+ warnSpy.mockRestore();
494
504
  await duckdb.close();
495
505
  }
496
506
  });
@@ -0,0 +1,77 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { ModelCompilationError } from "../errors";
3
+ import { assertPersistNamesQuoted } from "./persist_annotation_validation";
4
+
5
+ describe("assertPersistNamesQuoted", () => {
6
+ it("throws ModelCompilationError on a bare (unquoted) persist name", () => {
7
+ expect(() =>
8
+ assertPersistNamesQuoted(
9
+ `#@ persist name=engaged_events\nsource: engaged_events is x -> { select: * }`,
10
+ "m.malloy",
11
+ ),
12
+ ).toThrow(ModelCompilationError);
13
+ });
14
+
15
+ it("tolerates whitespace around = but still flags an unquoted value", () => {
16
+ expect(() =>
17
+ assertPersistNamesQuoted(
18
+ `#@ persist name = engaged_events`,
19
+ "m.malloy",
20
+ ),
21
+ ).toThrow(/must be quoted/);
22
+ });
23
+
24
+ it("accepts a double-quoted name", () => {
25
+ expect(() =>
26
+ assertPersistNamesQuoted(
27
+ `#@ persist name="engaged_events"`,
28
+ "m.malloy",
29
+ ),
30
+ ).not.toThrow();
31
+ });
32
+
33
+ it("accepts a single-quoted name", () => {
34
+ expect(() =>
35
+ assertPersistNamesQuoted(
36
+ `#@ persist name='engaged_events'`,
37
+ "m.malloy",
38
+ ),
39
+ ).not.toThrow();
40
+ });
41
+
42
+ it("accepts a dotted, quoted dialect path", () => {
43
+ expect(() =>
44
+ assertPersistNamesQuoted(
45
+ `#@ persist name="my_dataset.engaged_events"`,
46
+ "m.malloy",
47
+ ),
48
+ ).not.toThrow();
49
+ });
50
+
51
+ it("ignores non-persist annotations and persist lines with no name field", () => {
52
+ expect(() =>
53
+ assertPersistNamesQuoted(
54
+ `# number=2\n#@ persist realization="COPY"\nsource: x is y -> { select: * }`,
55
+ "m.malloy",
56
+ ),
57
+ ).not.toThrow();
58
+ });
59
+
60
+ it("does not mistake a neighbouring key for the name field", () => {
61
+ expect(() =>
62
+ assertPersistNamesQuoted(
63
+ `#@ persist tablename=foo name="bar"`,
64
+ "m.malloy",
65
+ ),
66
+ ).not.toThrow();
67
+ });
68
+
69
+ it("reports every offending annotation in the message", () => {
70
+ expect(() =>
71
+ assertPersistNamesQuoted(
72
+ `#@ persist name=a\nsource: a is x -> { select: * }\n#@ persist name=b\nsource: b is x -> { select: * }`,
73
+ "m.malloy",
74
+ ),
75
+ ).toThrow(/name=a.*name=b/s);
76
+ });
77
+ });
@@ -0,0 +1,54 @@
1
+ import { ModelCompilationError } from "../errors";
2
+
3
+ /** A line whose first non-whitespace content is a `#@ persist` directive. */
4
+ const PERSIST_LINE_PATTERN = /^\s*#@\s+persist\b/;
5
+ /**
6
+ * A `name=` key whose value is NOT immediately opened by a single or double
7
+ * quote — i.e. a bare `name=engaged_events` (whitespace around `=` tolerated).
8
+ * `name="..."` and `name='...'` pass. The `\bname` word boundary requires a
9
+ * standalone key, so neighbours like `tablename=` / `realization_name=` are
10
+ * never mistaken for it.
11
+ */
12
+ const UNQUOTED_NAME_PATTERN = /\bname\s*=\s*(?!["'])/;
13
+
14
+ /**
15
+ * Reject `#@ persist name=<value>` annotations whose name is unquoted.
16
+ *
17
+ * The persist build plan requires a quoted name — a dialect-style table path
18
+ * (`name="engaged_events"`, or `name="my_dataset.engaged_events"`). A bare
19
+ * value is dropped from the build plan, so the source publishes and serves but
20
+ * is never materialized, with no error anywhere. Throwing a
21
+ * `ModelCompilationError` (HTTP 424) fails the publish/load with a clear,
22
+ * actionable message — the same hard-stop `Model.validateRenderTags` applies to
23
+ * a misconfigured render tag.
24
+ *
25
+ * Scans the raw model source line-by-line rather than the compiled annotation
26
+ * objects: the check must fire regardless of how the compiler attaches the
27
+ * annotation, and the raw text is the ground truth for whether the author
28
+ * quoted the value (the tag parser discards quote information once parsed).
29
+ *
30
+ * @throws {ModelCompilationError} listing every offending annotation.
31
+ */
32
+ export function assertPersistNamesQuoted(
33
+ modelSource: string,
34
+ modelPath: string,
35
+ ): void {
36
+ const offenders: string[] = [];
37
+ for (const rawLine of modelSource.split("\n")) {
38
+ if (!PERSIST_LINE_PATTERN.test(rawLine)) continue;
39
+ if (UNQUOTED_NAME_PATTERN.test(rawLine)) {
40
+ offenders.push(rawLine.trim());
41
+ }
42
+ }
43
+ if (offenders.length > 0) {
44
+ throw new ModelCompilationError({
45
+ message:
46
+ `${modelPath}: persist annotation name must be quoted. Write a quoted ` +
47
+ `value like name="engaged_events" (or a dialect table path such as ` +
48
+ `name="my_dataset.engaged_events"), not a bare value — an unquoted ` +
49
+ `persist name is dropped from the build plan, so the source would ` +
50
+ `publish but never materialize. Offending annotation(s): ` +
51
+ `${offenders.join("; ")}.`,
52
+ });
53
+ }
54
+ }
@@ -0,0 +1,50 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { bareTableName, quoteIdentifier, quoteTablePath } from "./quoting";
3
+
4
+ describe("bareTableName", () => {
5
+ it("returns the segment after the last dot for a qualified name", () => {
6
+ expect(bareTableName("my_schema.my_table")).toBe("my_table");
7
+ });
8
+
9
+ it("returns the name unchanged when it is unqualified", () => {
10
+ expect(bareTableName("my_table")).toBe("my_table");
11
+ });
12
+ });
13
+
14
+ describe("quoteIdentifier", () => {
15
+ it("backticks for backtick dialects (BigQuery / MySQL / Databricks)", () => {
16
+ expect(quoteIdentifier("my-table", "standardsql")).toBe("`my-table`");
17
+ expect(quoteIdentifier("t", "mysql")).toBe("`t`");
18
+ expect(quoteIdentifier("t", "databricks")).toBe("`t`");
19
+ });
20
+
21
+ it("double-quotes for everything else (Postgres / DuckDB / Snowflake)", () => {
22
+ expect(quoteIdentifier("my-table", "postgres")).toBe('"my-table"');
23
+ expect(quoteIdentifier("t", "duckdb")).toBe('"t"');
24
+ expect(quoteIdentifier("t", "snowflake")).toBe('"t"');
25
+ });
26
+
27
+ it("escapes an embedded quote char by doubling it", () => {
28
+ expect(quoteIdentifier("a`b", "standardsql")).toBe("`a``b`");
29
+ expect(quoteIdentifier('a"b', "postgres")).toBe('"a""b"');
30
+ });
31
+ });
32
+
33
+ describe("quoteTablePath", () => {
34
+ it("quotes each segment of a container path independently", () => {
35
+ expect(
36
+ quoteTablePath(
37
+ "my-proj.mydataset.engaged_events_ab12_v0",
38
+ "standardsql",
39
+ ),
40
+ ).toBe("`my-proj`.`mydataset`.`engaged_events_ab12_v0`");
41
+ expect(quoteTablePath("mydataset.t_ab12_v0", "postgres")).toBe(
42
+ '"mydataset"."t_ab12_v0"',
43
+ );
44
+ });
45
+
46
+ it("quotes a bare (unqualified) name", () => {
47
+ expect(quoteTablePath("t_ab12_v0", "standardsql")).toBe("`t_ab12_v0`");
48
+ expect(quoteTablePath("t_ab12_v0", "postgres")).toBe('"t_ab12_v0"');
49
+ });
50
+ });
@@ -1,21 +1,42 @@
1
1
  /**
2
- * Split a possibly schema-qualified table name into its schema prefix
3
- * (including the trailing dot) and the bare table name.
4
- *
5
- * Examples:
6
- * "my_schema.my_table" -> { schemaPrefix: "my_schema.", bareName: "my_table" }
7
- * "my_table" -> { schemaPrefix: "", bareName: "my_table" }
2
+ * The bare (unqualified) name of a possibly container-qualified table path:
3
+ * the segment after the last dot, e.g. `my_schema.my_table` -> `my_table` and
4
+ * `my_table` -> `my_table`. Used as the RENAME target, which names a table
5
+ * within its existing schema rather than re-stating the full path.
8
6
  */
9
- export function splitTablePath(tableName: string): {
10
- schemaPrefix: string;
11
- bareName: string;
12
- } {
7
+ export function bareTableName(tableName: string): string {
13
8
  const lastDot = tableName.lastIndexOf(".");
14
- if (lastDot >= 0) {
15
- return {
16
- schemaPrefix: tableName.substring(0, lastDot + 1),
17
- bareName: tableName.substring(lastDot + 1),
18
- };
9
+ return lastDot >= 0 ? tableName.substring(lastDot + 1) : tableName;
10
+ }
11
+
12
+ // Dialects whose identifier quote character is a backtick; everything else uses
13
+ // the SQL-standard double quote. Keyed by Malloy `dialectName`.
14
+ const BACKTICK_DIALECTS = new Set(["standardsql", "mysql", "databricks"]);
15
+
16
+ /**
17
+ * Quote a single SQL identifier for {@code dialect}, escaping any embedded quote
18
+ * character by doubling it.
19
+ */
20
+ export function quoteIdentifier(identifier: string, dialect: string): string {
21
+ if (BACKTICK_DIALECTS.has(dialect)) {
22
+ return "`" + identifier.replace(/`/g, "``") + "`";
19
23
  }
20
- return { schemaPrefix: "", bareName: tableName };
24
+ return '"' + identifier.replace(/"/g, '""') + '"';
25
+ }
26
+
27
+ /**
28
+ * Dialect-quote a (possibly container-qualified) table path so it can be inlined
29
+ * into DDL. Each dot segment is quoted independently and rejoined with dots, so
30
+ * a path like {@code my-proj.mydataset.engaged_events_v0} becomes
31
+ * `` `my-proj`.`mydataset`.`engaged_events_v0` `` on BigQuery or
32
+ * {@code "my-proj"."mydataset"."engaged_events_v0"} on Postgres — handling
33
+ * container hierarchies and quote-requiring names (e.g. hyphenated BigQuery
34
+ * project ids) uniformly. The control plane provides the logical (unquoted) path
35
+ * in `physicalTableName`; quoting for the warehouse is the publisher's job.
36
+ */
37
+ export function quoteTablePath(tableName: string, dialect: string): string {
38
+ return tableName
39
+ .split(".")
40
+ .map((segment) => quoteIdentifier(segment, dialect))
41
+ .join(".");
21
42
  }
package/src/utils.ts CHANGED
@@ -22,3 +22,8 @@ export const URL_READER: URLReader = {
22
22
  export function ignoreDotfiles(file: string): boolean {
23
23
  return path.basename(file).startsWith(".");
24
24
  }
25
+
26
+ /** The message of a thrown value, stringifying non-Error throws. */
27
+ export function errMessage(err: unknown): string {
28
+ return err instanceof Error ? err.message : String(err);
29
+ }
@@ -1 +0,0 @@
1
- import{$ as s,j as t,s as o}from"./index-DuqTjxM_.js";function e(){const n=s();return t.jsx(o,{onClickEnvironment:n})}export{e as default};