@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
@@ -0,0 +1,199 @@
1
+ import { Mutex } from "async-mutex";
2
+ import { getInstanceTheme, sanitizeTheme, Theme } from "../config";
3
+ import { logger } from "../logger";
4
+ import { DuckDBConnection } from "../storage/duckdb/DuckDBConnection";
5
+ import { StorageManager } from "../storage/StorageManager";
6
+
7
+ const THEME_ROW_ID = "default";
8
+
9
+ /**
10
+ * Persistence for the instance-wide theme edited via the in-app Theme
11
+ * Editor. Backed by a single row in the `themes` table (DuckDB).
12
+ *
13
+ * Boot semantics: on first read after process start, if the table is
14
+ * empty, the row is seeded from `publisher.config.json`'s top-level
15
+ * `theme` block (if any). Subsequent writes go to DuckDB; the JSON file
16
+ * remains the boot seed only. This mirrors how environments + packages
17
+ * are handled. The JSON config is the seed; runtime mutations live in
18
+ * the DB and survive across restarts.
19
+ *
20
+ * Every payload read out of the DB is run through `sanitizeTheme` so a
21
+ * schema change can't put a stale shape in front of the editor (e.g.
22
+ * old per-mode `series` arrays after the simplification revert). The
23
+ * editor receives only fields that match the current type.
24
+ *
25
+ * Single in-process consumer; a Mutex serializes set / reset against
26
+ * concurrent reads to avoid the cache + DB diverging.
27
+ */
28
+ export class ThemeStore {
29
+ private mutex = new Mutex();
30
+ private cached: Theme | undefined;
31
+ private cacheLoaded = false;
32
+
33
+ constructor(
34
+ private storageManager: StorageManager,
35
+ private serverRoot: string,
36
+ ) {}
37
+
38
+ private get db(): DuckDBConnection {
39
+ return this.storageManager.getDuckDbConnection();
40
+ }
41
+
42
+ /**
43
+ * Idempotent. Loads the persisted theme into memory. If the table is
44
+ * empty, seeds from `publisher.config.json`'s instance theme (if
45
+ * present) and persists the seed so subsequent reads + the editor see
46
+ * the same baseline.
47
+ */
48
+ async initialize(): Promise<void> {
49
+ await this.mutex.runExclusive(async () => {
50
+ await this.loadLocked();
51
+ });
52
+ }
53
+
54
+ async get(): Promise<Theme | undefined> {
55
+ if (!this.cacheLoaded) {
56
+ await this.initialize();
57
+ }
58
+ return this.cached;
59
+ }
60
+
61
+ async set(theme: Theme): Promise<Theme> {
62
+ return this.mutex.runExclusive(async () => {
63
+ await this.persistLocked(theme);
64
+ this.cached = theme;
65
+ this.cacheLoaded = true;
66
+ return theme;
67
+ });
68
+ }
69
+
70
+ /**
71
+ * Drops the persisted row so the next read falls back to the boot
72
+ * seed (or to no theme at all if the config doesn't define one).
73
+ */
74
+ async reset(): Promise<Theme | undefined> {
75
+ return this.mutex.runExclusive(async () => {
76
+ await this.db.run("DELETE FROM themes WHERE id = ?", [THEME_ROW_ID]);
77
+ this.cached = undefined;
78
+ this.cacheLoaded = false;
79
+ await this.loadLocked();
80
+ return this.cached;
81
+ });
82
+ }
83
+
84
+ /**
85
+ * Single source of truth for loading the cache. Must be called with
86
+ * the mutex held. Idempotent: returns immediately if already loaded.
87
+ */
88
+ private async loadLocked(): Promise<void> {
89
+ if (this.cacheLoaded) return;
90
+ const row = await this.db.get<{ payload: string }>(
91
+ "SELECT payload FROM themes WHERE id = ?",
92
+ [THEME_ROW_ID],
93
+ );
94
+ if (row?.payload) {
95
+ let parsed: unknown;
96
+ try {
97
+ parsed = JSON.parse(row.payload);
98
+ } catch (error) {
99
+ logger.warn(
100
+ "ThemeStore: stored theme JSON is unparseable; treating as empty",
101
+ { error },
102
+ );
103
+ this.cacheLoaded = true;
104
+ return;
105
+ }
106
+ // Run through sanitizeTheme so stale schema shapes (e.g. an
107
+ // old per-mode `series` object) get dropped before the editor
108
+ // ever sees them. Operators can keep editing without hitting
109
+ // Reset; fields that match the current shape survive.
110
+ const sanitized = sanitizeTheme(parsed, "ThemeStore.load");
111
+ const dropped = listSanitizerDrops(parsed, sanitized);
112
+ if (dropped.length > 0) {
113
+ // Surface what disappeared so operators reading the server
114
+ // log can see why the editor doesn't show their old custom
115
+ // values after an upgrade. Re-saving from the editor will
116
+ // overwrite the on-disk payload with the current shape.
117
+ logger.info(
118
+ "ThemeStore: dropped stale fields from stored theme during load",
119
+ { dropped },
120
+ );
121
+ }
122
+ this.cached = sanitized;
123
+ } else {
124
+ const seed = getInstanceTheme(this.serverRoot);
125
+ if (seed) {
126
+ this.cached = seed;
127
+ await this.persistLocked(seed);
128
+ }
129
+ }
130
+ this.cacheLoaded = true;
131
+ }
132
+
133
+ private async persistLocked(theme: Theme): Promise<void> {
134
+ const now = new Date().toISOString();
135
+ const payload = JSON.stringify(theme);
136
+ await this.db.run(
137
+ `INSERT INTO themes (id, payload, created_at, updated_at)
138
+ VALUES (?, ?, ?, ?)
139
+ ON CONFLICT (id) DO UPDATE SET
140
+ payload = EXCLUDED.payload,
141
+ updated_at = EXCLUDED.updated_at`,
142
+ [THEME_ROW_ID, payload, now, now],
143
+ );
144
+ }
145
+ }
146
+
147
+ /**
148
+ * Return the list of top-level Theme keys present on `raw` but absent
149
+ * from `sanitized` (i.e. dropped by `sanitizeTheme`). Reports dotted
150
+ * paths down one level so the log says e.g. `palette.series` rather
151
+ * than just `palette`. Returns an empty array when nothing was lost.
152
+ */
153
+ function listSanitizerDrops(
154
+ raw: unknown,
155
+ sanitized: Theme | undefined,
156
+ ): string[] {
157
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return [];
158
+ const r = raw as Record<string, unknown>;
159
+ const drops: string[] = [];
160
+
161
+ const topKeys = ["defaultMode", "allowUserToggle"] as const;
162
+ for (const k of topKeys) {
163
+ if (r[k] !== undefined && sanitized?.[k] === undefined) drops.push(k);
164
+ }
165
+
166
+ if (
167
+ r.palette &&
168
+ typeof r.palette === "object" &&
169
+ !Array.isArray(r.palette)
170
+ ) {
171
+ const rp = r.palette as Record<string, unknown>;
172
+ const sp = (sanitized?.palette ?? {}) as Record<string, unknown>;
173
+ for (const k of Object.keys(rp)) {
174
+ if (rp[k] !== undefined && sp[k] === undefined)
175
+ drops.push(`palette.${k}`);
176
+ }
177
+ } else if (
178
+ r.palette !== undefined &&
179
+ (!sanitized || sanitized.palette === undefined)
180
+ ) {
181
+ drops.push("palette");
182
+ }
183
+
184
+ if (r.font && typeof r.font === "object" && !Array.isArray(r.font)) {
185
+ const rf = r.font as Record<string, unknown>;
186
+ const sf = (sanitized?.font ?? {}) as Record<string, unknown>;
187
+ for (const k of Object.keys(rf)) {
188
+ if (rf[k] !== undefined && sf[k] === undefined)
189
+ drops.push(`font.${k}`);
190
+ }
191
+ } else if (
192
+ r.font !== undefined &&
193
+ (!sanitized || sanitized.font === undefined)
194
+ ) {
195
+ drops.push("font");
196
+ }
197
+
198
+ return drops;
199
+ }
@@ -143,7 +143,7 @@ export interface MaterializationUpdate {
143
143
  }
144
144
 
145
145
  /**
146
- * Malloy-facing build manifest: maps a buildId to the physical table backing
146
+ * Malloy-facing build manifest: maps a sourceEntityId to the physical table backing
147
147
  * that persist source. This is the shape the Malloy runtime consumes when
148
148
  * (re)loading models so persist references resolve to materialized tables.
149
149
  * Distinct from {@link BuildManifestResult} (the wire build output, which also
@@ -75,6 +75,23 @@ export class StorageManager {
75
75
  this.repository = new DuckDBRepository(connection);
76
76
  }
77
77
 
78
+ /**
79
+ * Access the underlying DuckDB connection for callers that need raw
80
+ * SQL access (e.g. the ThemeStore singleton) and don't fit the
81
+ * structured `ResourceRepository` interface. Use sparingly; prefer
82
+ * `getRepository()` for anything that maps cleanly onto the existing
83
+ * repository methods.
84
+ */
85
+ getDuckDbConnection(): DuckDBConnection {
86
+ if (!this.connection) {
87
+ throw new Error("Storage not initialized");
88
+ }
89
+ if (!(this.connection instanceof DuckDBConnection)) {
90
+ throw new Error("Underlying storage connection is not DuckDB");
91
+ }
92
+ return this.connection;
93
+ }
94
+
78
95
  getRepository(): ResourceRepository {
79
96
  if (!this.repository) {
80
97
  throw new Error("Storage not initialized. Call initialize() first.");
@@ -7,27 +7,28 @@ export async function initializeSchema(
7
7
  ): Promise<void> {
8
8
  const initialized = await db.isInitialized();
9
9
 
10
- if (initialized && !force) {
11
- return;
12
- }
13
-
14
10
  if (force) {
15
11
  logger.info(
16
12
  "Reinitializing database schema dropping and recreating all tables",
17
13
  );
18
14
  await dropAllTables(db);
19
- } else {
15
+ } else if (!initialized) {
20
16
  // TODO: Remove this during projects cleanup
21
17
  // If a pre-rename `projects` schema is on disk, the new
22
18
  // CREATE TABLE IF NOT EXISTS pass below would silently leave child
23
19
  // tables on the old `project_id` column and the first query against
24
20
  // `environment_id` would crash. Drop the legacy tables (with a loud
25
21
  // warning) so the fresh schema can be created cleanly. This is
26
- // destructive operators upgrading should re-create their environments
22
+ // destructive: operators upgrading should re-create their environments
27
23
  // and packages via the API after the upgrade.
28
24
  await dropLegacyProjectSchema(db);
29
25
  logger.info("Creating database schema for the first time...");
30
26
  }
27
+ // Always fall through to the CREATE TABLE IF NOT EXISTS pass below.
28
+ // The statements are idempotent and let an already-initialized DB pick
29
+ // up new tables added in later builds (e.g., the `themes` table for
30
+ // the in-app Theme Editor) without forcing operators to run --init
31
+ // and lose their existing environments / packages / materializations.
31
32
 
32
33
  // Environments table
33
34
  await db.run(`
@@ -102,6 +103,25 @@ export async function initializeSchema(
102
103
  )
103
104
  `);
104
105
 
106
+ // Themes table.
107
+ //
108
+ // Singleton storage for the instance-wide theme edited by the in-app
109
+ // Theme Editor. At most one row (id = "default"); we use INSERT ON
110
+ // CONFLICT to keep the table effectively a key/value cell.
111
+ //
112
+ // publisher.config.json's optional `theme` block is a BOOT SEED only —
113
+ // on first run, if this table is empty, ThemeStore writes the seed
114
+ // here. Subsequent reads come from this table so the editor's saves
115
+ // win over the file.
116
+ await db.run(`
117
+ CREATE TABLE IF NOT EXISTS themes (
118
+ id VARCHAR PRIMARY KEY,
119
+ payload JSON NOT NULL,
120
+ created_at TIMESTAMP NOT NULL,
121
+ updated_at TIMESTAMP NOT NULL
122
+ )
123
+ `);
124
+
105
125
  // Create indexes for better query performance
106
126
  await db.run(
107
127
  "CREATE INDEX IF NOT EXISTS idx_packages_environment_id ON packages(environment_id)",
@@ -156,6 +176,7 @@ async function dropAllTables(db: DuckDBConnection): Promise<void> {
156
176
  "packages",
157
177
  "connections",
158
178
  "environments",
179
+ "themes",
159
180
  ];
160
181
 
161
182
  logger.info("Dropping tables:", tables.join(", "));
@@ -0,0 +1,57 @@
1
+ import { describe, expect, it } from "bun:test";
2
+ import { readFileSync } from "fs";
3
+ import { resolve } from "path";
4
+ import { PER_MODE_COLOR_KEYS } from "./config";
5
+
6
+ /**
7
+ * The per-mode palette key list is hand-copied in three places: this server
8
+ * copy (config.ts), the SDK copy (packages/sdk/src/theme/keys.ts), and the
9
+ * api-doc.yaml Theme.palette schema. The server and the SDK are intentionally
10
+ * decoupled (neither imports the other), and api-doc.yaml is their only shared
11
+ * contract, so nothing but this test keeps the three in step. A silent drift
12
+ * drops a colour: the server sanitizer or the SDK resolver ignores a key that
13
+ * is not in its own copy.
14
+ */
15
+
16
+ function extractTsArray(source: string, constName: string): string[] {
17
+ const decl = source.indexOf(`${constName} = [`);
18
+ if (decl === -1) throw new Error(`${constName} array not found`);
19
+ const open = source.indexOf("[", decl);
20
+ const close = source.indexOf("]", open);
21
+ return [...source.slice(open + 1, close).matchAll(/"([^"]+)"/g)].map(
22
+ (m) => m[1],
23
+ );
24
+ }
25
+
26
+ function extractApiDocPaletteKeys(apiDoc: string): string[] {
27
+ const start = apiDoc.indexOf("\n palette:");
28
+ if (start === -1) throw new Error("palette block not found in api-doc.yaml");
29
+ const end = apiDoc.indexOf("\n font:", start);
30
+ const block = apiDoc.slice(start, end === -1 ? undefined : end);
31
+ // Direct children of `palette.properties` sit at 12-space indentation;
32
+ // deeper keys (type, properties, light, dark) are more indented and skipped.
33
+ return [...block.matchAll(/^ {12}(\w+):$/gm)].map((m) => m[1]);
34
+ }
35
+
36
+ describe("per-mode palette key parity", () => {
37
+ const serverKeys = [...PER_MODE_COLOR_KEYS];
38
+
39
+ it("server config.ts matches the SDK keys.ts copy", () => {
40
+ const sdk = readFileSync(
41
+ resolve(import.meta.dir, "../../sdk/src/theme/keys.ts"),
42
+ "utf8",
43
+ );
44
+ expect(extractTsArray(sdk, "PER_MODE_COLOR_KEYS")).toEqual(serverKeys);
45
+ });
46
+
47
+ it("server config.ts matches the api-doc.yaml Theme.palette schema", () => {
48
+ const apiDoc = readFileSync(
49
+ resolve(import.meta.dir, "../../../api-doc.yaml"),
50
+ "utf8",
51
+ );
52
+ // palette also declares `series` (a shared array, not a per-mode object).
53
+ expect(new Set(extractApiDocPaletteKeys(apiDoc))).toEqual(
54
+ new Set([...serverKeys, "series"]),
55
+ );
56
+ });
57
+ });
@@ -111,7 +111,7 @@ describe("Manifest binding via Package.manifestLocation (E2E)", () => {
111
111
  strict: false,
112
112
  entries: {
113
113
  build123: {
114
- buildId: "build123",
114
+ sourceEntityId: "build123",
115
115
  sourceName: "order_summary",
116
116
  physicalTableName: "main.order_summary_mz",
117
117
  connectionName: "duckdb",
@@ -206,14 +206,14 @@ describe("Materialization REST API: single-call (E2E)", () => {
206
206
  // instruction, and create the materialization already building it.
207
207
  const source = await firstPlanSource();
208
208
  expect(source.name).toBe("order_summary");
209
- expect(typeof source.buildId).toBe("string");
209
+ expect(typeof source.sourceEntityId).toBe("string");
210
210
 
211
211
  const physicalTableName = "order_summary_built";
212
212
  const createRes = await createMaterialization({
213
213
  buildInstructions: {
214
214
  sources: [
215
215
  {
216
- buildId: source.buildId,
216
+ sourceEntityId: source.sourceEntityId,
217
217
  sourceID: source.sourceID,
218
218
  materializedTableId: "mt-order-summary",
219
219
  physicalTableName,
@@ -241,7 +241,7 @@ describe("Materialization REST API: single-call (E2E)", () => {
241
241
  string,
242
242
  Record<string, unknown>
243
243
  >;
244
- const entry = entries[source.buildId as string];
244
+ const entry = entries[source.sourceEntityId as string];
245
245
  expect(entry).toBeDefined();
246
246
  expect(entry.physicalTableName).toBe(physicalTableName);
247
247
  expect(entry.sourceName).toBe("order_summary");
@@ -261,12 +261,12 @@ describe("Materialization REST API: single-call (E2E)", () => {
261
261
  { timeout: 90_000 },
262
262
  );
263
263
 
264
- it("rejects buildInstructions with an unknown buildId at create (400)", async () => {
264
+ it("rejects buildInstructions with an unknown sourceEntityId at create (400)", async () => {
265
265
  const createRes = await createMaterialization({
266
266
  buildInstructions: {
267
267
  sources: [
268
268
  {
269
- buildId: "not-a-real-build-id",
269
+ sourceEntityId: "not-a-real-build-id",
270
270
  materializedTableId: "mt",
271
271
  physicalTableName: "t",
272
272
  realization: "COPY",
@@ -0,0 +1,141 @@
1
+ // Lives under `tests/unit/` (not `src/`) on purpose: a sibling spec
2
+ // (environment_store.spec.ts) calls
3
+ // `mock.module("../storage/StorageManager", ...)`. Bun's module mocks
4
+ // persist process-wide across spec files, so a spec under `src/` that
5
+ // imports the real StorageManager would silently get the mock (which
6
+ // doesn't expose `getDuckDbConnection`). The integration runner gives
7
+ // us a clean module cache.
8
+ import { afterEach, beforeEach, describe, expect, it } from "bun:test";
9
+ import fs from "fs";
10
+ import path from "path";
11
+ import { PUBLISHER_CONFIG_NAME } from "../../../src/constants";
12
+ import { ThemeController } from "../../../src/controller/theme.controller";
13
+ import { BadRequestError, FrozenConfigError } from "../../../src/errors";
14
+ import { ThemeStore } from "../../../src/service/theme_store";
15
+ import { StorageManager } from "../../../src/storage/StorageManager";
16
+
17
+ const TEST_ROOT = path.join(process.cwd(), "test-temp-theme-controller");
18
+
19
+ function writeConfig(json: object) {
20
+ if (!fs.existsSync(TEST_ROOT)) fs.mkdirSync(TEST_ROOT, { recursive: true });
21
+ fs.writeFileSync(
22
+ path.join(TEST_ROOT, PUBLISHER_CONFIG_NAME),
23
+ JSON.stringify(json),
24
+ );
25
+ }
26
+
27
+ async function build(
28
+ configJson: object,
29
+ ): Promise<{ controller: ThemeController; sm: StorageManager }> {
30
+ writeConfig(configJson);
31
+ const sm = new StorageManager({
32
+ type: "duckdb",
33
+ duckdb: { path: ":memory:" },
34
+ });
35
+ await sm.initialize(true);
36
+ const store = new ThemeStore(sm, TEST_ROOT);
37
+ return { controller: new ThemeController(store, TEST_ROOT), sm };
38
+ }
39
+
40
+ describe("ThemeController", () => {
41
+ beforeEach(() => {
42
+ if (fs.existsSync(TEST_ROOT)) {
43
+ fs.rmSync(TEST_ROOT, { recursive: true, force: true });
44
+ }
45
+ });
46
+ afterEach(() => {
47
+ if (fs.existsSync(TEST_ROOT)) {
48
+ fs.rmSync(TEST_ROOT, { recursive: true, force: true });
49
+ }
50
+ });
51
+
52
+ it("getTheme returns the boot-seeded theme", async () => {
53
+ const { controller } = await build({
54
+ frozenConfig: false,
55
+ theme: { palette: { series: ["#abc"] } },
56
+ environments: [],
57
+ });
58
+ const theme = await controller.getTheme();
59
+ expect(theme.palette?.series).toEqual(["#abc"]);
60
+ });
61
+
62
+ it("getTheme returns an empty object when no theme configured", async () => {
63
+ const { controller } = await build({
64
+ frozenConfig: false,
65
+ environments: [],
66
+ });
67
+ expect(await controller.getTheme()).toEqual({});
68
+ });
69
+
70
+ it("putTheme writes and returns the theme", async () => {
71
+ const { controller } = await build({
72
+ frozenConfig: false,
73
+ environments: [],
74
+ });
75
+ const saved = await controller.putTheme({
76
+ palette: { series: ["#ff0080"] },
77
+ });
78
+ expect(saved.palette?.series).toEqual(["#ff0080"]);
79
+ // The next GET returns the same value.
80
+ expect((await controller.getTheme()).palette?.series).toEqual([
81
+ "#ff0080",
82
+ ]);
83
+ });
84
+
85
+ it("putTheme accepts an empty object as a clear-all-overrides signal", async () => {
86
+ const { controller } = await build({
87
+ frozenConfig: false,
88
+ theme: { palette: { series: ["#aaa"] } },
89
+ environments: [],
90
+ });
91
+ // Seed the editor's draft, then PUT {} to clear it.
92
+ await controller.putTheme({ palette: { series: ["#edit"] } });
93
+ const cleared = await controller.putTheme({});
94
+ expect(cleared).toEqual({});
95
+ expect(await controller.getTheme()).toEqual({});
96
+ });
97
+
98
+ it("putTheme rejects non-object payloads", async () => {
99
+ const { controller } = await build({
100
+ frozenConfig: false,
101
+ environments: [],
102
+ });
103
+ await expect(controller.putTheme("nope")).rejects.toBeInstanceOf(
104
+ BadRequestError,
105
+ );
106
+ await expect(controller.putTheme([1, 2, 3])).rejects.toBeInstanceOf(
107
+ BadRequestError,
108
+ );
109
+ });
110
+
111
+ it("putTheme throws FrozenConfigError when frozenConfig is true", async () => {
112
+ const { controller } = await build({
113
+ frozenConfig: true,
114
+ environments: [],
115
+ });
116
+ await expect(
117
+ controller.putTheme({ palette: { series: ["#abc"] } }),
118
+ ).rejects.toBeInstanceOf(FrozenConfigError);
119
+ });
120
+
121
+ it("resetTheme falls back to the boot seed", async () => {
122
+ const { controller } = await build({
123
+ frozenConfig: false,
124
+ theme: { palette: { series: ["#seed"] } },
125
+ environments: [],
126
+ });
127
+ await controller.putTheme({ palette: { series: ["#edit"] } });
128
+ const reseeded = await controller.resetTheme();
129
+ expect(reseeded.palette?.series).toEqual(["#seed"]);
130
+ });
131
+
132
+ it("resetTheme throws FrozenConfigError when frozenConfig is true", async () => {
133
+ const { controller } = await build({
134
+ frozenConfig: true,
135
+ environments: [],
136
+ });
137
+ await expect(controller.resetTheme()).rejects.toBeInstanceOf(
138
+ FrozenConfigError,
139
+ );
140
+ });
141
+ });