@malloy-publisher/server 0.0.219 → 0.0.221

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 (69) hide show
  1. package/dist/app/api-doc.yaml +291 -20
  2. package/dist/app/assets/{EnvironmentPage-gehnjfC6.js → EnvironmentPage-D6G5n6mY.js} +1 -1
  3. package/dist/app/assets/{HomePage-8LQBytE4.js → HomePage-DgZluD8u.js} +1 -1
  4. package/dist/app/assets/LightMode-w9v0pEbg.js +1 -0
  5. package/dist/app/assets/MainPage-Bi_Tukvr.js +2 -0
  6. package/dist/app/assets/{MaterializationsPage-D7P1Kp6O.js → MaterializationsPage-BVERjnmG.js} +1 -1
  7. package/dist/app/assets/ModelPage-2OjS259-.js +1 -0
  8. package/dist/app/assets/{PackagePage-Cz9fVwSG.js → PackagePage-C8L2On4z.js} +1 -1
  9. package/dist/app/assets/{RouteError-UONCloyN.js → RouteError-DJoNC_Vl.js} +1 -1
  10. package/dist/app/assets/ThemeEditorPage-B-hJ1zgd.js +1 -0
  11. package/dist/app/assets/{WorkbookPage-Bhzqvbq_.js → WorkbookPage-CTbDgGeJ.js} +1 -1
  12. package/dist/app/assets/{core-BiGj7BML.es-kMHAa8tP.js → core-D9Hl0IDY.es-CmgzHn4c.js} +1 -1
  13. package/dist/app/assets/github-dark-DenFmJkN.es-DHJKELXO.js +1 -0
  14. package/dist/app/assets/index-8E2uLeV9.js +2541 -0
  15. package/dist/app/assets/index-BEjJCJjX.js +1761 -0
  16. package/dist/app/assets/index-BF8PkFm8.js +1 -0
  17. package/dist/app/assets/index-Cr-asgoV.js +527 -0
  18. package/dist/app/assets/index-DcCw_qxr.js +18 -0
  19. package/dist/app/assets/index-w_0OQJgZ.js +23 -0
  20. package/dist/app/index.html +1 -1
  21. package/dist/package_load_worker.mjs +20 -2
  22. package/dist/runtime/publisher.js +6 -1
  23. package/dist/server.mjs +412 -55
  24. package/dist/sshcrypto-8m50vnmb.node +0 -0
  25. package/package.json +12 -12
  26. package/src/config.theme.spec.ts +178 -0
  27. package/src/config.ts +179 -0
  28. package/src/controller/materialization.controller.spec.ts +4 -4
  29. package/src/controller/materialization.controller.ts +2 -2
  30. package/src/controller/package.controller.spec.ts +89 -1
  31. package/src/controller/package.controller.ts +28 -3
  32. package/src/controller/theme.controller.ts +83 -0
  33. package/src/dto/connection.dto.ts +6 -0
  34. package/src/materialization_metrics.ts +1 -1
  35. package/src/package_load/package_load_pool.ts +2 -1
  36. package/src/package_load/package_load_worker.ts +5 -2
  37. package/src/package_load/protocol.ts +2 -1
  38. package/src/runtime/publisher.js +6 -1
  39. package/src/server.ts +58 -1
  40. package/src/service/build_plan.spec.ts +61 -17
  41. package/src/service/build_plan.ts +35 -11
  42. package/src/service/connection.ts +52 -6
  43. package/src/service/connection_fingerprint.spec.ts +102 -0
  44. package/src/service/manifest_loader.spec.ts +5 -5
  45. package/src/service/manifest_loader.ts +4 -4
  46. package/src/service/materialization_cron_gate.spec.ts +137 -0
  47. package/src/service/materialization_schedule_surface.spec.ts +30 -1
  48. package/src/service/materialization_service.spec.ts +47 -37
  49. package/src/service/materialization_service.ts +59 -50
  50. package/src/service/materialization_test_fixtures.ts +22 -6
  51. package/src/service/model.ts +1 -1
  52. package/src/service/package.ts +50 -4
  53. package/src/service/package_manifest.spec.ts +42 -4
  54. package/src/service/package_manifest.ts +53 -4
  55. package/src/service/theme_store.ts +199 -0
  56. package/src/storage/DatabaseInterface.ts +1 -1
  57. package/src/storage/StorageManager.ts +17 -0
  58. package/src/storage/duckdb/schema.ts +27 -6
  59. package/src/theme_key_parity.spec.ts +57 -0
  60. package/tests/integration/materialization/manifest_binding.integration.spec.ts +1 -1
  61. package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +5 -5
  62. package/tests/unit/controller/theme.controller.spec.ts +141 -0
  63. package/tests/unit/service/theme_store.spec.ts +139 -0
  64. package/dist/app/assets/MainPage-DDaZLJw-.js +0 -2
  65. package/dist/app/assets/ModelPage-Do_vhxOc.js +0 -1
  66. package/dist/app/assets/index-VzRbxcF7.js +0 -455
  67. package/dist/app/assets/index-ddq4-5hu.js +0 -1760
  68. package/dist/app/assets/index-qOQF9CXq.js +0 -40
  69. package/dist/app/assets/index.umd-B2kmxDh7.js +0 -2467
Binary file
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@malloy-publisher/server",
3
3
  "description": "Malloy Publisher Server",
4
- "version": "0.0.219",
4
+ "version": "0.0.221",
5
5
  "main": "dist/server.mjs",
6
6
  "bin": {
7
7
  "malloy-publisher": "dist/server.mjs"
@@ -36,17 +36,17 @@
36
36
  "@azure/storage-blob": "^12.26.0",
37
37
  "@duckdb/node-api": "1.5.3-r.2",
38
38
  "@google-cloud/storage": "^7.16.0",
39
- "@malloydata/db-bigquery": "^0.0.415",
40
- "@malloydata/db-databricks": "^0.0.415",
41
- "@malloydata/db-duckdb": "^0.0.415",
42
- "@malloydata/db-mysql": "^0.0.415",
43
- "@malloydata/db-postgres": "^0.0.415",
44
- "@malloydata/db-publisher": "^0.0.415",
45
- "@malloydata/db-snowflake": "^0.0.415",
46
- "@malloydata/db-trino": "^0.0.415",
47
- "@malloydata/malloy": "^0.0.415",
48
- "@malloydata/malloy-sql": "^0.0.415",
49
- "@malloydata/render-validator": "^0.0.415",
39
+ "@malloydata/db-bigquery": "^0.0.421",
40
+ "@malloydata/db-databricks": "^0.0.421",
41
+ "@malloydata/db-duckdb": "^0.0.421",
42
+ "@malloydata/db-mysql": "^0.0.421",
43
+ "@malloydata/db-postgres": "^0.0.421",
44
+ "@malloydata/db-publisher": "^0.0.421",
45
+ "@malloydata/db-snowflake": "^0.0.421",
46
+ "@malloydata/db-trino": "^0.0.421",
47
+ "@malloydata/malloy": "^0.0.421",
48
+ "@malloydata/malloy-sql": "^0.0.421",
49
+ "@malloydata/render-validator": "^0.0.421",
50
50
  "@modelcontextprotocol/sdk": "^1.13.2",
51
51
  "@opentelemetry/api": "^1.9.0",
52
52
  "@opentelemetry/auto-instrumentations-node": "^0.57.0",
@@ -0,0 +1,178 @@
1
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
2
+ import fs from "fs";
3
+ import path from "path";
4
+ import {
5
+ getInstanceTheme,
6
+ getProcessedPublisherConfig,
7
+ mergeThemes,
8
+ type Theme,
9
+ } from "./config";
10
+ import { PUBLISHER_CONFIG_NAME } from "./constants";
11
+
12
+ const TEST_ROOT = path.join(process.cwd(), "test-temp-theme-config");
13
+ const CONFIG_PATH = path.join(TEST_ROOT, PUBLISHER_CONFIG_NAME);
14
+
15
+ function writeConfig(json: object) {
16
+ if (!fs.existsSync(TEST_ROOT)) {
17
+ fs.mkdirSync(TEST_ROOT, { recursive: true });
18
+ }
19
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(json));
20
+ }
21
+
22
+ describe("Theme cascade in publisher.config.json", () => {
23
+ beforeEach(() => {
24
+ if (fs.existsSync(CONFIG_PATH)) fs.unlinkSync(CONFIG_PATH);
25
+ });
26
+
27
+ afterEach(() => {
28
+ if (fs.existsSync(CONFIG_PATH)) fs.unlinkSync(CONFIG_PATH);
29
+ if (fs.existsSync(TEST_ROOT)) {
30
+ fs.rmdirSync(TEST_ROOT, { recursive: true });
31
+ }
32
+ });
33
+
34
+ it("returns no theme when none is set", () => {
35
+ writeConfig({
36
+ frozenConfig: false,
37
+ environments: [
38
+ {
39
+ name: "default",
40
+ packages: [{ name: "pkg", location: "/tmp/pkg" }],
41
+ },
42
+ ],
43
+ });
44
+ const cfg = getProcessedPublisherConfig(TEST_ROOT);
45
+ expect(cfg.theme).toBeUndefined();
46
+ expect(cfg.environments[0].theme).toBeUndefined();
47
+ });
48
+
49
+ it("exposes the instance theme on the top-level config", () => {
50
+ writeConfig({
51
+ frozenConfig: false,
52
+ theme: {
53
+ defaultMode: "dark",
54
+ palette: { series: ["#ff0080"] },
55
+ },
56
+ environments: [
57
+ {
58
+ name: "default",
59
+ packages: [{ name: "pkg", location: "/tmp/pkg" }],
60
+ },
61
+ ],
62
+ });
63
+ const cfg = getProcessedPublisherConfig(TEST_ROOT);
64
+ expect(cfg.theme?.defaultMode).toBe("dark");
65
+ expect(cfg.theme?.palette?.series).toEqual(["#ff0080"]);
66
+ expect(cfg.environments[0].theme?.palette?.series).toEqual(["#ff0080"]);
67
+ });
68
+
69
+ it("environment theme overrides instance per key", () => {
70
+ writeConfig({
71
+ frozenConfig: false,
72
+ theme: {
73
+ defaultMode: "light",
74
+ palette: {
75
+ series: ["#aaa"],
76
+ background: { light: "#fff" },
77
+ },
78
+ font: { family: "Inter" },
79
+ },
80
+ environments: [
81
+ {
82
+ name: "marketing",
83
+ packages: [{ name: "pkg", location: "/tmp/pkg" }],
84
+ theme: {
85
+ palette: { series: ["#cc0000"] },
86
+ },
87
+ },
88
+ ],
89
+ });
90
+ const cfg = getProcessedPublisherConfig(TEST_ROOT);
91
+ expect(cfg.environments[0].theme?.palette?.series).toEqual(["#cc0000"]);
92
+ // Font and background fall through from instance default.
93
+ expect(cfg.environments[0].theme?.font?.family).toBe("Inter");
94
+ expect(cfg.environments[0].theme?.palette?.background?.light).toBe(
95
+ "#fff",
96
+ );
97
+ });
98
+
99
+ it("drops invalid defaultMode values without losing the rest", () => {
100
+ writeConfig({
101
+ frozenConfig: false,
102
+ theme: {
103
+ defaultMode: "nonsense",
104
+ palette: { series: ["#a"] },
105
+ },
106
+ environments: [
107
+ {
108
+ name: "default",
109
+ packages: [{ name: "pkg", location: "/tmp/pkg" }],
110
+ },
111
+ ],
112
+ });
113
+ const cfg = getProcessedPublisherConfig(TEST_ROOT);
114
+ expect(cfg.theme?.defaultMode).toBeUndefined();
115
+ expect(cfg.theme?.palette?.series).toEqual(["#a"]);
116
+ });
117
+
118
+ it("getInstanceTheme returns the same instance theme", () => {
119
+ writeConfig({
120
+ frozenConfig: false,
121
+ theme: { palette: { series: ["#abc"] } },
122
+ environments: [],
123
+ });
124
+ const t = getInstanceTheme(TEST_ROOT);
125
+ expect(t?.palette?.series).toEqual(["#abc"]);
126
+ });
127
+ });
128
+
129
+ describe("mergeThemes", () => {
130
+ it("returns undefined when both sides are missing", () => {
131
+ expect(mergeThemes(undefined, undefined)).toBeUndefined();
132
+ });
133
+
134
+ it("returns the override when base is missing", () => {
135
+ const o: Theme = { palette: { series: ["#x"] } };
136
+ expect(mergeThemes(undefined, o)).toEqual(o);
137
+ });
138
+
139
+ it("merges background sub-object per mode", () => {
140
+ const base: Theme = {
141
+ palette: { background: { light: "#fff", dark: "#000" } },
142
+ };
143
+ const o: Theme = {
144
+ palette: { background: { dark: "#111" } },
145
+ };
146
+ const merged = mergeThemes(base, o);
147
+ expect(merged?.palette?.background?.light).toBe("#fff");
148
+ expect(merged?.palette?.background?.dark).toBe("#111");
149
+ });
150
+
151
+ it("merges all per-mode color keys, not just background and tableHeader", () => {
152
+ // Regression: mergeThemes used to ignore tableBody / tile /
153
+ // tileTitle, so an environment that overrode one of those keys
154
+ // dropped the instance-level default for the same key.
155
+ const base: Theme = {
156
+ palette: {
157
+ tableBody: { light: "#727883" },
158
+ tile: { light: "#f5fafc", dark: "#0f172a" },
159
+ tileTitle: { light: "#5d626b" },
160
+ },
161
+ };
162
+ const o: Theme = {
163
+ palette: { tile: { dark: "#000000" } },
164
+ };
165
+ const merged = mergeThemes(base, o);
166
+ expect(merged?.palette?.tableBody?.light).toBe("#727883");
167
+ expect(merged?.palette?.tile?.light).toBe("#f5fafc");
168
+ expect(merged?.palette?.tile?.dark).toBe("#000000");
169
+ expect(merged?.palette?.tileTitle?.light).toBe("#5d626b");
170
+ });
171
+
172
+ it("override series replaces the array wholesale", () => {
173
+ const base: Theme = { palette: { series: ["#1", "#2", "#3"] } };
174
+ const o: Theme = { palette: { series: ["#a"] } };
175
+ const merged = mergeThemes(base, o);
176
+ expect(merged?.palette?.series).toEqual(["#a"]);
177
+ });
178
+ });
package/src/config.ts CHANGED
@@ -74,6 +74,25 @@ function resolvePublisherConfigPath(serverRoot: string): {
74
74
  type FilesystemPath = `./${string}` | `../${string}` | `/${string}`;
75
75
  type GcsPath = `gs://${string}`;
76
76
  type ApiConnection = components["schemas"]["Connection"];
77
+ export type Theme = components["schemas"]["Theme"];
78
+
79
+ /**
80
+ * Palette colour keys that have separate light/dark variants. Hand-copied
81
+ * from `packages/sdk/src/theme/keys.ts`: the server and the SDK are
82
+ * intentionally decoupled (neither imports the other; their only shared
83
+ * contract is api-doc.yaml), so the list lives in both. The parity test
84
+ * `theme_key_parity.spec.ts` fails if the two copies or the api-doc
85
+ * Theme.palette schema drift apart. Exported so that test can import it.
86
+ */
87
+ export const PER_MODE_COLOR_KEYS = [
88
+ "background",
89
+ "tableHeader",
90
+ "tableHeaderBackground",
91
+ "tableBody",
92
+ "tile",
93
+ "tileTitle",
94
+ "mapColor",
95
+ ] as const;
77
96
 
78
97
  export type Package = {
79
98
  name: string;
@@ -87,23 +106,27 @@ export type Connection = {
87
106
 
88
107
  export type Environment = {
89
108
  name: string;
109
+ theme?: Theme;
90
110
  packages: Package[];
91
111
  connections?: Connection[];
92
112
  };
93
113
 
94
114
  export type PublisherConfig = {
95
115
  frozenConfig: boolean;
116
+ theme?: Theme;
96
117
  environments: Environment[];
97
118
  };
98
119
 
99
120
  export type ProcessedEnvironment = {
100
121
  name: string;
122
+ theme?: Theme;
101
123
  packages: Package[];
102
124
  connections: ApiConnection[];
103
125
  };
104
126
 
105
127
  export type ProcessedPublisherConfig = {
106
128
  frozenConfig: boolean;
129
+ theme?: Theme;
107
130
  environments: ProcessedEnvironment[];
108
131
  };
109
132
 
@@ -488,12 +511,138 @@ export const getPublisherConfig = (serverRoot: string): PublisherConfig => {
488
511
  );
489
512
  }
490
513
 
514
+ const instanceTheme = sanitizeTheme(
515
+ processedConfig &&
516
+ typeof processedConfig === "object" &&
517
+ "theme" in processedConfig
518
+ ? (processedConfig as { theme: unknown }).theme
519
+ : undefined,
520
+ "publisher.config.json",
521
+ );
522
+
491
523
  return {
492
524
  frozenConfig,
525
+ ...(instanceTheme ? { theme: instanceTheme } : {}),
493
526
  environments,
494
527
  } as PublisherConfig;
495
528
  };
496
529
 
530
+ /**
531
+ * Sanitize a raw theme value pulled from JSON. Returns a Theme on success
532
+ * or `undefined` if the input is missing/invalid. Bad shapes log a warning
533
+ * and are dropped rather than failing the whole config; an unthemed config
534
+ * still boots fine.
535
+ */
536
+ export function sanitizeTheme(
537
+ raw: unknown,
538
+ context: string,
539
+ ): Theme | undefined {
540
+ if (raw === undefined || raw === null) return undefined;
541
+ if (typeof raw !== "object" || Array.isArray(raw)) {
542
+ logger.warn(
543
+ `Invalid "theme" in ${context}: expected an object. Ignoring.`,
544
+ );
545
+ return undefined;
546
+ }
547
+ const obj = raw as Record<string, unknown>;
548
+ const theme: Theme = {};
549
+
550
+ if ("defaultMode" in obj) {
551
+ const mode = obj.defaultMode;
552
+ if (mode === "light" || mode === "dark" || mode === "auto") {
553
+ theme.defaultMode = mode;
554
+ } else {
555
+ logger.warn(
556
+ `Invalid "theme.defaultMode" in ${context}: expected "light" | "dark" | "auto" (got ${JSON.stringify(mode)}). Ignoring field.`,
557
+ );
558
+ }
559
+ }
560
+ if ("allowUserToggle" in obj) {
561
+ const value = obj.allowUserToggle;
562
+ if (typeof value === "boolean") {
563
+ theme.allowUserToggle = value;
564
+ } else {
565
+ // Don't coerce: Boolean("false") is true, so a stray string would
566
+ // silently invert the operator's intent. Match defaultMode above
567
+ // and warn + ignore instead.
568
+ logger.warn(
569
+ `Invalid "theme.allowUserToggle" in ${context}: expected a boolean (got ${JSON.stringify(value)}). Ignoring field.`,
570
+ );
571
+ }
572
+ }
573
+ if ("palette" in obj && obj.palette && typeof obj.palette === "object") {
574
+ const palette = obj.palette as Record<string, unknown>;
575
+ const sanitized: NonNullable<Theme["palette"]> = {};
576
+ if (Array.isArray(palette.series)) {
577
+ // Preserve an explicit empty array as a clear-the-palette
578
+ // signal; resolveTheme treats [] as a real override.
579
+ sanitized.series = palette.series.filter(
580
+ (c): c is string => typeof c === "string",
581
+ );
582
+ }
583
+ // Per-mode colour keys share the same shape: { light?: string, dark?: string }.
584
+ // Sanitize uniformly so adding a new key only touches PER_MODE_COLOR_KEYS.
585
+ for (const key of PER_MODE_COLOR_KEYS) {
586
+ const raw = palette[key];
587
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) continue;
588
+ const r = raw as Record<string, unknown>;
589
+ const out: { light?: string; dark?: string } = {};
590
+ if (typeof r.light === "string") out.light = r.light;
591
+ if (typeof r.dark === "string") out.dark = r.dark;
592
+ if (Object.keys(out).length > 0) {
593
+ (sanitized as Record<string, unknown>)[key] = out;
594
+ }
595
+ }
596
+ if (Object.keys(sanitized).length > 0) theme.palette = sanitized;
597
+ }
598
+ if ("font" in obj && obj.font && typeof obj.font === "object") {
599
+ const font = obj.font as Record<string, unknown>;
600
+ const sanitized: NonNullable<Theme["font"]> = {};
601
+ if (typeof font.family === "string") sanitized.family = font.family;
602
+ if (typeof font.size === "number" && Number.isFinite(font.size)) {
603
+ sanitized.size = font.size;
604
+ }
605
+ if (Object.keys(sanitized).length > 0) theme.font = sanitized;
606
+ }
607
+
608
+ return Object.keys(theme).length > 0 ? theme : undefined;
609
+ }
610
+
611
+ /**
612
+ * Merge an environment-level theme on top of the instance default. Both
613
+ * inputs are already sanitized. The override wins per key at every level;
614
+ * absent keys fall through to the base. Returns `undefined` only when both
615
+ * sides are absent.
616
+ *
617
+ * Per-mode colour objects merge per-mode so an environment that sets
618
+ * only `palette.tile.dark` keeps the instance's `palette.tile.light`.
619
+ */
620
+ export function mergeThemes(
621
+ base: Theme | undefined,
622
+ override: Theme | undefined,
623
+ ): Theme | undefined {
624
+ if (!base) return override;
625
+ if (!override) return base;
626
+ const merged: Theme = { ...base, ...override };
627
+ if (base.palette || override.palette) {
628
+ merged.palette = {
629
+ ...(base.palette ?? {}),
630
+ ...(override.palette ?? {}),
631
+ };
632
+ for (const key of PER_MODE_COLOR_KEYS) {
633
+ const b = base.palette?.[key];
634
+ const o = override.palette?.[key];
635
+ if (b || o) {
636
+ merged.palette[key] = { ...(b ?? {}), ...(o ?? {}) };
637
+ }
638
+ }
639
+ }
640
+ if (base.font || override.font) {
641
+ merged.font = { ...(base.font ?? {}), ...(override.font ?? {}) };
642
+ }
643
+ return merged;
644
+ }
645
+
497
646
  export const isPublisherConfigFrozen = (serverRoot: string) => {
498
647
  try {
499
648
  const publisherConfig = getPublisherConfig(serverRoot);
@@ -637,17 +786,47 @@ export const getProcessedPublisherConfig = (
637
786
  continue;
638
787
  }
639
788
 
789
+ // Per-environment theme override: computed here (the instance default
790
+ // merged with this environment's theme) but NOT yet wired through.
791
+ // addEnvironment() drops this field, so the live Environment never
792
+ // carries it and no viewer applies it today; only the instance-level
793
+ // theme is applied. Kept for the planned per-environment follow-up.
794
+ const envTheme = sanitizeTheme(
795
+ (environment as { theme?: unknown }).theme,
796
+ `environment "${environment.name}"`,
797
+ );
798
+ const resolvedTheme = mergeThemes(rawConfig.theme, envTheme);
799
+
640
800
  validEnvironments.push({
641
801
  name: environment.name,
642
802
  packages: validPackages,
643
803
  connections: convertConnectionsToApiConnections(
644
804
  environment.connections || [],
645
805
  ),
806
+ ...(resolvedTheme ? { theme: resolvedTheme } : {}),
646
807
  });
647
808
  }
648
809
 
649
810
  return {
650
811
  frozenConfig: rawConfig.frozenConfig ?? false,
812
+ ...(rawConfig.theme ? { theme: rawConfig.theme } : {}),
651
813
  environments: validEnvironments,
652
814
  };
653
815
  };
816
+
817
+ /**
818
+ * Convenience accessor for the instance-wide default theme. Used by
819
+ * ServerStatus so the app shell can apply the operator's chosen theme
820
+ * before the viewer has navigated into any specific environment.
821
+ */
822
+ export const getInstanceTheme = (serverRoot: string): Theme | undefined => {
823
+ try {
824
+ return getPublisherConfig(serverRoot).theme;
825
+ } catch (error) {
826
+ logger.error(
827
+ `Error reading instance theme from ${PUBLISHER_CONFIG_NAME}`,
828
+ { error },
829
+ );
830
+ return undefined;
831
+ }
832
+ };
@@ -59,7 +59,7 @@ describe("MaterializationController.createMaterialization validation", () => {
59
59
  buildInstructions: {
60
60
  sources: [
61
61
  {
62
- buildId: "b1",
62
+ sourceEntityId: "b1",
63
63
  sourceID: "orders@m",
64
64
  materializedTableId: "mt-1",
65
65
  physicalTableName: "orders_v1",
@@ -71,7 +71,7 @@ describe("MaterializationController.createMaterialization validation", () => {
71
71
  expect(parsed).toEqual({
72
72
  buildInstructions: [
73
73
  {
74
- buildId: "b1",
74
+ sourceEntityId: "b1",
75
75
  sourceID: "orders@m",
76
76
  materializedTableId: "mt-1",
77
77
  physicalTableName: "orders_v1",
@@ -99,7 +99,7 @@ describe("MaterializationController.createMaterialization validation", () => {
99
99
  await expect(
100
100
  controller.createMaterialization("env", "pkg", {
101
101
  buildInstructions: {
102
- sources: [{ buildId: "b1", materializedTableId: "mt-1" }],
102
+ sources: [{ sourceEntityId: "b1", materializedTableId: "mt-1" }],
103
103
  },
104
104
  }),
105
105
  ).rejects.toThrow(BadRequestError);
@@ -112,7 +112,7 @@ describe("MaterializationController.createMaterialization validation", () => {
112
112
  buildInstructions: {
113
113
  sources: [
114
114
  {
115
- buildId: "b1",
115
+ sourceEntityId: "b1",
116
116
  materializedTableId: "mt-1",
117
117
  physicalTableName: "orders_v1",
118
118
  realization: "MERGE",
@@ -81,7 +81,7 @@ export class MaterializationController {
81
81
  }
82
82
  const instruction = raw as Record<string, unknown>;
83
83
  const required = [
84
- "buildId",
84
+ "sourceEntityId",
85
85
  "materializedTableId",
86
86
  "physicalTableName",
87
87
  "realization",
@@ -102,7 +102,7 @@ export class MaterializationController {
102
102
  );
103
103
  }
104
104
  return {
105
- buildId: instruction.buildId as string,
105
+ sourceEntityId: instruction.sourceEntityId as string,
106
106
  sourceID:
107
107
  typeof instruction.sourceID === "string"
108
108
  ? instruction.sourceID
@@ -18,6 +18,7 @@ describe("PackageController.addPackage explores validation", () => {
18
18
  "Invalid explores entry 'missing.malloy' in publisher.json: file not found";
19
19
  const mockPackage = {
20
20
  formatInvalidExplores: () => invalidMsg,
21
+ formatInvalidSchedule: () => "",
21
22
  };
22
23
  const unloadPackage = sinon.stub().resolves(undefined);
23
24
  const deletePackage = sinon.stub().resolves(undefined);
@@ -52,7 +53,10 @@ describe("PackageController.addPackage explores validation", () => {
52
53
  // controller passes a validator and does NOT call delete/unload itself.
53
54
  const invalidMsg =
54
55
  "Invalid explores entry 'missing.malloy' in publisher.json: file not found";
55
- const mockPackage = { formatInvalidExplores: () => invalidMsg };
56
+ const mockPackage = {
57
+ formatInvalidExplores: () => invalidMsg,
58
+ formatInvalidSchedule: () => "",
59
+ };
56
60
  // installPackage mimics the real contract: invoke the validator and, if it
57
61
  // returns a message, throw BadRequestError (after its internal rollback).
58
62
  const installPackage = sinon
@@ -100,6 +104,7 @@ describe("PackageController.addPackage explores validation", () => {
100
104
  it("persists when explores are valid (no-location)", async () => {
101
105
  const mockPackage = {
102
106
  formatInvalidExplores: () => "",
107
+ formatInvalidSchedule: () => "",
103
108
  };
104
109
  const addPackage = sinon.stub().resolves(mockPackage);
105
110
  const getEnvironment = sinon.stub().resolves({ addPackage });
@@ -122,6 +127,88 @@ describe("PackageController.addPackage explores validation", () => {
122
127
  });
123
128
  });
124
129
 
130
+ describe("PackageController.addPackage cron schedule validation", () => {
131
+ afterEach(() => {
132
+ sinon.restore();
133
+ });
134
+
135
+ it("rejects a publish whose cron fails the private-only gate (no-location path)", async () => {
136
+ // Valid explores but an invalid materialization cron: the publish must
137
+ // still 400 (strict-at-publish, same split as explores — load merely
138
+ // warns) and roll back via unloadPackage.
139
+ const cronMsg =
140
+ "materialization.schedule (cron) in publisher.json requires every " +
141
+ "persist source to declare '#@ persist ... sharing=private'; source " +
142
+ "'orders' resolves to unset.";
143
+ const mockPackage = {
144
+ formatInvalidExplores: () => "",
145
+ formatInvalidSchedule: () => cronMsg,
146
+ };
147
+ const unloadPackage = sinon.stub().resolves(undefined);
148
+ const addPackage = sinon.stub().resolves(mockPackage);
149
+ const environment = { addPackage, unloadPackage };
150
+ const getEnvironment = sinon.stub().resolves(environment);
151
+ const addPackageToDatabase = sinon.stub().resolves(undefined);
152
+ const environmentStore = {
153
+ publisherConfigIsFrozen: false,
154
+ getEnvironment,
155
+ addPackageToDatabase,
156
+ } as unknown as EnvironmentStore;
157
+
158
+ const controller = new PackageController(environmentStore);
159
+
160
+ await expect(
161
+ controller.addPackage("env", { name: "pkg", description: "test" }),
162
+ ).rejects.toThrow(cronMsg);
163
+
164
+ expect(unloadPackage.calledOnceWith("pkg")).toBe(true);
165
+ expect(addPackageToDatabase.called).toBe(false);
166
+ });
167
+
168
+ it("location path: the cron gate runs inside installPackage's rollback window", async () => {
169
+ const cronMsg =
170
+ "materialization.schedule (cron) in publisher.json requires every " +
171
+ "persist source to declare '#@ persist ... sharing=private'.";
172
+ const mockPackage = {
173
+ formatInvalidExplores: () => "",
174
+ formatInvalidSchedule: () => cronMsg,
175
+ };
176
+ const installPackage = sinon
177
+ .stub()
178
+ .callsFake(
179
+ async (
180
+ _name: string,
181
+ _downloader: unknown,
182
+ validate?: (pkg: unknown) => string | undefined,
183
+ ) => {
184
+ const msg = validate?.(mockPackage);
185
+ if (msg) throw new BadRequestError(msg);
186
+ return mockPackage;
187
+ },
188
+ );
189
+ const environment = { installPackage };
190
+ const getEnvironment = sinon.stub().resolves(environment);
191
+ const addPackageToDatabase = sinon.stub().resolves(undefined);
192
+ const environmentStore = {
193
+ publisherConfigIsFrozen: false,
194
+ getEnvironment,
195
+ addPackageToDatabase,
196
+ } as unknown as EnvironmentStore;
197
+
198
+ const controller = new PackageController(environmentStore);
199
+
200
+ await expect(
201
+ controller.addPackage("env", {
202
+ name: "pkg",
203
+ description: "test",
204
+ location: "gs://bucket/pkg.zip",
205
+ }),
206
+ ).rejects.toThrow(cronMsg);
207
+
208
+ expect(addPackageToDatabase.called).toBe(false);
209
+ });
210
+ });
211
+
125
212
  describe("PackageController.updatePackage explores validation", () => {
126
213
  afterEach(() => {
127
214
  sinon.restore();
@@ -138,6 +225,7 @@ describe("PackageController.updatePackage explores validation", () => {
138
225
  const mockPackage = {
139
226
  formatInvalidExplores: (override?: string[]) =>
140
227
  override?.includes("nope.malloy") ? invalidMsg : "",
228
+ formatInvalidSchedule: () => "",
141
229
  };
142
230
  const installPackage = sinon
143
231
  .stub()