@malloy-publisher/server 0.0.219 → 0.0.220
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 +240 -19
- package/dist/app/assets/{EnvironmentPage-gehnjfC6.js → EnvironmentPage-BqLiaatL.js} +1 -1
- package/dist/app/assets/{HomePage-8LQBytE4.js → HomePage-G_xpt9XE.js} +1 -1
- package/dist/app/assets/LightMode-s1PDRIsF.js +1 -0
- package/dist/app/assets/MainPage-C6TfpC92.js +2 -0
- package/dist/app/assets/{MaterializationsPage-D7P1Kp6O.js → MaterializationsPage-BMO1afhm.js} +1 -1
- package/dist/app/assets/ModelPage-zHOJwMnU.js +1 -0
- package/dist/app/assets/{PackagePage-Cz9fVwSG.js → PackagePage-GCaWmELQ.js} +1 -1
- package/dist/app/assets/{RouteError-UONCloyN.js → RouteError-CIC588k4.js} +1 -1
- package/dist/app/assets/ThemeEditorPage-BdbIfIGO.js +1 -0
- package/dist/app/assets/{WorkbookPage-Bhzqvbq_.js → WorkbookPage-C8ucK_H8.js} +1 -1
- package/dist/app/assets/{core-BiGj7BML.es-kMHAa8tP.js → core-DFvqRVqM.es-NtfPVC7h.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-B3NcDPbp.js +18 -0
- package/dist/app/assets/index-BxDMCn3s.js +527 -0
- package/dist/app/assets/index-aYNf0kTZ.js +1 -0
- package/dist/app/assets/index-aYtt-ovi.js +1761 -0
- package/dist/app/assets/index-w_0OQJgZ.js +23 -0
- package/dist/app/index.html +1 -1
- package/dist/runtime/publisher.js +6 -1
- package/dist/server.mjs +377 -50
- 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/theme.controller.ts +83 -0
- package/src/dto/connection.dto.ts +6 -0
- package/src/materialization_metrics.ts +1 -1
- package/src/runtime/publisher.js +6 -1
- package/src/server.ts +58 -1
- package/src/service/build_plan.spec.ts +17 -17
- package/src/service/build_plan.ts +25 -10
- 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_service.spec.ts +47 -37
- package/src/service/materialization_service.ts +59 -50
- package/src/service/materialization_test_fixtures.ts +5 -5
- package/src/service/model.ts +1 -1
- package/src/service/package.ts +4 -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.220",
|
|
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
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { components } from "../api";
|
|
2
|
+
import { sanitizeTheme, Theme } from "../config";
|
|
3
|
+
import { BadRequestError, FrozenConfigError } from "../errors";
|
|
4
|
+
import { isPublisherConfigFrozen } from "../config";
|
|
5
|
+
import { ThemeStore } from "../service/theme_store";
|
|
6
|
+
|
|
7
|
+
type ApiTheme = components["schemas"]["Theme"];
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Handlers for the in-app Theme Editor.
|
|
11
|
+
*
|
|
12
|
+
* GET /api/v0/theme → current saved theme (or built-in default)
|
|
13
|
+
* PUT /api/v0/theme → save the supplied theme
|
|
14
|
+
* DELETE /api/v0/theme → reset to boot seed / built-in default
|
|
15
|
+
*/
|
|
16
|
+
export class ThemeController {
|
|
17
|
+
constructor(
|
|
18
|
+
private themeStore: ThemeStore,
|
|
19
|
+
private serverRoot: string,
|
|
20
|
+
) {}
|
|
21
|
+
|
|
22
|
+
getTheme = async (): Promise<ApiTheme> => {
|
|
23
|
+
const theme = await this.themeStore.get();
|
|
24
|
+
// Always return an object so the client never has to deal with the
|
|
25
|
+
// null/undefined ambiguity. An empty object means "no overrides yet,
|
|
26
|
+
// use SDK defaults."
|
|
27
|
+
return theme ?? {};
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* PUT persists the supplied theme verbatim, including a literal `{}`.
|
|
32
|
+
* An empty `{}` is an intentional "clear all overrides" save: it writes
|
|
33
|
+
* an (empty) row so the boot seed from publisher.config.json's `theme`
|
|
34
|
+
* block is suppressed on subsequent loads. This deliberately differs
|
|
35
|
+
* from DELETE (resetTheme), which drops the row and lets that config
|
|
36
|
+
* seed re-apply. Note the persisted `"{}"` sanitizes back to `undefined`
|
|
37
|
+
* on reload (sanitizeTheme returns undefined for `{}`), but the row
|
|
38
|
+
* still exists, so the seed stays suppressed. This PUT-`{}` path is not
|
|
39
|
+
* reachable from the Theme Editor UI today (its clear affordance is the
|
|
40
|
+
* Reset button, which calls DELETE); it exists only via the API.
|
|
41
|
+
*/
|
|
42
|
+
putTheme = async (body: unknown): Promise<ApiTheme> => {
|
|
43
|
+
if (isPublisherConfigFrozen(this.serverRoot)) {
|
|
44
|
+
throw new FrozenConfigError(
|
|
45
|
+
'Cannot edit theme: publisher.config.json has "frozenConfig": true.',
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
// Treat a literal `{}` as an intentional "clear all overrides" PUT
|
|
49
|
+
// (the only other clear path is DELETE, which re-seeds from the
|
|
50
|
+
// config file). sanitizeTheme returns undefined for both `{}` and
|
|
51
|
+
// garbage, so we have to disambiguate here.
|
|
52
|
+
const isEmptyObject =
|
|
53
|
+
typeof body === "object" &&
|
|
54
|
+
body !== null &&
|
|
55
|
+
!Array.isArray(body) &&
|
|
56
|
+
Object.keys(body as Record<string, unknown>).length === 0;
|
|
57
|
+
const sanitized = sanitizeTheme(body, "PUT /api/v0/theme");
|
|
58
|
+
if (!sanitized && !isEmptyObject) {
|
|
59
|
+
throw new BadRequestError(
|
|
60
|
+
"Theme payload was malformed (expected an object).",
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
const saved: Theme = await this.themeStore.set(sanitized ?? {});
|
|
64
|
+
return saved;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* DELETE drops the persisted row so the next load re-seeds from
|
|
69
|
+
* publisher.config.json's `theme` block (or ends up unthemed if the
|
|
70
|
+
* config defines none). Contrast with PUT `{}`, which persists an empty
|
|
71
|
+
* row and thereby suppresses that seed instead of restoring it. See
|
|
72
|
+
* putTheme for the full divergence.
|
|
73
|
+
*/
|
|
74
|
+
resetTheme = async (): Promise<ApiTheme> => {
|
|
75
|
+
if (isPublisherConfigFrozen(this.serverRoot)) {
|
|
76
|
+
throw new FrozenConfigError(
|
|
77
|
+
'Cannot reset theme: publisher.config.json has "frozenConfig": true.',
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
const reseeded = await this.themeStore.reset();
|
|
81
|
+
return reseeded ?? {};
|
|
82
|
+
};
|
|
83
|
+
}
|
|
@@ -265,6 +265,12 @@ export class ConnectionDto implements ApiConnection {
|
|
|
265
265
|
| "motherduck"
|
|
266
266
|
| "ducklake";
|
|
267
267
|
|
|
268
|
+
// Opaque, secret-free data-identity token; carried verbatim (never parsed
|
|
269
|
+
// or re-hashed) into content-addressed source ids. See api-doc.yaml.
|
|
270
|
+
@IsOptional()
|
|
271
|
+
@IsString()
|
|
272
|
+
fingerprint?: string;
|
|
273
|
+
|
|
268
274
|
@IsOptional()
|
|
269
275
|
@ValidateNested()
|
|
270
276
|
@Type(() => PostgresConnectionDto)
|
|
@@ -131,7 +131,7 @@ export function recordAutoLoadOutcome(outcome: "success" | "failure"): void {
|
|
|
131
131
|
|
|
132
132
|
/**
|
|
133
133
|
* Record that a connection digest could not be computed during build-plan
|
|
134
|
-
* compilation because the connection failed to resolve. The resulting
|
|
134
|
+
* compilation because the connection failed to resolve. The resulting sourceEntityIds
|
|
135
135
|
* are computed without a digest, so this is a correctness signal, not just noise.
|
|
136
136
|
*/
|
|
137
137
|
export function recordConnectionDigestSkipped(): void {
|
package/src/runtime/publisher.js
CHANGED
|
@@ -79,7 +79,12 @@
|
|
|
79
79
|
"/packages/" +
|
|
80
80
|
encodeURIComponent(target.pkg) +
|
|
81
81
|
"/models/" +
|
|
82
|
-
|
|
82
|
+
// Encode the whole model path as ONE path segment (slashes -> %2F). A model
|
|
83
|
+
// in a subfolder ("model/x.malloy") must not become two URL segments: some
|
|
84
|
+
// servers only match a single-segment {path} and would 405 a multi-segment
|
|
85
|
+
// path. This matches the typed SDK (encodeURIComponent) and the server's
|
|
86
|
+
// encoded-slash handling.
|
|
87
|
+
encodeURIComponent(modelPath) +
|
|
83
88
|
"/query";
|
|
84
89
|
var body = { compactJson: compactJson };
|
|
85
90
|
if (malloyQuery) body.query = malloyQuery;
|