@malloy-publisher/server 0.0.228 → 0.0.230

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 (94) hide show
  1. package/dist/app/api-doc.yaml +102 -10
  2. package/dist/app/assets/EnvironmentPage-wa_EPkwK.js +1 -0
  3. package/dist/app/assets/{HomePage-Bkwc9Woc.js → HomePage-jnCrupQp.js} +1 -1
  4. package/dist/app/assets/{LightMode-Bum_KBpN.js → LightMode-DYbwNULZ.js} +1 -1
  5. package/dist/app/assets/MainPage-CuJLrPNI.js +2 -0
  6. package/dist/app/assets/MaterializationsPage-D_67x2ee.js +1 -0
  7. package/dist/app/assets/ModelPage-D5JtAWqR.js +1 -0
  8. package/dist/app/assets/PackagePage-BRwtqUSG.js +1 -0
  9. package/dist/app/assets/{RouteError-DmJLpLXm.js → RouteError-CBNNrnSD.js} +1 -1
  10. package/dist/app/assets/ThemeEditorPage-CTCeBneA.js +1 -0
  11. package/dist/app/assets/WorkbookPage-SN6f1RBm.js +1 -0
  12. package/dist/app/assets/{core-CEDZMHV1.es-_yGzNgNH.js → core-Dp3q5Ieu.es-CD5FvM2s.js} +10 -10
  13. package/dist/app/assets/{index-CdmFub34.js → index-B3Nn8Vm2.js} +459 -446
  14. package/dist/app/assets/index-BLCx1EdC.js +18 -0
  15. package/dist/app/assets/index-C_tJstcx.js +529 -0
  16. package/dist/app/assets/{index-CE9xhdra.js → index-CfmBVB6M.js} +1 -1
  17. package/dist/app/assets/{index-OEjKNSYb.js → index-DU4r7GdU.js} +573 -413
  18. package/dist/app/index.html +1 -1
  19. package/dist/instrumentation.mjs +1 -1
  20. package/dist/package_load_worker.mjs +109 -8
  21. package/dist/server.mjs +7504 -80
  22. package/package.json +15 -13
  23. package/scripts/bake-duckdb-extensions.js +5 -2
  24. package/src/config.spec.ts +87 -1
  25. package/src/config.ts +142 -1
  26. package/src/controller/materialization.controller.spec.ts +9 -0
  27. package/src/controller/materialization.controller.ts +15 -0
  28. package/src/ducklake_version.spec.ts +163 -0
  29. package/src/ducklake_version.ts +153 -0
  30. package/src/errors.ts +12 -0
  31. package/src/malloy_pin_prereqs.spec.ts +25 -0
  32. package/src/materialization_metrics.ts +33 -0
  33. package/src/mcp/skills/build_skills_bundle.ts +29 -9
  34. package/src/mcp/skills/skills_bundle.json +1 -1
  35. package/src/mcp/skills/skills_bundle.spec.ts +31 -2
  36. package/src/query_param_utils.ts +11 -0
  37. package/src/server.ts +74 -9
  38. package/src/service/authorize.spec.ts +22 -0
  39. package/src/service/authorize.ts +267 -10
  40. package/src/service/authorize_integration.spec.ts +1068 -26
  41. package/src/service/connection.spec.ts +79 -7
  42. package/src/service/connection.ts +312 -25
  43. package/src/service/connection_config.spec.ts +21 -0
  44. package/src/service/connection_config.ts +15 -1
  45. package/src/service/cron_evaluator.spec.ts +79 -0
  46. package/src/service/cron_evaluator.ts +105 -0
  47. package/src/service/ducklake_lazy_attach.spec.ts +110 -0
  48. package/src/service/environment.ts +156 -17
  49. package/src/service/environment_store.spec.ts +569 -14
  50. package/src/service/environment_store.ts +237 -31
  51. package/src/service/environment_store_anchoring.spec.ts +107 -0
  52. package/src/service/extension_fetch_policy.spec.ts +256 -0
  53. package/src/service/manifest_loader.spec.ts +3 -1
  54. package/src/service/manifest_loader.ts +9 -5
  55. package/src/service/materialization_scheduler.spec.ts +289 -0
  56. package/src/service/materialization_scheduler.ts +304 -0
  57. package/src/service/materialization_service.spec.ts +286 -5
  58. package/src/service/materialization_service.ts +121 -8
  59. package/src/service/materialization_test_fixtures.ts +10 -1
  60. package/src/service/model.spec.ts +67 -0
  61. package/src/service/model.ts +656 -31
  62. package/src/service/package.spec.ts +38 -0
  63. package/src/service/package.ts +102 -2
  64. package/src/service/package_quote_bound_tables.spec.ts +171 -0
  65. package/src/service/persistence_policy.spec.ts +31 -0
  66. package/src/service/quoting.spec.ts +57 -1
  67. package/src/service/quoting.ts +33 -0
  68. package/src/storage/DatabaseInterface.ts +15 -0
  69. package/src/storage/duckdb/DuckDBRepository.ts +17 -0
  70. package/src/storage/duckdb/MaterializationRepository.spec.ts +191 -0
  71. package/src/storage/duckdb/MaterializationRepository.ts +68 -0
  72. package/tests/fixtures/persist-schedule-test/data/orders.csv +5 -0
  73. package/tests/fixtures/persist-schedule-test/persist_schedule_test.malloy +11 -0
  74. package/tests/fixtures/persist-schedule-test/publisher.json +7 -0
  75. package/tests/fixtures/persist-schedule-warn-test/data/orders.csv +5 -0
  76. package/tests/fixtures/persist-schedule-warn-test/persist_warn_test.malloy +11 -0
  77. package/tests/fixtures/persist-schedule-warn-test/publisher.json +6 -0
  78. package/tests/integration/materialization/environment_list.integration.spec.ts +133 -0
  79. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +7 -2
  80. package/tests/integration/materialization/orchestrated_rebind.integration.spec.ts +150 -0
  81. package/tests/integration/materialization/schedule_edit.integration.spec.ts +126 -0
  82. package/tests/integration/materialization/scheduler.integration.spec.ts +200 -0
  83. package/tests/integration/materialization/scheduler_recovery.integration.spec.ts +172 -0
  84. package/tests/integration/materialization/scheduler_transitions.integration.spec.ts +256 -0
  85. package/tests/integration/materialization/scheduler_wiring.integration.spec.ts +143 -0
  86. package/dist/app/assets/EnvironmentPage-EW2lbGvb.js +0 -1
  87. package/dist/app/assets/MainPage-oiEy7TNM.js +0 -2
  88. package/dist/app/assets/MaterializationsPage-C_VJsTgU.js +0 -1
  89. package/dist/app/assets/ModelPage-z8REqAmk.js +0 -1
  90. package/dist/app/assets/PackagePage-C2Vtt1Ln.js +0 -1
  91. package/dist/app/assets/ThemeEditorPage-BywFjC7A.js +0 -1
  92. package/dist/app/assets/WorkbookPage-DCMizDMR.js +0 -1
  93. package/dist/app/assets/index-DDMrjIT3.js +0 -18
  94. package/dist/app/assets/index-EqslXZ44.js +0 -527
@@ -0,0 +1,105 @@
1
+ /**
2
+ * Evaluate a 5-field UNIX cron expression, in UTC, for the standalone
3
+ * materialization scheduler. Mirrors the control plane's `CronEvaluator`
4
+ * semantics (5-field, UTC, "strictly after") so a given `materialization.schedule`
5
+ * fires at the same instants whether the publisher runs standalone or the control
6
+ * plane drives it.
7
+ *
8
+ * Thin wrapper over `cron-parser` that pins three policies:
9
+ * - **exactly 5 fields** — `cron-parser` also accepts an optional leading
10
+ * seconds field (6 fields); the manifest contract is 5-field UNIX cron, so a
11
+ * 6-field expression is rejected rather than silently reinterpreted.
12
+ * - **plain UNIX grammar** — `cron-parser` accepts extensions (`L` last,
13
+ * `W` nearest-weekday, `#` nth-weekday, `?` Quartz no-specific) that the
14
+ * control plane's `cron-utils` UNIX parser rejects. Without this pin, an
15
+ * expression like `0 6 L * *` would validate and fire locally but, after
16
+ * publish, silently never arm in production. Month/day names (JAN, MON-FRI)
17
+ * are still allowed.
18
+ * - **UTC** — schedules are absolute, never tied to the server's local zone.
19
+ */
20
+ import { CronExpressionParser } from "cron-parser";
21
+
22
+ /** Valid 3-letter month and day-of-week names in UNIX cron. */
23
+ const UNIX_CRON_NAMES = new Set([
24
+ "JAN",
25
+ "FEB",
26
+ "MAR",
27
+ "APR",
28
+ "MAY",
29
+ "JUN",
30
+ "JUL",
31
+ "AUG",
32
+ "SEP",
33
+ "OCT",
34
+ "NOV",
35
+ "DEC",
36
+ "SUN",
37
+ "MON",
38
+ "TUE",
39
+ "WED",
40
+ "THU",
41
+ "FRI",
42
+ "SAT",
43
+ ]);
44
+
45
+ export class CronEvaluator {
46
+ /** True when `expr` is a syntactically valid 5-field UNIX cron. */
47
+ isValid(expr: string): boolean {
48
+ if (!this.hasFiveFields(expr) || !this.isPlainUnixGrammar(expr)) {
49
+ return false;
50
+ }
51
+ try {
52
+ CronExpressionParser.parse(expr, { tz: "UTC" });
53
+ return true;
54
+ } catch {
55
+ return false;
56
+ }
57
+ }
58
+
59
+ /**
60
+ * The next fire instant strictly after `from` (UTC). Throws when `expr` is
61
+ * not a valid 5-field cron — callers guard with {@link isValid} (the
62
+ * scheduler skips + logs an invalid cron rather than letting it throw in the
63
+ * tick).
64
+ */
65
+ nextAfter(expr: string, from: Date): Date {
66
+ if (!this.hasFiveFields(expr) || !this.isPlainUnixGrammar(expr)) {
67
+ throw new Error(
68
+ `Expected a 5-field UNIX cron, got: ${JSON.stringify(expr)}`,
69
+ );
70
+ }
71
+ return CronExpressionParser.parse(expr, {
72
+ currentDate: from,
73
+ tz: "UTC",
74
+ })
75
+ .next()
76
+ .toDate();
77
+ }
78
+
79
+ /** Whitespace-split field count is exactly 5. */
80
+ private hasFiveFields(expr: string): boolean {
81
+ return typeof expr === "string" && expr.trim().split(/\s+/).length === 5;
82
+ }
83
+
84
+ /**
85
+ * Reject cron-parser / Quartz extensions absent from plain UNIX cron: `#`
86
+ * and `?` (never part of a name), and any alphabetic token that is not a
87
+ * UNIX month/day name — which catches `L`, `W`, `LW`, `L-3`, `15W`, etc.
88
+ * while still allowing named fields like `JUL` or `MON-FRI`.
89
+ */
90
+ private isPlainUnixGrammar(expr: string): boolean {
91
+ for (const field of expr.trim().split(/\s+/)) {
92
+ if (/[#?]/.test(field)) return false;
93
+ for (const token of field.split(/[,\-/]/)) {
94
+ if (token === "" || token === "*") continue;
95
+ if (
96
+ /[A-Za-z]/.test(token) &&
97
+ !UNIX_CRON_NAMES.has(token.toUpperCase())
98
+ ) {
99
+ return false;
100
+ }
101
+ }
102
+ }
103
+ return true;
104
+ }
105
+ }
@@ -0,0 +1,110 @@
1
+ import { DuckDBConnection } from "@malloydata/db-duckdb";
2
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
3
+ import fs from "fs/promises";
4
+ import path from "path";
5
+ import sinon from "sinon";
6
+ import { components } from "../api";
7
+ import { buildEnvironmentMalloyConfig } from "./connection";
8
+
9
+ type ApiConnection = components["schemas"]["Connection"];
10
+
11
+ // A DuckLake connection whose Postgres catalog points at an unroutable host.
12
+ // If anything on the boot path tried to reach the catalog, these tests would
13
+ // hang or fail — which is precisely the regression they guard against.
14
+ const UNREACHABLE_DUCKLAKE: ApiConnection = {
15
+ name: "ducklake_lazy",
16
+ type: "ducklake",
17
+ ducklakeConnection: {
18
+ catalog: {
19
+ postgresConnection: {
20
+ host: "192.0.2.1", // TEST-NET-1: guaranteed non-routable
21
+ port: 5432,
22
+ userName: "nobody",
23
+ password: "nobody",
24
+ databaseName: "catalog",
25
+ },
26
+ },
27
+ storage: {
28
+ bucketUrl: "s3://test-bucket",
29
+ s3Connection: {
30
+ accessKeyId: "test",
31
+ secretAccessKey: "test",
32
+ },
33
+ },
34
+ },
35
+ } as ApiConnection;
36
+
37
+ describe("DuckLake lazy attach", () => {
38
+ const envPath = path.join(process.cwd(), "test-ducklake-lazy-attach");
39
+
40
+ beforeEach(async () => {
41
+ await fs.mkdir(envPath, { recursive: true });
42
+ });
43
+
44
+ afterEach(async () => {
45
+ sinon.restore();
46
+ await fs.rm(envPath, { recursive: true, force: true }).catch(() => {});
47
+ });
48
+
49
+ it("does not attach the catalog while building the environment config (boot path)", async () => {
50
+ // Stub the DuckDB session so no real database or network work happens; the
51
+ // stub also lets us observe whether ANY SQL ran during config build.
52
+ const runSQL = sinon
53
+ .stub(DuckDBConnection.prototype, "runSQL")
54
+ .resolves({ rows: [] } as never);
55
+
56
+ // Building the config is the worker boot path. It must be synchronous and
57
+ // must not open, construct, or touch the DuckLake session at all.
58
+ const config = buildEnvironmentMalloyConfig(
59
+ [UNREACHABLE_DUCKLAKE],
60
+ envPath,
61
+ );
62
+
63
+ expect(config.apiConnections.map((c) => c.name)).toContain(
64
+ "ducklake_lazy",
65
+ );
66
+ // The definitive assertion: zero SQL was issued building the config, so
67
+ // the (unreachable) catalog could not have been contacted on boot.
68
+ expect(runSQL.callCount).toBe(0);
69
+ });
70
+
71
+ it("cannot let an unavailable catalog block startup", async () => {
72
+ sinon
73
+ .stub(DuckDBConnection.prototype, "runSQL")
74
+ .resolves({ rows: [] } as never);
75
+
76
+ // Even with an unreachable catalog, constructing the environment config
77
+ // resolves promptly and never awaits the catalog. If attach were on the
78
+ // boot path, this would hang against 192.0.2.1 until a connect timeout.
79
+ const config = buildEnvironmentMalloyConfig(
80
+ [UNREACHABLE_DUCKLAKE],
81
+ envPath,
82
+ );
83
+ expect(config).toBeDefined();
84
+ expect(config.malloyConfig).toBeDefined();
85
+ });
86
+
87
+ it("attaches the catalog only on first connection lookup (serve path)", async () => {
88
+ const runSQL = sinon
89
+ .stub(DuckDBConnection.prototype, "runSQL")
90
+ .resolves({ rows: [] } as never);
91
+
92
+ const config = buildEnvironmentMalloyConfig(
93
+ [UNREACHABLE_DUCKLAKE],
94
+ envPath,
95
+ );
96
+ expect(runSQL.callCount).toBe(0);
97
+
98
+ // The lazy attach fires here, on the first lookup — not before.
99
+ await config.malloyConfig.connections.lookupConnection("ducklake_lazy");
100
+
101
+ const issued = runSQL.getCalls().map((c) => String(c.args[0]));
102
+ const attachedDuckLake = issued.some((sql) =>
103
+ sql.includes("ducklake:postgres:"),
104
+ );
105
+ expect(runSQL.callCount).toBeGreaterThan(0);
106
+ expect(attachedDuckLake).toBe(true);
107
+
108
+ await config.releaseConnections().catch(() => {});
109
+ });
110
+ });
@@ -21,6 +21,7 @@ import {
21
21
  ServiceUnavailableError,
22
22
  } from "../errors";
23
23
  import { logger } from "../logger";
24
+ import { redactPgSecrets } from "../pg_helpers";
24
25
  import { recordManifestBind } from "../materialization_metrics";
25
26
  import {
26
27
  assertSafeEnvironmentPath,
@@ -138,6 +139,27 @@ export class Environment {
138
139
  // AB/BA deadlock path.
139
140
  private packageMutexes = new Map<string, Mutex>();
140
141
  private packageStatuses: Map<string, PackageInfo> = new Map();
142
+ /**
143
+ * Configured packages that failed to load, keyed by name, with the reason.
144
+ *
145
+ * A load failure is not fatal: the package is omitted and its siblings serve
146
+ * on. It is also observable exactly once, because the failing load deletes
147
+ * the `packageStatuses` entry that `listPackages` enumerates, so the next
148
+ * listing no longer knows the package was ever configured. That makes this
149
+ * the only lasting record. Read by EnvironmentStore.getStatus.
150
+ */
151
+ private failedPackages: Map<string, string> = new Map();
152
+ /**
153
+ * Why a configured package never reached the disk, keyed by package name.
154
+ *
155
+ * Separate from {@link failedPackages} because it is the more specific
156
+ * answer and has to win. A package whose location failed to mount is still
157
+ * seeded SERVING by addEnvironment, so its later lazy load fails on the
158
+ * manifest that was never copied and `listPackages` records that instead.
159
+ * Reporting "Package manifest ... does not exist." for what was really a
160
+ * typo'd `location` sends the reader hunting in the wrong place.
161
+ */
162
+ private mountErrors: Map<string, string> = new Map();
141
163
  private malloyConfig: EnvironmentMalloyConfig;
142
164
  private connectionMutex = new Mutex();
143
165
  private retiredConnectionGenerations =
@@ -158,6 +180,11 @@ export class Environment {
158
180
  return this.environmentPath;
159
181
  }
160
182
 
183
+ /** This environment's name (the canonical key used by the API/service). */
184
+ public getEnvironmentName(): string {
185
+ return this.environmentName;
186
+ }
187
+
161
188
  constructor(
162
189
  environmentName: string,
163
190
  environmentPath: string,
@@ -456,10 +483,15 @@ export class Environment {
456
483
  );
457
484
  }
458
485
 
459
- // Authorize backstop (the *who* axis, 403). Only run when the model
460
- // declares gates so ungated compiles don't pay for the extra
461
- // final-query compile.
462
- if (queryMaterializer && gateModel?.hasAuthorize()) {
486
+ // Authorize backstop (the *who* axis, 403). NOT guarded by
487
+ // hasAuthorize(): that only inspects top-level modelDef.contents
488
+ // sources, so a gated source reached only via a cross-file/deep
489
+ // join is invisible to it and this backstop would silently never
490
+ // run for such a model — the same bypass assertAuthorizedForRunnable
491
+ // itself closes on the query path (see model.ts
492
+ // assertAuthorizedForAllSources). The own-source probe and joined-
493
+ // gate walk it runs are cheap no-ops for a genuinely ungated model.
494
+ if (queryMaterializer && gateModel) {
463
495
  await gateModel.assertAuthorizedForRunnable(
464
496
  queryMaterializer,
465
497
  givens ?? {},
@@ -589,6 +621,17 @@ export class Environment {
589
621
  );
590
622
  }
591
623
 
624
+ /**
625
+ * Snapshot of the packages currently loaded in memory (does not trigger a
626
+ * load or reload). Used by the standalone materialization scheduler to sweep
627
+ * only already-loaded packages — a not-yet-loaded package is simply not
628
+ * scheduled until something else loads it, so the scheduler never forces a
629
+ * load of its own.
630
+ */
631
+ public getLoadedPackages(): Package[] {
632
+ return [...this.packages.values()];
633
+ }
634
+
592
635
  public async listPackages(): Promise<ApiPackage[]> {
593
636
  logger.debug("Listing packages", {
594
637
  environmentPath: this.environmentPath,
@@ -613,6 +656,24 @@ export class Environment {
613
656
  );
614
657
  // Directory did not contain a valid package.json file -- therefore, it's not a package.
615
658
  // Or it timed out
659
+ // Redact before this reaches getStatus: compiling a model
660
+ // resolves the package's connections, so a Postgres/DuckLake
661
+ // ATTACH failure surfaces here carrying the connection string
662
+ // (connection.ts builds it, and redacts it before logging for
663
+ // the same reason). A log line was the old destination; this
664
+ // one is an HTTP response body.
665
+ //
666
+ // Reduces the exposure, does not remove it: redactPgSecrets
667
+ // only covers keyword-form `password=`, which is what
668
+ // buildPgConnectionString emits. A URL-form connectionString
669
+ // supplied verbatim in config still carries its credentials
670
+ // through. Widen the helper rather than trusting this call.
671
+ this.failedPackages.set(
672
+ packageName,
673
+ redactPgSecrets(
674
+ error instanceof Error ? error.message : String(error),
675
+ ),
676
+ );
616
677
  return undefined;
617
678
  }
618
679
  }),
@@ -876,6 +937,9 @@ export class Environment {
876
937
  }
877
938
  this.packages.set(packageName, _package);
878
939
  this.setPackageStatus(packageName, PackageStatus.SERVING);
940
+ // It loaded, so any earlier failure is stale. A package that failed at
941
+ // boot can be fixed on disk and reloaded without a restart.
942
+ this.clearPackageLoadFailure(packageName);
879
943
  logger.debug(`Successfully loaded package ${packageName}`);
880
944
 
881
945
  return _package;
@@ -957,6 +1021,10 @@ export class Environment {
957
1021
  throw error;
958
1022
  }
959
1023
  this.setPackageStatus(packageName, PackageStatus.SERVING);
1024
+ // Same reasoning as the load and install paths: it is serving now, so an
1025
+ // earlier boot failure is stale. Without this, a package fixed on disk
1026
+ // and re-added keeps its loadError for the life of the process.
1027
+ this.clearPackageLoadFailure(packageName);
960
1028
  return this.packages.get(packageName);
961
1029
  }
962
1030
 
@@ -1145,6 +1213,8 @@ export class Environment {
1145
1213
 
1146
1214
  this.packages.set(packageName, newPackage);
1147
1215
  this.setPackageStatus(packageName, PackageStatus.SERVING);
1216
+ // Publishing a fixed package clears the boot failure it replaces.
1217
+ this.clearPackageLoadFailure(packageName);
1148
1218
 
1149
1219
  if (oldPackage) {
1150
1220
  this.retireConnectionGeneration(`package ${packageName}`, () =>
@@ -1312,6 +1382,8 @@ export class Environment {
1312
1382
  explores?: string[];
1313
1383
  queryableSources?: "declared" | "all";
1314
1384
  manifestLocation?: string | null;
1385
+ scope?: ApiPackage["scope"];
1386
+ materialization?: ApiPackage["materialization"];
1315
1387
  },
1316
1388
  ): Promise<void> {
1317
1389
  const packagePath = safeJoinUnderRoot(this.environmentPath, packageName);
@@ -1344,6 +1416,10 @@ export class Environment {
1344
1416
  ...(metadata.manifestLocation !== undefined
1345
1417
  ? { manifestLocation: metadata.manifestLocation }
1346
1418
  : {}),
1419
+ ...(metadata.scope !== undefined ? { scope: metadata.scope } : {}),
1420
+ ...(metadata.materialization !== undefined
1421
+ ? { materialization: metadata.materialization }
1422
+ : {}),
1347
1423
  };
1348
1424
 
1349
1425
  // Write back to file
@@ -1396,6 +1472,29 @@ export class Environment {
1396
1472
  body.manifestLocation !== undefined
1397
1473
  ? body.manifestLocation
1398
1474
  : existing.manifestLocation;
1475
+ // Persist `scope` and `materialization` (the schedule cron) are
1476
+ // editable via the API — both are writable in the schema. When the
1477
+ // body carries a value, apply it; otherwise preserve the
1478
+ // manifest-derived one (a name/description-only PATCH must not wipe
1479
+ // them, and the control plane must not misread the gap as a removal).
1480
+ // Changing the schedule re-arms the standalone scheduler on its next
1481
+ // tick — no reload needed.
1482
+ //
1483
+ // A *null* scope/materialization is treated the same as omitted
1484
+ // (preserve), NOT a wipe: the control plane's post-build rebind PATCH
1485
+ // carries only name/location/manifestLocation, but a client that
1486
+ // serializes unset fields as explicit null must not thereby trip the
1487
+ // policy gate below (a rejection there fails the orchestrated run) or
1488
+ // reset the persisted policy. `manifestLocation` is deliberately
1489
+ // different — null there means "clear" (revert to live), which the
1490
+ // orchestrator relies on.
1491
+ const scopeProvided = body.scope != null;
1492
+ const materializationProvided = body.materialization != null;
1493
+ const editingPolicy = scopeProvided || materializationProvided;
1494
+ const scope = scopeProvided ? body.scope : existing.scope;
1495
+ const materialization = materializationProvided
1496
+ ? body.materialization
1497
+ : existing.materialization;
1399
1498
  _package.setPackageMetadata({
1400
1499
  name: body.name,
1401
1500
  description: body.description,
@@ -1404,25 +1503,27 @@ export class Environment {
1404
1503
  explores,
1405
1504
  queryableSources,
1406
1505
  manifestLocation,
1407
- // Carry the manifest-derived materialization policy through the
1408
- // PATCH. `setPackageMetadata` replaces the whole object, so omitting
1409
- // this wipes the in-memory `materialization` until the next reload —
1410
- // which makes a later getPackage report no schedule and lets the
1411
- // control plane misread the gap as a schedule removal. It is not a
1412
- // PATCH-editable field, so always preserve the existing value.
1413
- materialization: existing.materialization,
1414
- // Same rationale for the manifest-derived persist `scope`: not
1415
- // PATCH-editable, so preserve it rather than dropping it to the
1416
- // default until the next reload.
1417
- scope: existing.scope,
1506
+ materialization,
1507
+ scope,
1418
1508
  });
1419
1509
 
1420
1510
  // Strict-reject, symmetric with the publish path
1421
1511
  // (package.controller.addPackage): validate the resulting explores
1422
1512
  // against the live model set and restore the prior metadata before
1423
1513
  // rejecting, so a bad update neither persists nor mutates the served
1424
- // surface.
1425
- const invalidMsg = _package.formatInvalidExplores();
1514
+ // surface. When the body edits the persistence policy (scope /
1515
+ // materialization), also enforce the same scope/schedule/freshness/cron
1516
+ // rules a publish enforces — but only then, so a description-only PATCH
1517
+ // on a package with a pre-existing (load-tolerated) policy warning is
1518
+ // not newly rejected. Cron validity is one of these rules
1519
+ // (persistencePolicyWarnings Rule 4), so publish, PATCH, load, and the
1520
+ // scheduler all enforce it identically.
1521
+ const policyMsg = editingPolicy
1522
+ ? _package.formatInvalidPersistencePolicy()
1523
+ : "";
1524
+ const invalidMsg = [_package.formatInvalidExplores(), policyMsg]
1525
+ .filter(Boolean)
1526
+ .join("\n");
1426
1527
  if (invalidMsg) {
1427
1528
  _package.setPackageMetadata(existing);
1428
1529
  throw new BadRequestError(invalidMsg);
@@ -1434,6 +1535,13 @@ export class Environment {
1434
1535
  explores: normalizedExplores,
1435
1536
  queryableSources: body.queryableSources,
1436
1537
  manifestLocation: body.manifestLocation,
1538
+ // Only write when explicitly provided (non-null): mirrors the
1539
+ // null-as-absent rule above, so a rebind PATCH neither wipes the
1540
+ // persisted policy nor writes a stray `scope: null`.
1541
+ scope: scopeProvided ? body.scope : undefined,
1542
+ materialization: materializationProvided
1543
+ ? body.materialization
1544
+ : undefined,
1437
1545
  });
1438
1546
 
1439
1547
  // When the body changes manifestLocation, apply it now so the new
@@ -1455,6 +1563,29 @@ export class Environment {
1455
1563
  return this.packageStatuses.get(packageName);
1456
1564
  }
1457
1565
 
1566
+ /**
1567
+ * Record why a configured package's location never mounted, so /status can
1568
+ * name the real cause instead of the missing-manifest fallout it produces.
1569
+ * Called by EnvironmentStore right after the environment is created.
1570
+ */
1571
+ public setPackageMountError(packageName: string, message: string): void {
1572
+ this.mountErrors.set(packageName, message);
1573
+ }
1574
+
1575
+ /** Forget any recorded failure for a package, whatever its cause. */
1576
+ private clearPackageLoadFailure(packageName: string): void {
1577
+ this.failedPackages.delete(packageName);
1578
+ this.mountErrors.delete(packageName);
1579
+ }
1580
+
1581
+ /** Packages configured for this environment that did not load, and why. */
1582
+ public getFailedPackages(): ReadonlyMap<string, string> {
1583
+ if (this.mountErrors.size === 0) return this.failedPackages;
1584
+ // Mount errors last, so the specific cause overwrites the generic
1585
+ // manifest error that the un-mounted package produces on its lazy load.
1586
+ return new Map([...this.failedPackages, ...this.mountErrors]);
1587
+ }
1588
+
1458
1589
  public setPackageStatus(packageName: string, status: PackageStatus): void {
1459
1590
  const currentStatus = this.packageStatuses.get(packageName);
1460
1591
  this.packageStatuses.set(packageName, {
@@ -1471,6 +1602,14 @@ export class Environment {
1471
1602
  public async deletePackage(packageName: string): Promise<void> {
1472
1603
  assertSafePackageName(packageName);
1473
1604
  return this.withPackageLock(packageName, async () => {
1605
+ // Clear the load failure before the early return, not after it. A
1606
+ // package that failed to load is not in `packages` (the load catch
1607
+ // evicts it), so deleting it takes the early return every time, while
1608
+ // the controller still drops its config row. Leaving the entry would
1609
+ // make getStatus report a loadError for a package that is no longer
1610
+ // configured, which is the one thing that channel must not do.
1611
+ this.clearPackageLoadFailure(packageName);
1612
+
1474
1613
  const _package = this.packages.get(packageName);
1475
1614
  if (!_package) {
1476
1615
  return;