@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
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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
|
|
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
|
-
|
|
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
|
+
});
|