@malloy-publisher/server 0.0.228 → 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 (76) hide show
  1. package/dist/app/api-doc.yaml +84 -1
  2. package/dist/app/assets/EnvironmentPage-QOoHiVeJ.js +1 -0
  3. package/dist/app/assets/{HomePage-Bkwc9Woc.js → HomePage-C71GOfVW.js} +1 -1
  4. package/dist/app/assets/{LightMode-Bum_KBpN.js → LightMode-CrgCAwLe.js} +1 -1
  5. package/dist/app/assets/MainPage-BG5__FN3.js +2 -0
  6. package/dist/app/assets/MaterializationsPage-DE6PnrDR.js +1 -0
  7. package/dist/app/assets/ModelPage-CcBjcbLm.js +1 -0
  8. package/dist/app/assets/PackagePage-JTy3ztkB.js +1 -0
  9. package/dist/app/assets/{RouteError-DmJLpLXm.js → RouteError-Cymbp47a.js} +1 -1
  10. package/dist/app/assets/ThemeEditorPage-C_nMnHr8.js +1 -0
  11. package/dist/app/assets/WorkbookPage-CPQu-DQx.js +1 -0
  12. package/dist/app/assets/{core-CEDZMHV1.es-_yGzNgNH.js → core-Coi3caGs.es-CSOmajHS.js} +10 -10
  13. package/dist/app/assets/{index-DDMrjIT3.js → index-CM2qhQCI.js} +1 -1
  14. package/dist/app/assets/{index-CE9xhdra.js → index-CcuuST2X.js} +1 -1
  15. package/dist/app/assets/{index-OEjKNSYb.js → index-CkmABCAw.js} +388 -241
  16. package/dist/app/assets/{index-CdmFub34.js → index-DlWCXghy.js} +1 -1
  17. package/dist/app/assets/index-DxArlgRD.js +529 -0
  18. package/dist/app/index.html +1 -1
  19. package/dist/instrumentation.mjs +1 -1
  20. package/dist/package_load_worker.mjs +1 -1
  21. package/dist/server.mjs +6934 -36
  22. package/package.json +4 -2
  23. package/src/config.spec.ts +87 -1
  24. package/src/config.ts +102 -1
  25. package/src/controller/materialization.controller.spec.ts +9 -0
  26. package/src/controller/materialization.controller.ts +15 -0
  27. package/src/materialization_metrics.ts +33 -0
  28. package/src/mcp/skills/build_skills_bundle.ts +29 -9
  29. package/src/mcp/skills/skills_bundle.json +1 -1
  30. package/src/mcp/skills/skills_bundle.spec.ts +31 -2
  31. package/src/query_param_utils.ts +11 -0
  32. package/src/server.ts +67 -9
  33. package/src/service/connection.spec.ts +8 -7
  34. package/src/service/cron_evaluator.spec.ts +79 -0
  35. package/src/service/cron_evaluator.ts +105 -0
  36. package/src/service/environment.ts +118 -13
  37. package/src/service/environment_store.spec.ts +372 -2
  38. package/src/service/environment_store.ts +149 -17
  39. package/src/service/environment_store_anchoring.spec.ts +107 -0
  40. package/src/service/manifest_loader.spec.ts +3 -1
  41. package/src/service/manifest_loader.ts +9 -5
  42. package/src/service/materialization_scheduler.spec.ts +260 -0
  43. package/src/service/materialization_scheduler.ts +304 -0
  44. package/src/service/materialization_service.spec.ts +167 -4
  45. package/src/service/materialization_service.ts +121 -8
  46. package/src/service/materialization_test_fixtures.ts +10 -1
  47. package/src/service/package.ts +90 -1
  48. package/src/service/package_quote_bound_tables.spec.ts +171 -0
  49. package/src/service/persistence_policy.spec.ts +31 -0
  50. package/src/service/quoting.spec.ts +57 -1
  51. package/src/service/quoting.ts +33 -0
  52. package/src/storage/DatabaseInterface.ts +15 -0
  53. package/src/storage/duckdb/DuckDBRepository.ts +17 -0
  54. package/src/storage/duckdb/MaterializationRepository.spec.ts +154 -0
  55. package/src/storage/duckdb/MaterializationRepository.ts +56 -0
  56. package/tests/fixtures/persist-schedule-test/data/orders.csv +5 -0
  57. package/tests/fixtures/persist-schedule-test/persist_schedule_test.malloy +11 -0
  58. package/tests/fixtures/persist-schedule-test/publisher.json +7 -0
  59. package/tests/fixtures/persist-schedule-warn-test/data/orders.csv +5 -0
  60. package/tests/fixtures/persist-schedule-warn-test/persist_warn_test.malloy +11 -0
  61. package/tests/fixtures/persist-schedule-warn-test/publisher.json +6 -0
  62. package/tests/integration/materialization/environment_list.integration.spec.ts +133 -0
  63. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +7 -2
  64. package/tests/integration/materialization/orchestrated_rebind.integration.spec.ts +150 -0
  65. package/tests/integration/materialization/schedule_edit.integration.spec.ts +126 -0
  66. package/tests/integration/materialization/scheduler.integration.spec.ts +200 -0
  67. package/tests/integration/materialization/scheduler_recovery.integration.spec.ts +172 -0
  68. package/tests/integration/materialization/scheduler_wiring.integration.spec.ts +143 -0
  69. package/dist/app/assets/EnvironmentPage-EW2lbGvb.js +0 -1
  70. package/dist/app/assets/MainPage-oiEy7TNM.js +0 -2
  71. package/dist/app/assets/MaterializationsPage-C_VJsTgU.js +0 -1
  72. package/dist/app/assets/ModelPage-z8REqAmk.js +0 -1
  73. package/dist/app/assets/PackagePage-C2Vtt1Ln.js +0 -1
  74. package/dist/app/assets/ThemeEditorPage-BywFjC7A.js +0 -1
  75. package/dist/app/assets/WorkbookPage-DCMizDMR.js +0 -1
  76. package/dist/app/assets/index-EqslXZ44.js +0 -527
@@ -1,4 +1,6 @@
1
1
  import { describe, expect, it } from "bun:test";
2
+ import * as path from "node:path";
3
+ import { buildSkills, type SkillEntry } from "./build_skills_bundle";
2
4
  import bundle from "./skills_bundle.json";
3
5
 
4
6
  const skills = (
@@ -7,9 +9,36 @@ const skills = (
7
9
  }
8
10
  ).skills;
9
11
 
12
+ /** The repo's top-level skills/, which this bundle is generated from. */
13
+ const sourceDir = path.join(
14
+ import.meta.dir,
15
+ "..",
16
+ "..",
17
+ "..",
18
+ "..",
19
+ "..",
20
+ "skills",
21
+ );
22
+
23
+ /**
24
+ * Codepoint order. The committed bundle is sorted with localeCompare, which
25
+ * depends on the runtime's locale, and this suite runs on three platforms.
26
+ * Membership and content are what matter here; file order is cosmetic.
27
+ */
28
+ const byName = (a: SkillEntry, b: SkillEntry) =>
29
+ a.name < b.name ? -1 : a.name > b.name ? 1 : 0;
30
+
10
31
  describe("skills_bundle.json (generated dual-channel asset)", () => {
11
- it("contains the full ported skill set", () => {
12
- expect(skills.length).toBeGreaterThanOrEqual(24);
32
+ it("is in sync with skills/", () => {
33
+ // Regenerate with:
34
+ // bun run src/mcp/skills/build_skills_bundle.ts ../../skills
35
+ expect([...skills].sort(byName)).toEqual(
36
+ [...buildSkills(sourceDir)].sort(byName),
37
+ );
38
+ });
39
+
40
+ it("is not empty", () => {
41
+ expect(skills.length).toBeGreaterThan(0);
13
42
  });
14
43
 
15
44
  it("every skill has a nonempty name, description, and body", () => {
@@ -16,3 +16,14 @@ export function normalizeQueryArray(value: unknown): string[] | undefined {
16
16
  if (Array.isArray(value)) return value.map(String);
17
17
  return [String(value)];
18
18
  }
19
+
20
+ /**
21
+ * Parse an Express query param as a non-negative integer, or `undefined` when
22
+ * it is absent or not a finite integer. Degrades a garbage value (`?limit=abc`)
23
+ * to "unset" rather than passing `NaN` down into a SQL `LIMIT`/`OFFSET` bind.
24
+ */
25
+ export function parseNonNegativeIntParam(value: unknown): number | undefined {
26
+ if (value === undefined || value === null) return undefined;
27
+ const parsed = parseInt(String(value), 10);
28
+ return Number.isInteger(parsed) && parsed >= 0 ? parsed : undefined;
29
+ }
package/src/server.ts CHANGED
@@ -34,7 +34,10 @@ import {
34
34
  } from "./instrumentation";
35
35
  import { logger, loggerMiddleware } from "./logger";
36
36
 
37
- import { getMemoryGovernorConfig } from "./config";
37
+ import {
38
+ getMaterializationSchedulerConfig,
39
+ getMemoryGovernorConfig,
40
+ } from "./config";
38
41
  import { setFilterDeprecationHeaders } from "./filter_deprecation";
39
42
  import { checkHeapConfiguration } from "./heap_check";
40
43
  import { queryConcurrency } from "./query_concurrency";
@@ -43,8 +46,12 @@ import { ThemeController } from "./controller/theme.controller";
43
46
  import { initializeMcpServer } from "./mcp/server";
44
47
  import { registerLegacyRoutes } from "./server-old";
45
48
  import { EnvironmentStore } from "./service/environment_store";
49
+ import { MaterializationScheduler } from "./service/materialization_scheduler";
46
50
  import { MaterializationService } from "./service/materialization_service";
47
- import { normalizeQueryArray } from "./query_param_utils";
51
+ import {
52
+ normalizeQueryArray,
53
+ parseNonNegativeIntParam,
54
+ } from "./query_param_utils";
48
55
  import { PackageMemoryGovernor } from "./service/package_memory_governor";
49
56
  import { ThemeStore } from "./service/theme_store";
50
57
  import { assertSafePackageName, safeJoinUnderRoot } from "./path_safety";
@@ -183,7 +190,7 @@ app.use(httpMetricsMiddleware);
183
190
  // looks low for the default caps" advisory so operators don't
184
191
  // chase OOMKills before checking the obvious config.
185
192
  checkHeapConfiguration();
186
- const environmentStore = new EnvironmentStore(SERVER_ROOT);
193
+ export const environmentStore = new EnvironmentStore(SERVER_ROOT);
187
194
  const watchModeController = new WatchModeController(environmentStore);
188
195
  const connectionController = new ConnectionController(environmentStore);
189
196
  const modelController = new ModelController(environmentStore);
@@ -206,6 +213,32 @@ const materializationService = new MaterializationService(environmentStore);
206
213
  const materializationController = new MaterializationController(
207
214
  materializationService,
208
215
  );
216
+ /**
217
+ * Construct and start the standalone materialization scheduler from environment
218
+ * config, or return null when the feature is disabled
219
+ * (`PUBLISHER_LOCAL_MATERIALIZATION_SCHEDULER` unset/false — the default, so an
220
+ * orchestrated deployment never runs it). Extracted from the module body so a
221
+ * test can drive the real env-var → {@link getMaterializationSchedulerConfig} →
222
+ * construct → `start()`/`unref()` → timer path an operator uses; the
223
+ * module-level singleton below is armed once at import and can't be re-created
224
+ * per test.
225
+ */
226
+ export function startMaterializationSchedulerFromEnv(
227
+ store: EnvironmentStore,
228
+ service: MaterializationService,
229
+ ): MaterializationScheduler | null {
230
+ const config = getMaterializationSchedulerConfig();
231
+ if (!config) return null;
232
+ const scheduler = new MaterializationScheduler(store, service, config);
233
+ scheduler.start();
234
+ return scheduler;
235
+ }
236
+
237
+ // Standalone materialization scheduler: opt-in via
238
+ // PUBLISHER_LOCAL_MATERIALIZATION_SCHEDULER (default off, so an orchestrated
239
+ // deployment — whose control plane drives materialization — never runs it). The
240
+ // sweep timer is `unref`'d, so it never keeps the process alive on shutdown.
241
+ startMaterializationSchedulerFromEnv(environmentStore, materializationService);
209
242
  const themeStore = new ThemeStore(environmentStore.storageManager, SERVER_ROOT);
210
243
  const themeController = new ThemeController(themeStore, SERVER_ROOT);
211
244
 
@@ -1319,6 +1352,31 @@ app.post(
1319
1352
  },
1320
1353
  );
1321
1354
 
1355
+ // Environment-scoped aggregate: every materialization across all packages in
1356
+ // the env, newest first. Nested under `/packages` as the collection-level
1357
+ // sibling of the per-package `/packages/:packageName/materializations` list.
1358
+ // MUST stay registered ahead of `/packages/:packageName` below so the literal
1359
+ // `materializations` segment wins the match; consequently `materializations` is
1360
+ // a reserved package name at this position (a package can never be named that).
1361
+ app.get(
1362
+ `${API_PREFIX}/environments/:environmentName/packages/materializations`,
1363
+ async (req, res) => {
1364
+ try {
1365
+ const limit = parseNonNegativeIntParam(req.query.limit);
1366
+ const offset = parseNonNegativeIntParam(req.query.offset);
1367
+ const builds =
1368
+ await materializationController.listEnvironmentMaterializations(
1369
+ req.params.environmentName,
1370
+ { limit, offset },
1371
+ );
1372
+ res.status(200).json(builds);
1373
+ } catch (error) {
1374
+ const { json, status } = internalErrorToHttpError(error as Error);
1375
+ res.status(status).json(json);
1376
+ }
1377
+ },
1378
+ );
1379
+
1322
1380
  app.get(
1323
1381
  `${API_PREFIX}/environments/:environmentName/packages/:packageName`,
1324
1382
  async (req, res) => {
@@ -1636,6 +1694,10 @@ app.post(
1636
1694
  );
1637
1695
 
1638
1696
  // ==================== MATERIALIZATION ROUTES ====================
1697
+ // The environment-scoped aggregate list (every materialization across all
1698
+ // packages) is registered up in the package routes as
1699
+ // `/packages/materializations`, ahead of `/packages/:packageName`, so the
1700
+ // literal wins the match — see that route for the ordering contract.
1639
1701
 
1640
1702
  app.post(
1641
1703
  `${API_PREFIX}/environments/:environmentName/packages/:packageName/materializations`,
@@ -1658,12 +1720,8 @@ app.get(
1658
1720
  `${API_PREFIX}/environments/:environmentName/packages/:packageName/materializations`,
1659
1721
  async (req, res) => {
1660
1722
  try {
1661
- const limit = req.query.limit
1662
- ? parseInt(req.query.limit as string, 10)
1663
- : undefined;
1664
- const offset = req.query.offset
1665
- ? parseInt(req.query.offset as string, 10)
1666
- : undefined;
1723
+ const limit = parseNonNegativeIntParam(req.query.limit);
1724
+ const offset = parseNonNegativeIntParam(req.query.offset);
1667
1725
  const builds = await materializationController.listMaterializations(
1668
1726
  req.params.environmentName,
1669
1727
  req.params.packageName,
@@ -868,15 +868,16 @@ describe("connection integration tests", () => {
868
868
  const envStore = new EnvironmentStore(tempServerRoot);
869
869
  await envStore.finishedInitialization;
870
870
 
871
- // operationalState=serving is the only signal that
872
- // initialize() actually succeeded. initialize swallows
873
- // top-level errors and just calls markNotReady() (see
874
- // environment_store.ts:297-301), so
875
- // finishedInitialization always resolves regardless of
876
- // success. By construction (env_store:288-292), serving
877
- // also implies all configured environments loaded.
871
+ // initialize() swallows top-level errors and just calls
872
+ // markNotReady(), so finishedInitialization always resolves
873
+ // regardless of success; operationalState is what says it
874
+ // succeeded. It does NOT imply every configured environment
875
+ // and package loaded: a load failure is skipped and still
876
+ // reports "serving", which is why loadErrors exists. Assert
877
+ // on both, and on the package list below.
878
878
  const status = await envStore.getStatus();
879
879
  expect(status.operationalState).toBe("serving");
880
+ expect(status.loadErrors).toBeUndefined();
880
881
 
881
882
  const env = await envStore.getEnvironment("malloy-samples");
882
883
  const apiPackages = await env.listPackages();
@@ -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,
@@ -589,6 +605,17 @@ export class Environment {
589
605
  );
590
606
  }
591
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
+
592
619
  public async listPackages(): Promise<ApiPackage[]> {
593
620
  logger.debug("Listing packages", {
594
621
  environmentPath: this.environmentPath,
@@ -613,6 +640,24 @@ export class Environment {
613
640
  );
614
641
  // Directory did not contain a valid package.json file -- therefore, it's not a package.
615
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
+ );
616
661
  return undefined;
617
662
  }
618
663
  }),
@@ -876,6 +921,9 @@ export class Environment {
876
921
  }
877
922
  this.packages.set(packageName, _package);
878
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);
879
927
  logger.debug(`Successfully loaded package ${packageName}`);
880
928
 
881
929
  return _package;
@@ -957,6 +1005,10 @@ export class Environment {
957
1005
  throw error;
958
1006
  }
959
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);
960
1012
  return this.packages.get(packageName);
961
1013
  }
962
1014
 
@@ -1145,6 +1197,8 @@ export class Environment {
1145
1197
 
1146
1198
  this.packages.set(packageName, newPackage);
1147
1199
  this.setPackageStatus(packageName, PackageStatus.SERVING);
1200
+ // Publishing a fixed package clears the boot failure it replaces.
1201
+ this.failedPackages.delete(packageName);
1148
1202
 
1149
1203
  if (oldPackage) {
1150
1204
  this.retireConnectionGeneration(`package ${packageName}`, () =>
@@ -1312,6 +1366,8 @@ export class Environment {
1312
1366
  explores?: string[];
1313
1367
  queryableSources?: "declared" | "all";
1314
1368
  manifestLocation?: string | null;
1369
+ scope?: ApiPackage["scope"];
1370
+ materialization?: ApiPackage["materialization"];
1315
1371
  },
1316
1372
  ): Promise<void> {
1317
1373
  const packagePath = safeJoinUnderRoot(this.environmentPath, packageName);
@@ -1344,6 +1400,10 @@ export class Environment {
1344
1400
  ...(metadata.manifestLocation !== undefined
1345
1401
  ? { manifestLocation: metadata.manifestLocation }
1346
1402
  : {}),
1403
+ ...(metadata.scope !== undefined ? { scope: metadata.scope } : {}),
1404
+ ...(metadata.materialization !== undefined
1405
+ ? { materialization: metadata.materialization }
1406
+ : {}),
1347
1407
  };
1348
1408
 
1349
1409
  // Write back to file
@@ -1396,6 +1456,29 @@ export class Environment {
1396
1456
  body.manifestLocation !== undefined
1397
1457
  ? body.manifestLocation
1398
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;
1399
1482
  _package.setPackageMetadata({
1400
1483
  name: body.name,
1401
1484
  description: body.description,
@@ -1404,25 +1487,27 @@ export class Environment {
1404
1487
  explores,
1405
1488
  queryableSources,
1406
1489
  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,
1490
+ materialization,
1491
+ scope,
1418
1492
  });
1419
1493
 
1420
1494
  // Strict-reject, symmetric with the publish path
1421
1495
  // (package.controller.addPackage): validate the resulting explores
1422
1496
  // against the live model set and restore the prior metadata before
1423
1497
  // rejecting, so a bad update neither persists nor mutates the served
1424
- // surface.
1425
- 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");
1426
1511
  if (invalidMsg) {
1427
1512
  _package.setPackageMetadata(existing);
1428
1513
  throw new BadRequestError(invalidMsg);
@@ -1434,6 +1519,13 @@ export class Environment {
1434
1519
  explores: normalizedExplores,
1435
1520
  queryableSources: body.queryableSources,
1436
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,
1437
1529
  });
1438
1530
 
1439
1531
  // When the body changes manifestLocation, apply it now so the new
@@ -1455,6 +1547,11 @@ export class Environment {
1455
1547
  return this.packageStatuses.get(packageName);
1456
1548
  }
1457
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
+
1458
1555
  public setPackageStatus(packageName: string, status: PackageStatus): void {
1459
1556
  const currentStatus = this.packageStatuses.get(packageName);
1460
1557
  this.packageStatuses.set(packageName, {
@@ -1471,6 +1568,14 @@ export class Environment {
1471
1568
  public async deletePackage(packageName: string): Promise<void> {
1472
1569
  assertSafePackageName(packageName);
1473
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
+
1474
1579
  const _package = this.packages.get(packageName);
1475
1580
  if (!_package) {
1476
1581
  return;