@malloy-publisher/server 0.0.227 → 0.0.229

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 (106) hide show
  1. package/README.docker.md +8 -8
  2. package/dist/app/api-doc.yaml +84 -1
  3. package/dist/app/assets/EnvironmentPage-QOoHiVeJ.js +1 -0
  4. package/dist/app/assets/{HomePage-CXguJsXS.js → HomePage-C71GOfVW.js} +1 -1
  5. package/dist/app/assets/{LightMode-ZsshUznu.js → LightMode-CrgCAwLe.js} +1 -1
  6. package/dist/app/assets/MainPage-BG5__FN3.js +2 -0
  7. package/dist/app/assets/MaterializationsPage-DE6PnrDR.js +1 -0
  8. package/dist/app/assets/ModelPage-CcBjcbLm.js +1 -0
  9. package/dist/app/assets/PackagePage-JTy3ztkB.js +1 -0
  10. package/dist/app/assets/{RouteError-Chn7lL96.js → RouteError-Cymbp47a.js} +1 -1
  11. package/dist/app/assets/ThemeEditorPage-C_nMnHr8.js +1 -0
  12. package/dist/app/assets/WorkbookPage-CPQu-DQx.js +1 -0
  13. package/dist/app/assets/{core-vVgoO8IR.es-BD_THWs_.js → core-Coi3caGs.es-CSOmajHS.js} +10 -10
  14. package/dist/app/assets/{index-gEWxu09x.js → index-CM2qhQCI.js} +1 -1
  15. package/dist/app/assets/{index-D6YtyiJ0.js → index-CcuuST2X.js} +1 -1
  16. package/dist/app/assets/{index-OEjKNSYb.js → index-CkmABCAw.js} +388 -241
  17. package/dist/app/assets/{index-BioohWQj.js → index-DlWCXghy.js} +1 -1
  18. package/dist/app/assets/index-DxArlgRD.js +529 -0
  19. package/dist/app/index.html +1 -1
  20. package/dist/default-publisher.config.json +7 -7
  21. package/dist/instrumentation.mjs +1 -1
  22. package/dist/package_load_worker.mjs +1 -1
  23. package/dist/runtime/publisher.js +5 -0
  24. package/dist/server.mjs +7675 -899
  25. package/package.json +4 -2
  26. package/publisher.config.example.bigquery.json +7 -7
  27. package/publisher.config.example.duckdb.json +7 -7
  28. package/publisher.config.json +7 -11
  29. package/src/config.spec.ts +89 -3
  30. package/src/config.ts +102 -1
  31. package/src/controller/materialization.controller.spec.ts +9 -0
  32. package/src/controller/materialization.controller.ts +15 -0
  33. package/src/controller/package.controller.ts +62 -31
  34. package/src/default-publisher.config.json +7 -7
  35. package/src/materialization_metrics.ts +33 -0
  36. package/src/mcp/handler_utils.spec.ts +108 -0
  37. package/src/mcp/handler_utils.ts +98 -4
  38. package/src/mcp/server.protocol.spec.ts +58 -0
  39. package/src/mcp/server.ts +29 -1
  40. package/src/mcp/skills/build_skills_bundle.ts +29 -9
  41. package/src/mcp/skills/skills_bundle.json +1 -1
  42. package/src/mcp/skills/skills_bundle.spec.ts +31 -2
  43. package/src/mcp/tools/compile_tool.spec.ts +207 -0
  44. package/src/mcp/tools/compile_tool.ts +177 -0
  45. package/src/mcp/tools/execute_query_tool.spec.ts +143 -0
  46. package/src/mcp/tools/execute_query_tool.ts +37 -4
  47. package/src/mcp/tools/get_context_tool.ts +1 -1
  48. package/src/mcp/tools/reload_package_tool.spec.ts +232 -0
  49. package/src/mcp/tools/reload_package_tool.ts +158 -0
  50. package/src/query_param_utils.ts +11 -0
  51. package/src/runtime/publisher.js +5 -0
  52. package/src/server.ts +74 -14
  53. package/src/service/connection.spec.ts +8 -7
  54. package/src/service/cron_evaluator.spec.ts +79 -0
  55. package/src/service/cron_evaluator.ts +105 -0
  56. package/src/service/environment.ts +188 -19
  57. package/src/service/environment_store.spec.ts +372 -2
  58. package/src/service/environment_store.ts +149 -17
  59. package/src/service/environment_store_anchoring.spec.ts +107 -0
  60. package/src/service/manifest_loader.spec.ts +3 -1
  61. package/src/service/manifest_loader.ts +9 -5
  62. package/src/service/materialization_scheduler.spec.ts +260 -0
  63. package/src/service/materialization_scheduler.ts +304 -0
  64. package/src/service/materialization_service.spec.ts +167 -4
  65. package/src/service/materialization_service.ts +121 -8
  66. package/src/service/materialization_test_fixtures.ts +10 -1
  67. package/src/service/model.spec.ts +92 -0
  68. package/src/service/model.ts +58 -7
  69. package/src/service/package.ts +121 -14
  70. package/src/service/package_quote_bound_tables.spec.ts +171 -0
  71. package/src/service/package_reload_safety.spec.ts +193 -0
  72. package/src/service/persistence_policy.spec.ts +31 -0
  73. package/src/service/quoting.spec.ts +57 -1
  74. package/src/service/quoting.ts +33 -0
  75. package/src/storage/DatabaseInterface.ts +15 -0
  76. package/src/storage/duckdb/DuckDBRepository.ts +17 -0
  77. package/src/storage/duckdb/MaterializationRepository.spec.ts +154 -0
  78. package/src/storage/duckdb/MaterializationRepository.ts +56 -0
  79. package/tests/fixtures/persist-schedule-test/data/orders.csv +5 -0
  80. package/tests/fixtures/persist-schedule-test/persist_schedule_test.malloy +11 -0
  81. package/tests/fixtures/persist-schedule-test/publisher.json +7 -0
  82. package/tests/fixtures/persist-schedule-warn-test/data/orders.csv +5 -0
  83. package/tests/fixtures/persist-schedule-warn-test/persist_warn_test.malloy +11 -0
  84. package/tests/fixtures/persist-schedule-warn-test/publisher.json +6 -0
  85. package/tests/fixtures/query-givens/data/orders.csv +7 -0
  86. package/tests/fixtures/query-givens/model.malloy +34 -0
  87. package/tests/fixtures/query-givens/publisher.json +5 -0
  88. package/tests/integration/materialization/environment_list.integration.spec.ts +133 -0
  89. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +7 -2
  90. package/tests/integration/materialization/orchestrated_rebind.integration.spec.ts +150 -0
  91. package/tests/integration/materialization/schedule_edit.integration.spec.ts +126 -0
  92. package/tests/integration/materialization/scheduler.integration.spec.ts +200 -0
  93. package/tests/integration/materialization/scheduler_recovery.integration.spec.ts +172 -0
  94. package/tests/integration/materialization/scheduler_wiring.integration.spec.ts +143 -0
  95. package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +22 -22
  96. package/tests/integration/query_givens/query_givens.integration.spec.ts +146 -0
  97. package/tests/integration/query_givens/query_givens_authorize.integration.spec.ts +121 -0
  98. package/tests/integration/sdk_givens/sdk_givens.integration.spec.ts +110 -0
  99. package/dist/app/assets/EnvironmentPage-DvOJ7L_b.js +0 -1
  100. package/dist/app/assets/MainPage-BIe0VwBa.js +0 -2
  101. package/dist/app/assets/MaterializationsPage-BuZ6UJVx.js +0 -1
  102. package/dist/app/assets/ModelPage-DsPf-s8B.js +0 -1
  103. package/dist/app/assets/PackagePage-CEVNAKZa.js +0 -1
  104. package/dist/app/assets/ThemeEditorPage-DWC_FdNU.js +0 -1
  105. package/dist/app/assets/WorkbookPage-CGrsFz8p.js +0 -1
  106. package/dist/app/assets/index-DNUZpnaa.js +0 -527
@@ -0,0 +1,79 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { CronEvaluator } from "./cron_evaluator";
3
+
4
+ describe("CronEvaluator", () => {
5
+ const cron = new CronEvaluator();
6
+
7
+ describe("isValid", () => {
8
+ it("accepts 5-field UNIX crons", () => {
9
+ expect(cron.isValid("0 6 * * *")).toBe(true);
10
+ expect(cron.isValid("*/15 * * * *")).toBe(true);
11
+ expect(cron.isValid("0 0 1 1 *")).toBe(true);
12
+ });
13
+
14
+ it("rejects a 6-field (seconds) cron — the contract is 5-field", () => {
15
+ expect(cron.isValid("0 0 6 * * *")).toBe(false);
16
+ });
17
+
18
+ it("rejects garbage and empty input", () => {
19
+ expect(cron.isValid("not a cron")).toBe(false);
20
+ expect(cron.isValid("")).toBe(false);
21
+ expect(cron.isValid("* * *")).toBe(false);
22
+ // Out-of-range field.
23
+ expect(cron.isValid("99 * * * *")).toBe(false);
24
+ });
25
+
26
+ it("rejects cron-parser / Quartz extensions the UNIX parser lacks", () => {
27
+ // These parse under cron-parser but the control plane's cron-utils
28
+ // UNIX parser rejects them — pinning the grammar keeps a cron from
29
+ // validating locally yet silently never arming in production.
30
+ expect(cron.isValid("0 6 L * *")).toBe(false); // last day of month
31
+ expect(cron.isValid("0 6 15W * *")).toBe(false); // nearest weekday
32
+ expect(cron.isValid("0 6 * * 1#2")).toBe(false); // 2nd Monday
33
+ expect(cron.isValid("0 0 ? * *")).toBe(false); // Quartz no-specific
34
+ expect(cron.isValid("0 6 LW * *")).toBe(false); // last weekday
35
+ });
36
+
37
+ it("accepts UNIX month/day names (not mistaken for extensions)", () => {
38
+ expect(cron.isValid("0 6 * JUL *")).toBe(true); // JUL contains 'L'
39
+ expect(cron.isValid("0 6 * * WED")).toBe(true); // WED contains 'W'
40
+ expect(cron.isValid("0 6 * * THU")).toBe(true); // THU contains 'H'
41
+ expect(cron.isValid("0 6 * * MON-FRI")).toBe(true);
42
+ });
43
+ });
44
+
45
+ describe("nextAfter (UTC, strictly after)", () => {
46
+ it("returns the next daily fire", () => {
47
+ const from = new Date("2026-07-13T00:00:00Z");
48
+ expect(cron.nextAfter("0 6 * * *", from).toISOString()).toBe(
49
+ "2026-07-13T06:00:00.000Z",
50
+ );
51
+ });
52
+
53
+ it("rolls to the next day when the fire time has passed", () => {
54
+ const from = new Date("2026-07-13T07:00:00Z");
55
+ expect(cron.nextAfter("0 6 * * *", from).toISOString()).toBe(
56
+ "2026-07-14T06:00:00.000Z",
57
+ );
58
+ });
59
+
60
+ it("advances strictly past a `from` that lands exactly on a fire", () => {
61
+ const from = new Date("2026-07-13T06:00:00Z");
62
+ // Strictly after: the 06:00 instant itself is excluded.
63
+ expect(cron.nextAfter("0 6 * * *", from).toISOString()).toBe(
64
+ "2026-07-14T06:00:00.000Z",
65
+ );
66
+ });
67
+
68
+ it("steps every 15 minutes", () => {
69
+ const from = new Date("2026-07-13T09:07:00Z");
70
+ expect(cron.nextAfter("*/15 * * * *", from).toISOString()).toBe(
71
+ "2026-07-13T09:15:00.000Z",
72
+ );
73
+ });
74
+
75
+ it("throws on an invalid cron (callers guard with isValid)", () => {
76
+ expect(() => cron.nextAfter("nope", new Date())).toThrow();
77
+ });
78
+ });
79
+ });
@@ -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
+ }
@@ -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,16 @@ 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();
141
152
  private malloyConfig: EnvironmentMalloyConfig;
142
153
  private connectionMutex = new Mutex();
143
154
  private retiredConnectionGenerations =
@@ -158,6 +169,11 @@ export class Environment {
158
169
  return this.environmentPath;
159
170
  }
160
171
 
172
+ /** This environment's name (the canonical key used by the API/service). */
173
+ public getEnvironmentName(): string {
174
+ return this.environmentName;
175
+ }
176
+
161
177
  constructor(
162
178
  environmentName: string,
163
179
  environmentPath: string,
@@ -471,9 +487,30 @@ export class Environment {
471
487
  if (includeSql && queryMaterializer) {
472
488
  try {
473
489
  sql = await queryMaterializer.getSQL({ givens });
474
- } catch {
475
- // Source may not contain a runnable query (e.g. only source definitions),
476
- // in which case we simply omit the sql field.
490
+ } catch (error) {
491
+ // A bad caller given (unknown name, wrong-typed value, finalized
492
+ // override, ...) surfaces as a Malloy `runtime-given-*` error.
493
+ // Map it to a 400 rather than silently omitting `sql` (which is
494
+ // indistinguishable from "no runnable query"). Duck-type on
495
+ // `.code`; let a MalloyError fall to the outer catch → problems.
496
+ // The `runtime-given-` prefix is pinned to Malloy's error codes
497
+ // (given_binding.ts / runtime.ts, same as model.ts) — if they're
498
+ // renamed upstream a bad given would silently revert to the omit
499
+ // branch below, so keep the two in sync.
500
+ const givenCode = (error as { code?: string })?.code;
501
+ if (
502
+ typeof givenCode === "string" &&
503
+ givenCode.startsWith("runtime-given-")
504
+ ) {
505
+ throw new BadRequestError(
506
+ error instanceof Error ? error.message : String(error),
507
+ );
508
+ }
509
+ if (error instanceof MalloyError) {
510
+ throw error;
511
+ }
512
+ // Otherwise the source may just not contain a runnable query
513
+ // (e.g. only source definitions) — omit the sql field.
477
514
  }
478
515
  }
479
516
 
@@ -568,6 +605,17 @@ export class Environment {
568
605
  );
569
606
  }
570
607
 
608
+ /**
609
+ * Snapshot of the packages currently loaded in memory (does not trigger a
610
+ * load or reload). Used by the standalone materialization scheduler to sweep
611
+ * only already-loaded packages — a not-yet-loaded package is simply not
612
+ * scheduled until something else loads it, so the scheduler never forces a
613
+ * load of its own.
614
+ */
615
+ public getLoadedPackages(): Package[] {
616
+ return [...this.packages.values()];
617
+ }
618
+
571
619
  public async listPackages(): Promise<ApiPackage[]> {
572
620
  logger.debug("Listing packages", {
573
621
  environmentPath: this.environmentPath,
@@ -592,6 +640,24 @@ export class Environment {
592
640
  );
593
641
  // Directory did not contain a valid package.json file -- therefore, it's not a package.
594
642
  // Or it timed out
643
+ // Redact before this reaches getStatus: compiling a model
644
+ // resolves the package's connections, so a Postgres/DuckLake
645
+ // ATTACH failure surfaces here carrying the connection string
646
+ // (connection.ts builds it, and redacts it before logging for
647
+ // the same reason). A log line was the old destination; this
648
+ // one is an HTTP response body.
649
+ //
650
+ // Reduces the exposure, does not remove it: redactPgSecrets
651
+ // only covers keyword-form `password=`, which is what
652
+ // buildPgConnectionString emits. A URL-form connectionString
653
+ // supplied verbatim in config still carries its credentials
654
+ // through. Widen the helper rather than trusting this call.
655
+ this.failedPackages.set(
656
+ packageName,
657
+ redactPgSecrets(
658
+ error instanceof Error ? error.message : String(error),
659
+ ),
660
+ );
595
661
  return undefined;
596
662
  }
597
663
  }),
@@ -855,13 +921,25 @@ export class Environment {
855
921
  }
856
922
  this.packages.set(packageName, _package);
857
923
  this.setPackageStatus(packageName, PackageStatus.SERVING);
924
+ // It loaded, so any earlier failure is stale. A package that failed at
925
+ // boot can be fixed on disk and reloaded without a restart.
926
+ this.failedPackages.delete(packageName);
858
927
  logger.debug(`Successfully loaded package ${packageName}`);
859
928
 
860
929
  return _package;
861
930
  } catch (error) {
862
931
  logger.error(`Failed to load package ${packageName}`, { error });
863
- this.packages.delete(packageName);
864
- this.packageStatuses.delete(packageName);
932
+ if (existingPackage !== undefined && reload) {
933
+ // A failed RELOAD must not take down a package that is already
934
+ // serving. The compiled model in `packages` is still the last good
935
+ // one (it is only replaced on success), so keep serving it and let
936
+ // the caller surface the error instead of evicting the package and
937
+ // leaving the environment with nothing to answer from.
938
+ this.setPackageStatus(packageName, PackageStatus.SERVING);
939
+ } else {
940
+ this.packages.delete(packageName);
941
+ this.packageStatuses.delete(packageName);
942
+ }
865
943
  throw error;
866
944
  }
867
945
  }
@@ -927,6 +1005,10 @@ export class Environment {
927
1005
  throw error;
928
1006
  }
929
1007
  this.setPackageStatus(packageName, PackageStatus.SERVING);
1008
+ // Same reasoning as the load and install paths: it is serving now, so an
1009
+ // earlier boot failure is stale. Without this, a package fixed on disk
1010
+ // and re-added keeps its loadError for the life of the process.
1011
+ this.failedPackages.delete(packageName);
930
1012
  return this.packages.get(packageName);
931
1013
  }
932
1014
 
@@ -1019,6 +1101,10 @@ export class Environment {
1019
1101
  packageName,
1020
1102
  canonicalPath,
1021
1103
  () => this.malloyConfig.malloyConfig,
1104
+ // This tree was just staged into place by the rename above, so a
1105
+ // failed load leaves a half-built directory that is ours to
1106
+ // remove; the rollback below restores the previous one.
1107
+ true,
1022
1108
  );
1023
1109
  // Strict-reject hook (publish/update only — reload passes no
1024
1110
  // validator and stays fail-safe). Throw INSIDE the try so the
@@ -1064,7 +1150,37 @@ export class Environment {
1064
1150
  await fs.promises
1065
1151
  .rm(stagingPath, { recursive: true, force: true })
1066
1152
  .catch(() => {});
1067
- this.deletePackageStatus(packageName);
1153
+ if (oldPackage && restored) {
1154
+ // The rollback put the old tree back and the previous package is
1155
+ // still in `this.packages` (it is only replaced on success
1156
+ // below), so it is genuinely still serving. Deleting its status
1157
+ // would strand it: listPackages enumerates packageStatuses, so
1158
+ // the package would answer getPackage while being invisible to
1159
+ // listings and discovery until a restart.
1160
+ this.setPackageStatus(packageName, PackageStatus.SERVING);
1161
+ } else {
1162
+ // Either there was nothing to fall back to (a first install), or
1163
+ // the restore did not happen: the rename-back threw, or the old
1164
+ // tree was never on disk to retire. The canonical path is then
1165
+ // missing or still holds the rejected content, so the cached
1166
+ // package no longer matches disk and must not be advertised as
1167
+ // serving. Drop both, and keep the two maps agreeing.
1168
+ this.deletePackageStatus(packageName);
1169
+ if (oldPackage) {
1170
+ // Retire before dropping it, the same way every other eviction
1171
+ // here does: once it leaves this.packages, closeAllConnections
1172
+ // can no longer reach its MalloyConfig, so its native handles
1173
+ // would never be released. Retire rather than shut down
1174
+ // inline, because withPackageLock does not cover queries that
1175
+ // already took this Package from an earlier getPackage; the
1176
+ // drain lets those finish first.
1177
+ this.retireConnectionGeneration(
1178
+ `package ${packageName}`,
1179
+ () => oldPackage.getMalloyConfig().shutdown("close"),
1180
+ );
1181
+ }
1182
+ this.packages.delete(packageName);
1183
+ }
1068
1184
  logger.debug("install.phase2.rollback", {
1069
1185
  environmentName: this.environmentName,
1070
1186
  packageName,
@@ -1081,6 +1197,8 @@ export class Environment {
1081
1197
 
1082
1198
  this.packages.set(packageName, newPackage);
1083
1199
  this.setPackageStatus(packageName, PackageStatus.SERVING);
1200
+ // Publishing a fixed package clears the boot failure it replaces.
1201
+ this.failedPackages.delete(packageName);
1084
1202
 
1085
1203
  if (oldPackage) {
1086
1204
  this.retireConnectionGeneration(`package ${packageName}`, () =>
@@ -1248,6 +1366,8 @@ export class Environment {
1248
1366
  explores?: string[];
1249
1367
  queryableSources?: "declared" | "all";
1250
1368
  manifestLocation?: string | null;
1369
+ scope?: ApiPackage["scope"];
1370
+ materialization?: ApiPackage["materialization"];
1251
1371
  },
1252
1372
  ): Promise<void> {
1253
1373
  const packagePath = safeJoinUnderRoot(this.environmentPath, packageName);
@@ -1280,6 +1400,10 @@ export class Environment {
1280
1400
  ...(metadata.manifestLocation !== undefined
1281
1401
  ? { manifestLocation: metadata.manifestLocation }
1282
1402
  : {}),
1403
+ ...(metadata.scope !== undefined ? { scope: metadata.scope } : {}),
1404
+ ...(metadata.materialization !== undefined
1405
+ ? { materialization: metadata.materialization }
1406
+ : {}),
1283
1407
  };
1284
1408
 
1285
1409
  // Write back to file
@@ -1332,6 +1456,29 @@ export class Environment {
1332
1456
  body.manifestLocation !== undefined
1333
1457
  ? body.manifestLocation
1334
1458
  : existing.manifestLocation;
1459
+ // Persist `scope` and `materialization` (the schedule cron) are
1460
+ // editable via the API — both are writable in the schema. When the
1461
+ // body carries a value, apply it; otherwise preserve the
1462
+ // manifest-derived one (a name/description-only PATCH must not wipe
1463
+ // them, and the control plane must not misread the gap as a removal).
1464
+ // Changing the schedule re-arms the standalone scheduler on its next
1465
+ // tick — no reload needed.
1466
+ //
1467
+ // A *null* scope/materialization is treated the same as omitted
1468
+ // (preserve), NOT a wipe: the control plane's post-build rebind PATCH
1469
+ // carries only name/location/manifestLocation, but a client that
1470
+ // serializes unset fields as explicit null must not thereby trip the
1471
+ // policy gate below (a rejection there fails the orchestrated run) or
1472
+ // reset the persisted policy. `manifestLocation` is deliberately
1473
+ // different — null there means "clear" (revert to live), which the
1474
+ // orchestrator relies on.
1475
+ const scopeProvided = body.scope != null;
1476
+ const materializationProvided = body.materialization != null;
1477
+ const editingPolicy = scopeProvided || materializationProvided;
1478
+ const scope = scopeProvided ? body.scope : existing.scope;
1479
+ const materialization = materializationProvided
1480
+ ? body.materialization
1481
+ : existing.materialization;
1335
1482
  _package.setPackageMetadata({
1336
1483
  name: body.name,
1337
1484
  description: body.description,
@@ -1340,25 +1487,27 @@ export class Environment {
1340
1487
  explores,
1341
1488
  queryableSources,
1342
1489
  manifestLocation,
1343
- // Carry the manifest-derived materialization policy through the
1344
- // PATCH. `setPackageMetadata` replaces the whole object, so omitting
1345
- // this wipes the in-memory `materialization` until the next reload —
1346
- // which makes a later getPackage report no schedule and lets the
1347
- // control plane misread the gap as a schedule removal. It is not a
1348
- // PATCH-editable field, so always preserve the existing value.
1349
- materialization: existing.materialization,
1350
- // Same rationale for the manifest-derived persist `scope`: not
1351
- // PATCH-editable, so preserve it rather than dropping it to the
1352
- // default until the next reload.
1353
- scope: existing.scope,
1490
+ materialization,
1491
+ scope,
1354
1492
  });
1355
1493
 
1356
1494
  // Strict-reject, symmetric with the publish path
1357
1495
  // (package.controller.addPackage): validate the resulting explores
1358
1496
  // against the live model set and restore the prior metadata before
1359
1497
  // rejecting, so a bad update neither persists nor mutates the served
1360
- // surface.
1361
- const invalidMsg = _package.formatInvalidExplores();
1498
+ // surface. When the body edits the persistence policy (scope /
1499
+ // materialization), also enforce the same scope/schedule/freshness/cron
1500
+ // rules a publish enforces — but only then, so a description-only PATCH
1501
+ // on a package with a pre-existing (load-tolerated) policy warning is
1502
+ // not newly rejected. Cron validity is one of these rules
1503
+ // (persistencePolicyWarnings Rule 4), so publish, PATCH, load, and the
1504
+ // scheduler all enforce it identically.
1505
+ const policyMsg = editingPolicy
1506
+ ? _package.formatInvalidPersistencePolicy()
1507
+ : "";
1508
+ const invalidMsg = [_package.formatInvalidExplores(), policyMsg]
1509
+ .filter(Boolean)
1510
+ .join("\n");
1362
1511
  if (invalidMsg) {
1363
1512
  _package.setPackageMetadata(existing);
1364
1513
  throw new BadRequestError(invalidMsg);
@@ -1370,6 +1519,13 @@ export class Environment {
1370
1519
  explores: normalizedExplores,
1371
1520
  queryableSources: body.queryableSources,
1372
1521
  manifestLocation: body.manifestLocation,
1522
+ // Only write when explicitly provided (non-null): mirrors the
1523
+ // null-as-absent rule above, so a rebind PATCH neither wipes the
1524
+ // persisted policy nor writes a stray `scope: null`.
1525
+ scope: scopeProvided ? body.scope : undefined,
1526
+ materialization: materializationProvided
1527
+ ? body.materialization
1528
+ : undefined,
1373
1529
  });
1374
1530
 
1375
1531
  // When the body changes manifestLocation, apply it now so the new
@@ -1391,6 +1547,11 @@ export class Environment {
1391
1547
  return this.packageStatuses.get(packageName);
1392
1548
  }
1393
1549
 
1550
+ /** Packages configured for this environment that did not load, and why. */
1551
+ public getFailedPackages(): ReadonlyMap<string, string> {
1552
+ return this.failedPackages;
1553
+ }
1554
+
1394
1555
  public setPackageStatus(packageName: string, status: PackageStatus): void {
1395
1556
  const currentStatus = this.packageStatuses.get(packageName);
1396
1557
  this.packageStatuses.set(packageName, {
@@ -1407,6 +1568,14 @@ export class Environment {
1407
1568
  public async deletePackage(packageName: string): Promise<void> {
1408
1569
  assertSafePackageName(packageName);
1409
1570
  return this.withPackageLock(packageName, async () => {
1571
+ // Clear the load failure before the early return, not after it. A
1572
+ // package that failed to load is not in `packages` (the load catch
1573
+ // evicts it), so deleting it takes the early return every time, while
1574
+ // the controller still drops its config row. Leaving the entry would
1575
+ // make getStatus report a loadError for a package that is no longer
1576
+ // configured, which is the one thing that channel must not do.
1577
+ this.failedPackages.delete(packageName);
1578
+
1410
1579
  const _package = this.packages.get(packageName);
1411
1580
  if (!_package) {
1412
1581
  return;