@malloy-publisher/server 0.0.217 → 0.0.219
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 +114 -0
- package/dist/app/assets/{EnvironmentPage-BX71Wsun.js → EnvironmentPage-gehnjfC6.js} +1 -1
- package/dist/app/assets/{HomePage-Bnp4Puv4.js → HomePage-8LQBytE4.js} +1 -1
- package/dist/app/assets/{MainPage-chkqUlI0.js → MainPage-DDaZLJw-.js} +1 -1
- package/dist/app/assets/{MaterializationsPage-DG4e_wRd.js → MaterializationsPage-D7P1Kp6O.js} +1 -1
- package/dist/app/assets/{ModelPage-DbLU-ABs.js → ModelPage-Do_vhxOc.js} +1 -1
- package/dist/app/assets/{PackagePage-EvWf3VZ4.js → PackagePage-Cz9fVwSG.js} +1 -1
- package/dist/app/assets/{RouteError-POj8kImc.js → RouteError-UONCloyN.js} +1 -1
- package/dist/app/assets/{WorkbookPage-Bkq3C1MS.js → WorkbookPage-Bhzqvbq_.js} +1 -1
- package/dist/app/assets/{core-DiLT6QvL.es-Jn6sdy15.js → core-BiGj7BML.es-kMHAa8tP.js} +1 -1
- package/dist/app/assets/{index-gjr27uMq.js → index-VzRbxcF7.js} +3 -3
- package/dist/app/assets/{index-C_AT6ZZw.js → index-ddq4-5hu.js} +1 -1
- package/dist/app/assets/{index-bAdd7U9-.js → index-qOQF9CXq.js} +1 -1
- package/dist/app/assets/{index.umd-BxzPw_1E.js → index.umd-B2kmxDh7.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/cpufeatures-1yrn0vtw.node +0 -0
- package/dist/server.mjs +19069 -70
- package/package.json +1 -1
- package/src/dto/connection.dto.ts +43 -0
- package/src/service/connection.spec.ts +70 -0
- package/src/service/connection.ts +149 -2
- package/src/service/connection_config.spec.ts +254 -0
- package/src/service/connection_config.ts +127 -0
- package/src/service/model.ts +25 -3
- package/src/service/package.ts +21 -2
- package/src/service/package_worker_path.spec.ts +103 -0
- package/src/service/proxy.spec.ts +414 -0
- package/src/service/proxy.ts +248 -0
|
@@ -1,10 +1,18 @@
|
|
|
1
1
|
import { createPrivateKey } from "crypto";
|
|
2
|
+
import { existsSync } from "fs";
|
|
2
3
|
import path from "path";
|
|
3
4
|
import { components } from "../api";
|
|
5
|
+
import { parseHostKeys } from "./proxy";
|
|
4
6
|
|
|
5
7
|
type ApiConnection = components["schemas"]["Connection"];
|
|
6
8
|
type AttachedDatabase = components["schemas"]["AttachedDatabase"];
|
|
7
9
|
|
|
10
|
+
// TLS modes accepted on a proxied postgres connection. Canonical here (rather
|
|
11
|
+
// than in connection.ts, which imports this module) so both the config-load
|
|
12
|
+
// validator and the connect-time builder derive from one list. Mirrors the
|
|
13
|
+
// `sslmode` enum in api-doc.yaml.
|
|
14
|
+
export const PROXIED_SSLMODES = ["disable", "no-verify", "verify-ca"] as const;
|
|
15
|
+
|
|
8
16
|
export type CoreConnectionEntry = {
|
|
9
17
|
is: string;
|
|
10
18
|
[key: string]: unknown;
|
|
@@ -22,6 +30,7 @@ export type EnvironmentConnectionMetadata = {
|
|
|
22
30
|
isDuckLake: boolean;
|
|
23
31
|
databasePath?: string;
|
|
24
32
|
workingDirectory: string;
|
|
33
|
+
proxy?: ApiConnection["proxy"];
|
|
25
34
|
};
|
|
26
35
|
|
|
27
36
|
export type AssembledEnvironmentConnections = {
|
|
@@ -258,6 +267,123 @@ function buildDuckdbEntry(
|
|
|
258
267
|
}
|
|
259
268
|
|
|
260
269
|
function validateConnectionShape(connection: ApiConnection): void {
|
|
270
|
+
if (connection.proxy) {
|
|
271
|
+
// A connection proxy makes THIS server open an outbound SSH tunnel to a
|
|
272
|
+
// tenant-configured bastion. It's a normal connection capability,
|
|
273
|
+
// authorized by whoever configures the connection — deliberately NOT gated
|
|
274
|
+
// by an env flag, and kept separate from the `publisher` HTTP multi-hop
|
|
275
|
+
// type's PUBLISHER_ALLOW_PROXY_CONNECTIONS gate below (that flag is about
|
|
276
|
+
// publisher-to-publisher proxying, a distinct operator decision). Optional
|
|
277
|
+
// host-key pinning is fail-closed at connect time when configured (see
|
|
278
|
+
// openProxy); the proxy-specific fields are validated up front below so a
|
|
279
|
+
// permanent misconfig fails at config load, not by repeatedly dialing the
|
|
280
|
+
// tenant's bastion at query time.
|
|
281
|
+
if (connection.proxy.type !== "ssh") {
|
|
282
|
+
throw new Error(
|
|
283
|
+
`Connection '${connection.name}' has an unsupported proxy type '${connection.proxy.type}'. Only 'ssh' is supported.`,
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
if (connection.type !== "postgres") {
|
|
287
|
+
throw new Error(
|
|
288
|
+
`Connection proxy is not supported for type '${connection.type}' (only 'postgres' today).`,
|
|
289
|
+
);
|
|
290
|
+
}
|
|
291
|
+
if (!connection.proxy.ssh) {
|
|
292
|
+
throw new Error(
|
|
293
|
+
`Connection proxy on '${connection.name}' has type 'ssh' but no 'ssh' config object.`,
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
// The tunnel forwards to an explicit host:port; the connectionString form
|
|
297
|
+
// can't be rewritten to the local endpoint. Reject it outright when a
|
|
298
|
+
// proxy is set — normal postgres gives connectionString precedence over
|
|
299
|
+
// host/port, so a config carrying BOTH would silently tunnel to
|
|
300
|
+
// host/port and ignore the connectionString, connecting to a different
|
|
301
|
+
// database than the operator configured. Require discrete host/port.
|
|
302
|
+
if (connection.postgresConnection?.connectionString) {
|
|
303
|
+
throw new Error(
|
|
304
|
+
`Connection proxy on '${connection.name}' does not support the connectionString form; ` +
|
|
305
|
+
`provide discrete host and port instead (the tunnel forwards to an explicit endpoint).`,
|
|
306
|
+
);
|
|
307
|
+
}
|
|
308
|
+
if (
|
|
309
|
+
!connection.postgresConnection?.host ||
|
|
310
|
+
!connection.postgresConnection?.port
|
|
311
|
+
) {
|
|
312
|
+
throw new Error(
|
|
313
|
+
`Connection proxy on '${connection.name}' requires explicit host and port on the ` +
|
|
314
|
+
`postgres connection; the connectionString form is not supported with a proxy.`,
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
// hostKey is optional (omitted or empty string => connect unpinned), but a
|
|
319
|
+
// non-empty hostKey that parses to zero keys — only blank lines, whitespace,
|
|
320
|
+
// or `#` comments, e.g. a paste that grabbed just ssh-keyscan's
|
|
321
|
+
// `# host:port ...` header — is a misconfigured pin, not a licence to
|
|
322
|
+
// connect unverified. Reject it here so the operator gets a config error
|
|
323
|
+
// instead of a silently unpinned tunnel. (Truthiness, not trim(): "" is the
|
|
324
|
+
// unpinned signal; " " is a non-empty value that must yield a key.)
|
|
325
|
+
const hostKey = connection.proxy.ssh?.hostKey;
|
|
326
|
+
if (hostKey && parseHostKeys(hostKey).size === 0) {
|
|
327
|
+
throw new Error(
|
|
328
|
+
`Connection proxy on '${connection.name}' has a hostKey with no usable host-key line ` +
|
|
329
|
+
`(only blanks/comments). Provide an OpenSSH known_hosts line or base64 blob, or omit ` +
|
|
330
|
+
`hostKey to connect unpinned.`,
|
|
331
|
+
);
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Validate the proxied TLS mode up front. The tunnel is dialed lazily on
|
|
335
|
+
// first lookup and a failed build is retried on every subsequent query, so
|
|
336
|
+
// a permanent sslmode misconfig left to throw at connect time would re-dial
|
|
337
|
+
// the tenant's bastion indefinitely. Fail at config load instead.
|
|
338
|
+
// `!= null` (not truthiness) so a present-but-empty sslmode ("") is caught
|
|
339
|
+
// here as unsupported rather than slipping through to fail at tunnel-build;
|
|
340
|
+
// null/undefined mean unset (server applies the default).
|
|
341
|
+
const sslmode = connection.postgresConnection?.sslmode;
|
|
342
|
+
if (sslmode != null) {
|
|
343
|
+
if (!(PROXIED_SSLMODES as readonly string[]).includes(sslmode)) {
|
|
344
|
+
throw new Error(
|
|
345
|
+
`Connection proxy on '${connection.name}' has unsupported sslmode '${sslmode}' ` +
|
|
346
|
+
`(expected ${PROXIED_SSLMODES.join(" | ")}).`,
|
|
347
|
+
);
|
|
348
|
+
}
|
|
349
|
+
if (sslmode === "verify-ca") {
|
|
350
|
+
const caBundle = process.env.NODE_EXTRA_CA_CERTS;
|
|
351
|
+
if (!caBundle || !existsSync(caBundle)) {
|
|
352
|
+
throw new Error(
|
|
353
|
+
`Connection proxy on '${connection.name}' uses sslmode 'verify-ca' but no readable ` +
|
|
354
|
+
`CA bundle is available (NODE_EXTRA_CA_CERTS is unset or points at a missing file). ` +
|
|
355
|
+
`Add the CA bundle to the image or use sslmode 'no-verify'.`,
|
|
356
|
+
);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// The proxied path builds a connectionString to the local tunnel endpoint;
|
|
362
|
+
// pg decodes the database path with decodeURI, which leaves URI-reserved
|
|
363
|
+
// characters percent-encoded — so a db name containing them would resolve
|
|
364
|
+
// to the wrong database. Reject it clearly rather than failing later with a
|
|
365
|
+
// confusing "database does not exist". (user/password use decodeURIComponent
|
|
366
|
+
// on parse and round-trip fine.)
|
|
367
|
+
const dbName = connection.postgresConnection?.databaseName;
|
|
368
|
+
if (dbName && decodeURI(encodeURIComponent(dbName)) !== dbName) {
|
|
369
|
+
throw new Error(
|
|
370
|
+
`Connection proxy on '${connection.name}' has a database name with characters that can't ` +
|
|
371
|
+
`be carried over a proxied connection (${JSON.stringify(dbName)}). Use a database name ` +
|
|
372
|
+
`without URI-reserved characters (; , / ? : @ & = + $ #).`,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// sslmode is only honored on the proxied path (the direct path builds TLS from
|
|
378
|
+
// the deployment PGSSLMODE). Reject it on a non-proxied connection so a tenant
|
|
379
|
+
// who sets it doesn't silently get a different TLS posture than they asked for.
|
|
380
|
+
if (!connection.proxy && connection.postgresConnection?.sslmode) {
|
|
381
|
+
throw new Error(
|
|
382
|
+
`Connection '${connection.name}' sets postgresConnection.sslmode but has no proxy; sslmode is ` +
|
|
383
|
+
`only supported for proxied connections (direct connections use the deployment PGSSLMODE).`,
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
261
387
|
switch (connection.type) {
|
|
262
388
|
case "postgres":
|
|
263
389
|
case "mysql":
|
|
@@ -445,6 +571,7 @@ export function assembleEnvironmentConnections(
|
|
|
445
571
|
isDuckLake,
|
|
446
572
|
databasePath,
|
|
447
573
|
workingDirectory: environmentPath,
|
|
574
|
+
proxy: connection.proxy,
|
|
448
575
|
});
|
|
449
576
|
|
|
450
577
|
switch (connection.type) {
|
package/src/service/model.ts
CHANGED
|
@@ -115,6 +115,18 @@ function quoteMalloyIdentifier(name: string | undefined): string {
|
|
|
115
115
|
return "`" + (name ?? "").replace(/\\/g, "\\\\").replace(/`/g, "\\`") + "`";
|
|
116
116
|
}
|
|
117
117
|
|
|
118
|
+
/**
|
|
119
|
+
* A non-fatal render-tag finding from {@link Model.validateRenderTags}: an
|
|
120
|
+
* error-severity issue that affects only how a field renders, never whether the
|
|
121
|
+
* model compiles or a query runs. `target` is the query or view it sits on
|
|
122
|
+
* (e.g. `by_carrier` or `flights -> by_carrier`).
|
|
123
|
+
*/
|
|
124
|
+
export interface RenderTagWarning {
|
|
125
|
+
target: string;
|
|
126
|
+
message: string;
|
|
127
|
+
severity: "error" | "warn";
|
|
128
|
+
}
|
|
129
|
+
|
|
118
130
|
export class Model {
|
|
119
131
|
private packageName: string;
|
|
120
132
|
private modelPath: string;
|
|
@@ -952,12 +964,13 @@ export class Model {
|
|
|
952
964
|
* it does not fail the package load. Such a tag still renders as
|
|
953
965
|
* "[object Object]" at query time, so the warning is the operator-facing
|
|
954
966
|
* signal. Lower-severity findings are left for the query-time `renderLogs`
|
|
955
|
-
* surface.
|
|
967
|
+
* surface. The findings are returned so the owning Package can surface them
|
|
968
|
+
* as non-fatal `warnings` on its response.
|
|
956
969
|
*/
|
|
957
|
-
public async validateRenderTags(): Promise<
|
|
970
|
+
public async validateRenderTags(): Promise<RenderTagWarning[]> {
|
|
958
971
|
const mm = this.modelMaterializer;
|
|
959
972
|
if (!mm) {
|
|
960
|
-
return;
|
|
973
|
+
return [];
|
|
961
974
|
}
|
|
962
975
|
// Dynamic import (like execute_query_tool.ts): the renderer is heavy and
|
|
963
976
|
// mutates DOM globals on load, so only pull it in when there's a model to
|
|
@@ -1003,6 +1016,7 @@ export class Model {
|
|
|
1003
1016
|
}
|
|
1004
1017
|
}
|
|
1005
1018
|
|
|
1019
|
+
const findings: RenderTagWarning[] = [];
|
|
1006
1020
|
for (const target of targets) {
|
|
1007
1021
|
let result: Malloy.Result;
|
|
1008
1022
|
try {
|
|
@@ -1024,8 +1038,16 @@ export class Model {
|
|
|
1024
1038
|
.map((e) => e.message)
|
|
1025
1039
|
.join("; ")}`,
|
|
1026
1040
|
);
|
|
1041
|
+
for (const e of errors) {
|
|
1042
|
+
findings.push({
|
|
1043
|
+
target: target.label,
|
|
1044
|
+
message: e.message,
|
|
1045
|
+
severity: "error",
|
|
1046
|
+
});
|
|
1047
|
+
}
|
|
1027
1048
|
}
|
|
1028
1049
|
}
|
|
1050
|
+
return findings;
|
|
1029
1051
|
}
|
|
1030
1052
|
|
|
1031
1053
|
public async getModel(): Promise<ApiCompiledModel> {
|
package/src/service/package.ts
CHANGED
|
@@ -40,6 +40,7 @@ type ApiDatabase = components["schemas"]["Database"];
|
|
|
40
40
|
type ApiModel = components["schemas"]["Model"];
|
|
41
41
|
type ApiNotebook = components["schemas"]["Notebook"];
|
|
42
42
|
export type ApiPackage = components["schemas"]["Package"];
|
|
43
|
+
type ApiPackageWarning = NonNullable<ApiPackage["warnings"]>[number];
|
|
43
44
|
type ApiColumn = components["schemas"]["Column"];
|
|
44
45
|
type ApiTableDescription = components["schemas"]["TableDescription"];
|
|
45
46
|
// A thunk lets callers pass a live reference to the *current* environment
|
|
@@ -77,6 +78,12 @@ export class Package {
|
|
|
77
78
|
// no persist source. Surfaced read-only on getPackageMetadata() so a caller
|
|
78
79
|
// can derive build instructions without a separate plan round-trip.
|
|
79
80
|
private buildPlan: BuildPlan | null = null;
|
|
81
|
+
// Non-fatal render-tag findings aggregated across the package's models (each
|
|
82
|
+
// tagged with its model path), surfaced read-only on
|
|
83
|
+
// getPackageMetadata().warnings. Refreshed on load and reload. A bad render
|
|
84
|
+
// tag does not fail the load (see Model.validateRenderTags); this is the
|
|
85
|
+
// response-level signal that a tag is misconfigured.
|
|
86
|
+
private renderTagWarnings: ApiPackageWarning[] = [];
|
|
80
87
|
private static meter = publisherMeter();
|
|
81
88
|
private static packageLoadHistogram = this.meter.createHistogram(
|
|
82
89
|
"malloy_package_load_duration",
|
|
@@ -350,6 +357,7 @@ export class Package {
|
|
|
350
357
|
// placeholders instead; that branch goes through a different
|
|
351
358
|
// hydration path.)
|
|
352
359
|
const models = new Map<string, Model>();
|
|
360
|
+
const renderTagWarnings: ApiPackageWarning[] = [];
|
|
353
361
|
for (const sm of outcome.models) {
|
|
354
362
|
if (sm.compilationError) {
|
|
355
363
|
const err = Model.deserializeCompilationError(sm.compilationError);
|
|
@@ -371,7 +379,10 @@ export class Package {
|
|
|
371
379
|
// Validate renderer tags on the main thread (the renderer is too heavy
|
|
372
380
|
// to load inside the pure-CPU package-load worker). A misconfigured tag
|
|
373
381
|
// is logged as a warning naming the target; it does not fail the load.
|
|
374
|
-
|
|
382
|
+
// The findings also ride the package response as non-fatal `warnings`.
|
|
383
|
+
for (const w of await model.validateRenderTags()) {
|
|
384
|
+
renderTagWarnings.push({ model: sm.modelPath, ...w });
|
|
385
|
+
}
|
|
375
386
|
// Reject unquoted `#@ persist name=` annotations the same way: an
|
|
376
387
|
// unquoted name is dropped from the build plan, so the source would
|
|
377
388
|
// publish but never materialize. Scan the raw `.malloy` source (the
|
|
@@ -406,6 +417,7 @@ export class Package {
|
|
|
406
417
|
models,
|
|
407
418
|
malloyConfig,
|
|
408
419
|
);
|
|
420
|
+
pkg.renderTagWarnings = renderTagWarnings;
|
|
409
421
|
|
|
410
422
|
// Compute the persist build plan off the live (unbound) models, before the
|
|
411
423
|
// caller binds any configured manifest, so the surfaced plan reflects the
|
|
@@ -484,6 +496,9 @@ export class Package {
|
|
|
484
496
|
if (warnings.length > 0) {
|
|
485
497
|
metadata.exploresWarnings = warnings;
|
|
486
498
|
}
|
|
499
|
+
if (this.renderTagWarnings.length > 0) {
|
|
500
|
+
metadata.warnings = [...this.renderTagWarnings];
|
|
501
|
+
}
|
|
487
502
|
return metadata;
|
|
488
503
|
}
|
|
489
504
|
|
|
@@ -704,6 +719,7 @@ export class Package {
|
|
|
704
719
|
}
|
|
705
720
|
|
|
706
721
|
const nextModels = new Map<string, Model>();
|
|
722
|
+
const renderTagWarnings: ApiPackageWarning[] = [];
|
|
707
723
|
for (const sm of outcome.models) {
|
|
708
724
|
if (sm.compilationError) {
|
|
709
725
|
const err = Model.deserializeCompilationError(sm.compilationError);
|
|
@@ -735,7 +751,9 @@ export class Package {
|
|
|
735
751
|
// unexpected internal failure is recorded as this model's
|
|
736
752
|
// compilationError rather than aborting the whole reload.
|
|
737
753
|
try {
|
|
738
|
-
await model.validateRenderTags()
|
|
754
|
+
for (const w of await model.validateRenderTags()) {
|
|
755
|
+
renderTagWarnings.push({ model: sm.modelPath, ...w });
|
|
756
|
+
}
|
|
739
757
|
nextModels.set(sm.modelPath, model);
|
|
740
758
|
} catch (renderErr) {
|
|
741
759
|
const err =
|
|
@@ -760,6 +778,7 @@ export class Package {
|
|
|
760
778
|
}
|
|
761
779
|
}
|
|
762
780
|
this.models = nextModels;
|
|
781
|
+
this.renderTagWarnings = renderTagWarnings;
|
|
763
782
|
// A reload re-reads publisher.json in the worker; pick up any change to
|
|
764
783
|
// the explore set and query-boundary mode so listModels()/the gate
|
|
765
784
|
// reflect edited explores without a full Package.create.
|
|
@@ -316,6 +316,17 @@ source: gated is duckdb.sql("select 1 as id")`,
|
|
|
316
316
|
m.includes("nums -> card"),
|
|
317
317
|
),
|
|
318
318
|
).toBe(true);
|
|
319
|
+
// The same finding rides the package response (non-fatal) so the
|
|
320
|
+
// control plane / UI can surface it without scraping logs.
|
|
321
|
+
const responseWarnings = pkg.getPackageMetadata().warnings ?? [];
|
|
322
|
+
expect(
|
|
323
|
+
responseWarnings.some(
|
|
324
|
+
(w) =>
|
|
325
|
+
w.model === "bad_render.malloy" &&
|
|
326
|
+
w.target === "nums -> card" &&
|
|
327
|
+
w.severity === "error",
|
|
328
|
+
),
|
|
329
|
+
).toBe(true);
|
|
319
330
|
} finally {
|
|
320
331
|
warnSpy.mockRestore();
|
|
321
332
|
await duckdb.close();
|
|
@@ -337,6 +348,8 @@ source: gated is duckdb.sql("select 1 as id")`,
|
|
|
337
348
|
try {
|
|
338
349
|
const pkg = await Package.create("env", "pkg", tempDir, malloyConfig);
|
|
339
350
|
expect(pkg.getModelPaths()).toEqual(["good_render.malloy"]);
|
|
351
|
+
// A clean package omits the warnings field entirely.
|
|
352
|
+
expect(pkg.getPackageMetadata().warnings).toBeUndefined();
|
|
340
353
|
} finally {
|
|
341
354
|
await duckdb.close();
|
|
342
355
|
}
|
|
@@ -449,6 +462,14 @@ source: nums is duckdb.sql("select 1 as a, 2 as b") extend {
|
|
|
449
462
|
expect(
|
|
450
463
|
warnings.some((m) => m.includes("Invalid renderer configuration")),
|
|
451
464
|
).toBe(true);
|
|
465
|
+
// The notebook finding also rides the package response (not just logs).
|
|
466
|
+
const responseWarnings = pkg.getPackageMetadata().warnings ?? [];
|
|
467
|
+
expect(
|
|
468
|
+
responseWarnings.some(
|
|
469
|
+
(w) =>
|
|
470
|
+
w.model === "bad_render.malloynb" && w.severity === "error",
|
|
471
|
+
),
|
|
472
|
+
).toBe(true);
|
|
452
473
|
} finally {
|
|
453
474
|
warnSpy.mockRestore();
|
|
454
475
|
await duckdb.close();
|
|
@@ -499,12 +520,94 @@ source: nums is duckdb.sql("select 1 as a, 2 as b") extend {
|
|
|
499
520
|
expect(
|
|
500
521
|
warnings.some((m) => m.includes("Invalid renderer configuration")),
|
|
501
522
|
).toBe(true);
|
|
523
|
+
// Reload refreshes the response-level warnings too.
|
|
524
|
+
const responseWarnings = pkg.getPackageMetadata().warnings ?? [];
|
|
525
|
+
expect(responseWarnings.some((w) => w.model === "m.malloy")).toBe(
|
|
526
|
+
true,
|
|
527
|
+
);
|
|
502
528
|
} finally {
|
|
503
529
|
warnSpy.mockRestore();
|
|
504
530
|
await duckdb.close();
|
|
505
531
|
}
|
|
506
532
|
});
|
|
507
533
|
|
|
534
|
+
it("clears render-tag warnings on reload when a bad tag is fixed (bad -> clean)", async () => {
|
|
535
|
+
writeManifest();
|
|
536
|
+
// Start BAD so the package loads with a warning.
|
|
537
|
+
fs.writeFileSync(
|
|
538
|
+
path.join(tempDir, "m.malloy"),
|
|
539
|
+
`source: nums is duckdb.sql("select 1 as a, 2 as b") extend {
|
|
540
|
+
measure: total is a.sum()
|
|
541
|
+
# big_value { sparkline=trend }
|
|
542
|
+
view: card is {
|
|
543
|
+
aggregate: total
|
|
544
|
+
nest:
|
|
545
|
+
# line_chart { size=spark }
|
|
546
|
+
trend is { group_by: a; aggregate: total }
|
|
547
|
+
}
|
|
548
|
+
}`,
|
|
549
|
+
);
|
|
550
|
+
|
|
551
|
+
const { malloyConfig, duckdb } = await makeMalloyConfig();
|
|
552
|
+
try {
|
|
553
|
+
const pkg = await Package.create("env", "pkg", tempDir, malloyConfig);
|
|
554
|
+
expect(
|
|
555
|
+
(pkg.getPackageMetadata().warnings ?? []).some(
|
|
556
|
+
(w) => w.model === "m.malloy",
|
|
557
|
+
),
|
|
558
|
+
).toBe(true);
|
|
559
|
+
|
|
560
|
+
// Fix the tag, then reload: renderTagWarnings is rebuilt from scratch,
|
|
561
|
+
// so the stale finding must disappear (field omitted when empty).
|
|
562
|
+
fs.writeFileSync(
|
|
563
|
+
path.join(tempDir, "m.malloy"),
|
|
564
|
+
`source: nums is duckdb.sql("select 1 as a, 2 as b") extend {
|
|
565
|
+
measure: total is a.sum()
|
|
566
|
+
# bar_chart
|
|
567
|
+
view: chart is { group_by: a; aggregate: total }
|
|
568
|
+
}`,
|
|
569
|
+
);
|
|
570
|
+
await pkg.reloadAllModels({});
|
|
571
|
+
|
|
572
|
+
expect(pkg.getPackageMetadata().warnings).toBeUndefined();
|
|
573
|
+
} finally {
|
|
574
|
+
await duckdb.close();
|
|
575
|
+
}
|
|
576
|
+
});
|
|
577
|
+
|
|
578
|
+
it("aggregates render-tag warnings from multiple models, each tagged with its own path", async () => {
|
|
579
|
+
writeManifest();
|
|
580
|
+
const badModel = (view: string) =>
|
|
581
|
+
`source: nums is duckdb.sql("select 1 as a, 2 as b") extend {
|
|
582
|
+
measure: total is a.sum()
|
|
583
|
+
# big_value { sparkline=trend }
|
|
584
|
+
view: ${view} is {
|
|
585
|
+
aggregate: total
|
|
586
|
+
nest:
|
|
587
|
+
# line_chart { size=spark }
|
|
588
|
+
trend is { group_by: a; aggregate: total }
|
|
589
|
+
}
|
|
590
|
+
}`;
|
|
591
|
+
fs.writeFileSync(path.join(tempDir, "a.malloy"), badModel("card_a"));
|
|
592
|
+
fs.writeFileSync(path.join(tempDir, "b.malloy"), badModel("card_b"));
|
|
593
|
+
|
|
594
|
+
const { malloyConfig, duckdb } = await makeMalloyConfig();
|
|
595
|
+
try {
|
|
596
|
+
const pkg = await Package.create("env", "pkg", tempDir, malloyConfig);
|
|
597
|
+
const responseWarnings = pkg.getPackageMetadata().warnings ?? [];
|
|
598
|
+
const models = new Set(responseWarnings.map((w) => w.model));
|
|
599
|
+
expect(models.has("a.malloy")).toBe(true);
|
|
600
|
+
expect(models.has("b.malloy")).toBe(true);
|
|
601
|
+
expect(models.size).toBe(2);
|
|
602
|
+
// The message field is carried through, not just model/target/severity.
|
|
603
|
+
expect(
|
|
604
|
+
responseWarnings.every((w) => (w.message ?? "").length > 0),
|
|
605
|
+
).toBe(true);
|
|
606
|
+
} finally {
|
|
607
|
+
await duckdb.close();
|
|
608
|
+
}
|
|
609
|
+
});
|
|
610
|
+
|
|
508
611
|
// NB: kept last in this describe — swapping the singleton for a
|
|
509
612
|
// pre-shutdown pool also tears down the shared `pool` (the swap
|
|
510
613
|
// implementation shuts down the outgoing singleton). Subsequent
|