@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.
- package/dist/app/api-doc.yaml +291 -20
- package/dist/app/assets/{EnvironmentPage-gehnjfC6.js → EnvironmentPage-D6G5n6mY.js} +1 -1
- package/dist/app/assets/{HomePage-8LQBytE4.js → HomePage-DgZluD8u.js} +1 -1
- package/dist/app/assets/LightMode-w9v0pEbg.js +1 -0
- package/dist/app/assets/MainPage-Bi_Tukvr.js +2 -0
- package/dist/app/assets/{MaterializationsPage-D7P1Kp6O.js → MaterializationsPage-BVERjnmG.js} +1 -1
- package/dist/app/assets/ModelPage-2OjS259-.js +1 -0
- package/dist/app/assets/{PackagePage-Cz9fVwSG.js → PackagePage-C8L2On4z.js} +1 -1
- package/dist/app/assets/{RouteError-UONCloyN.js → RouteError-DJoNC_Vl.js} +1 -1
- package/dist/app/assets/ThemeEditorPage-B-hJ1zgd.js +1 -0
- package/dist/app/assets/{WorkbookPage-Bhzqvbq_.js → WorkbookPage-CTbDgGeJ.js} +1 -1
- package/dist/app/assets/{core-BiGj7BML.es-kMHAa8tP.js → core-D9Hl0IDY.es-CmgzHn4c.js} +1 -1
- package/dist/app/assets/github-dark-DenFmJkN.es-DHJKELXO.js +1 -0
- package/dist/app/assets/index-8E2uLeV9.js +2541 -0
- package/dist/app/assets/index-BEjJCJjX.js +1761 -0
- package/dist/app/assets/index-BF8PkFm8.js +1 -0
- package/dist/app/assets/index-Cr-asgoV.js +527 -0
- package/dist/app/assets/index-DcCw_qxr.js +18 -0
- package/dist/app/assets/index-w_0OQJgZ.js +23 -0
- package/dist/app/index.html +1 -1
- package/dist/package_load_worker.mjs +20 -2
- package/dist/runtime/publisher.js +6 -1
- package/dist/server.mjs +412 -55
- package/dist/sshcrypto-8m50vnmb.node +0 -0
- package/package.json +12 -12
- package/src/config.theme.spec.ts +178 -0
- package/src/config.ts +179 -0
- package/src/controller/materialization.controller.spec.ts +4 -4
- package/src/controller/materialization.controller.ts +2 -2
- package/src/controller/package.controller.spec.ts +89 -1
- package/src/controller/package.controller.ts +28 -3
- package/src/controller/theme.controller.ts +83 -0
- package/src/dto/connection.dto.ts +6 -0
- package/src/materialization_metrics.ts +1 -1
- package/src/package_load/package_load_pool.ts +2 -1
- package/src/package_load/package_load_worker.ts +5 -2
- package/src/package_load/protocol.ts +2 -1
- package/src/runtime/publisher.js +6 -1
- package/src/server.ts +58 -1
- package/src/service/build_plan.spec.ts +61 -17
- package/src/service/build_plan.ts +35 -11
- package/src/service/connection.ts +52 -6
- package/src/service/connection_fingerprint.spec.ts +102 -0
- package/src/service/manifest_loader.spec.ts +5 -5
- package/src/service/manifest_loader.ts +4 -4
- package/src/service/materialization_cron_gate.spec.ts +137 -0
- package/src/service/materialization_schedule_surface.spec.ts +30 -1
- package/src/service/materialization_service.spec.ts +47 -37
- package/src/service/materialization_service.ts +59 -50
- package/src/service/materialization_test_fixtures.ts +22 -6
- package/src/service/model.ts +1 -1
- package/src/service/package.ts +50 -4
- package/src/service/package_manifest.spec.ts +42 -4
- package/src/service/package_manifest.ts +53 -4
- package/src/service/theme_store.ts +199 -0
- package/src/storage/DatabaseInterface.ts +1 -1
- package/src/storage/StorageManager.ts +17 -0
- package/src/storage/duckdb/schema.ts +27 -6
- package/src/theme_key_parity.spec.ts +57 -0
- package/tests/integration/materialization/manifest_binding.integration.spec.ts +1 -1
- package/tests/integration/materialization/materialization_lifecycle.integration.spec.ts +5 -5
- package/tests/unit/controller/theme.controller.spec.ts +141 -0
- package/tests/unit/service/theme_store.spec.ts +139 -0
- package/dist/app/assets/MainPage-DDaZLJw-.js +0 -2
- package/dist/app/assets/ModelPage-Do_vhxOc.js +0 -1
- package/dist/app/assets/index-VzRbxcF7.js +0 -455
- package/dist/app/assets/index-ddq4-5hu.js +0 -1760
- package/dist/app/assets/index-qOQF9CXq.js +0 -40
- 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.
|
|
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.
|
|
40
|
-
"@malloydata/db-databricks": "^0.0.
|
|
41
|
-
"@malloydata/db-duckdb": "^0.0.
|
|
42
|
-
"@malloydata/db-mysql": "^0.0.
|
|
43
|
-
"@malloydata/db-postgres": "^0.0.
|
|
44
|
-
"@malloydata/db-publisher": "^0.0.
|
|
45
|
-
"@malloydata/db-snowflake": "^0.0.
|
|
46
|
-
"@malloydata/db-trino": "^0.0.
|
|
47
|
-
"@malloydata/malloy": "^0.0.
|
|
48
|
-
"@malloydata/malloy-sql": "^0.0.
|
|
49
|
-
"@malloydata/render-validator": "^0.0.
|
|
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
|
-
|
|
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
|
-
|
|
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: [{
|
|
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
|
-
|
|
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
|
-
"
|
|
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
|
-
|
|
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 = {
|
|
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()
|