@malloy-publisher/server 0.0.217 → 0.0.218

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 (28) hide show
  1. package/dist/app/api-doc.yaml +98 -0
  2. package/dist/app/assets/{EnvironmentPage-BX71Wsun.js → EnvironmentPage-DxOiCxcd.js} +1 -1
  3. package/dist/app/assets/{HomePage-Bnp4Puv4.js → HomePage-i8qAtD6x.js} +1 -1
  4. package/dist/app/assets/{MainPage-chkqUlI0.js → MainPage-B58d5pmX.js} +1 -1
  5. package/dist/app/assets/{MaterializationsPage-DG4e_wRd.js → MaterializationsPage-6OEVM543.js} +1 -1
  6. package/dist/app/assets/{ModelPage-DbLU-ABs.js → ModelPage-8d5l7YkU.js} +1 -1
  7. package/dist/app/assets/{PackagePage-EvWf3VZ4.js → PackagePage-BVmoVsPQ.js} +1 -1
  8. package/dist/app/assets/{RouteError-POj8kImc.js → RouteError-DFX521_t.js} +1 -1
  9. package/dist/app/assets/{WorkbookPage-Bkq3C1MS.js → WorkbookPage-CzucDJ-T.js} +1 -1
  10. package/dist/app/assets/{core-DiLT6QvL.es-Jn6sdy15.js → core-B95VQkAV.es-Cbn-yKG-.js} +1 -1
  11. package/dist/app/assets/{index-gjr27uMq.js → index--xJ1pzL-.js} +3 -3
  12. package/dist/app/assets/{index-bAdd7U9-.js → index-Bxkza-hz.js} +1 -1
  13. package/dist/app/assets/{index-C_AT6ZZw.js → index-DxRKma36.js} +1 -1
  14. package/dist/app/assets/{index.umd-BxzPw_1E.js → index.umd-o-yUIEtS.js} +1 -1
  15. package/dist/app/index.html +1 -1
  16. package/dist/cpufeatures-1yrn0vtw.node +0 -0
  17. package/dist/server.mjs +19022 -70
  18. package/dist/sshcrypto-8m50vnmb.node +0 -0
  19. package/package.json +1 -1
  20. package/src/dto/connection.dto.ts +43 -0
  21. package/src/service/connection.ts +104 -2
  22. package/src/service/connection_config.spec.ts +116 -0
  23. package/src/service/connection_config.ts +48 -0
  24. package/src/service/model.ts +25 -3
  25. package/src/service/package.ts +21 -2
  26. package/src/service/package_worker_path.spec.ts +103 -0
  27. package/src/service/proxy.spec.ts +367 -0
  28. package/src/service/proxy.ts +233 -0
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.217",
4
+ "version": "0.0.218",
5
5
  "main": "dist/server.mjs",
6
6
  "bin": {
7
7
  "malloy-publisher": "dist/server.mjs"
@@ -1,10 +1,12 @@
1
1
  import { Type } from "class-transformer";
2
2
  import {
3
+ IsDefined,
3
4
  IsEnum,
4
5
  IsArray,
5
6
  IsNumber,
6
7
  IsOptional,
7
8
  IsString,
9
+ ValidateIf,
8
10
  ValidateNested,
9
11
  } from "class-validator";
10
12
  import "reflect-metadata";
@@ -199,6 +201,42 @@ export class DuckdbConnectionDto {
199
201
  attachedDatabases?: AttachedDatabase[];
200
202
  }
201
203
 
204
+ export class SshProxyConfigDto {
205
+ @IsString()
206
+ host!: string;
207
+
208
+ @IsOptional()
209
+ @IsNumber()
210
+ port?: number;
211
+
212
+ @IsString()
213
+ username!: string;
214
+
215
+ @IsString()
216
+ privateKey!: string;
217
+
218
+ @IsOptional()
219
+ @IsString()
220
+ privateKeyPass?: string;
221
+
222
+ @IsOptional()
223
+ @IsString()
224
+ hostKey?: string;
225
+ }
226
+
227
+ export class ConnectionProxyDto {
228
+ @IsEnum(["ssh"])
229
+ type!: "ssh";
230
+
231
+ // ssh config is required for the ssh proxy type (the only type today); keep
232
+ // the conditional so a future proxy type isn't forced to supply `ssh`.
233
+ @ValidateIf((o) => o.type === "ssh")
234
+ @IsDefined()
235
+ @ValidateNested()
236
+ @Type(() => SshProxyConfigDto)
237
+ ssh?: SshProxyConfigDto;
238
+ }
239
+
202
240
  export class ConnectionDto implements ApiConnection {
203
241
  @IsOptional()
204
242
  @IsString()
@@ -256,4 +294,9 @@ export class ConnectionDto implements ApiConnection {
256
294
  @ValidateNested()
257
295
  @Type(() => DuckdbConnectionDto)
258
296
  duckdbConnection?: DuckdbConnectionDto;
297
+
298
+ @IsOptional()
299
+ @ValidateNested()
300
+ @Type(() => ConnectionProxyDto)
301
+ proxy?: ConnectionProxyDto;
259
302
  }
@@ -6,7 +6,10 @@ import "@malloydata/db-duckdb/native";
6
6
  import "@malloydata/db-mysql";
7
7
  import type { MySQLConnection } from "@malloydata/db-mysql";
8
8
  import "@malloydata/db-postgres";
9
- import type { PostgresConnection } from "@malloydata/db-postgres";
9
+ import {
10
+ PooledPostgresConnection,
11
+ type PostgresConnection,
12
+ } from "@malloydata/db-postgres";
10
13
  // Registers the "publisher" connection type (proxies SQL to a remote Publisher
11
14
  // dataplane). No live-class branch is needed in lookupConnection — the default
12
15
  // path resolves it through the registry like any other registered type.
@@ -41,6 +44,7 @@ import {
41
44
  normalizeSnowflakePrivateKey,
42
45
  } from "./connection_config";
43
46
  import { CloudStorageCredentials } from "./gcs_s3_utils";
47
+ import { openProxy, type ProxyEndpoint } from "./proxy";
44
48
 
45
49
  type AttachedDatabase = components["schemas"]["AttachedDatabase"];
46
50
  type ApiConnection = components["schemas"]["Connection"];
@@ -921,6 +925,30 @@ function buildSnowflakePrivateKeyConnection(
921
925
  });
922
926
  }
923
927
 
928
+ function buildProxiedPostgresConnection(
929
+ metadata: EnvironmentConnectionMetadata,
930
+ endpoint: ProxyEndpoint,
931
+ ): PooledPostgresConnection {
932
+ const name = metadata.apiConnection.name!;
933
+ const pg = metadata.apiConnection.postgresConnection;
934
+ if (!pg) {
935
+ throw new Error(
936
+ `Proxied connection '${name}' has type 'postgres' but no postgresConnection config.`,
937
+ );
938
+ }
939
+ return new PooledPostgresConnection({
940
+ name,
941
+ host: endpoint.host,
942
+ port: endpoint.port,
943
+ username: pg.userName,
944
+ password: pg.password,
945
+ databaseName: pg.databaseName,
946
+ // Pool sizing mirrors buildSnowflakePrivateKeyConnection.
947
+ poolMin: 1,
948
+ poolMax: 20,
949
+ });
950
+ }
951
+
924
952
  function buildDuckLakeConnection(
925
953
  metadata: EnvironmentConnectionMetadata,
926
954
  entry: CoreConnectionEntry,
@@ -987,6 +1015,11 @@ export function buildEnvironmentMalloyConfig(
987
1015
  const duckLakeCache = new Map<string, Promise<DuckLakeConnection>>();
988
1016
  const snowflakeJwtCache = new Map<string, Promise<SnowflakeConnection>>();
989
1017
  const azureDuckDBCache = new Map<string, Promise<AzureDuckDBConnection>>();
1018
+ const proxyConnectionCache = new Map<
1019
+ string,
1020
+ Promise<PooledPostgresConnection>
1021
+ >();
1022
+ const proxyEndpoints = new Map<string, ProxyEndpoint>();
990
1023
  const attachPromises = new WeakMap<Connection, Promise<void>>();
991
1024
 
992
1025
  const malloyConfig = new MalloyConfig(assembled.pojo, {
@@ -1044,6 +1077,64 @@ export function buildEnvironmentMalloyConfig(
1044
1077
  return connection;
1045
1078
  }
1046
1079
 
1080
+ if (metadata?.proxy) {
1081
+ let connectionPromise = proxyConnectionCache.get(name!);
1082
+ if (!connectionPromise) {
1083
+ const connType = metadata.apiConnection.type;
1084
+ if (connType !== "postgres") {
1085
+ throw new Error(
1086
+ `SSH proxy is not yet supported for connection type '${connType}'. ` +
1087
+ `Only 'postgres' is implemented.`,
1088
+ );
1089
+ }
1090
+ // validateConnectionShape already enforces explicit host/port
1091
+ // at config load (the connectionString form can't be tunneled);
1092
+ // this is a defense-in-depth guard for any path that reaches
1093
+ // lookup without that validation.
1094
+ const pgConfig = metadata.apiConnection.postgresConnection;
1095
+ if (!pgConfig?.host || !pgConfig?.port) {
1096
+ throw new Error(
1097
+ `SSH proxy for connection '${name}' requires explicit host and ` +
1098
+ `port on the postgres connection; the connectionString form ` +
1099
+ `is not supported with a proxy.`,
1100
+ );
1101
+ }
1102
+ connectionPromise = (async () => {
1103
+ const endpoint = await openProxy(metadata.proxy!, {
1104
+ host: pgConfig.host!,
1105
+ port: pgConfig.port!,
1106
+ });
1107
+ try {
1108
+ const connection = buildProxiedPostgresConnection(
1109
+ metadata,
1110
+ endpoint,
1111
+ );
1112
+ // Register only after the build succeeds: a throw between
1113
+ // openProxy and here would otherwise leave an orphaned
1114
+ // tunnel in proxyEndpoints that a retry can't reach (the
1115
+ // outer .catch evicts the cache but not the endpoint).
1116
+ proxyEndpoints.set(name!, endpoint);
1117
+ return connection;
1118
+ } catch (err) {
1119
+ await endpoint.close().catch(() => {});
1120
+ throw err;
1121
+ }
1122
+ })();
1123
+ // Drop a rejected build from the cache so a later lookup can
1124
+ // retry, rather than replaying a transient SSH failure (bastion
1125
+ // restart, network blip) until the environment is rebuilt.
1126
+ connectionPromise.catch(() => {
1127
+ if (
1128
+ proxyConnectionCache.get(name!) === connectionPromise
1129
+ ) {
1130
+ proxyConnectionCache.delete(name!);
1131
+ }
1132
+ });
1133
+ proxyConnectionCache.set(name!, connectionPromise);
1134
+ }
1135
+ return connectionPromise;
1136
+ }
1137
+
1047
1138
  if (metadata?.hasSnowflakePrivateKey) {
1048
1139
  let connectionPromise = snowflakeJwtCache.get(name!);
1049
1140
  if (!connectionPromise) {
@@ -1086,6 +1177,7 @@ export function buildEnvironmentMalloyConfig(
1086
1177
  ...duckLakeCache.values(),
1087
1178
  ...snowflakeJwtCache.values(),
1088
1179
  ...azureDuckDBCache.values(),
1180
+ ...proxyConnectionCache.values(),
1089
1181
  ];
1090
1182
  const closeResults = await Promise.allSettled([
1091
1183
  malloyConfig.shutdown("close"),
@@ -1094,11 +1186,21 @@ export function buildEnvironmentMalloyConfig(
1094
1186
  await connection.close();
1095
1187
  }),
1096
1188
  ]);
1189
+ // Close proxy tunnels only AFTER the build promises above have settled:
1190
+ // openProxy may still be in flight during a teardown race, and the
1191
+ // endpoint is registered inside that promise — capturing the map
1192
+ // earlier would miss it and leak the tunnel. Closing after the DB
1193
+ // connections also keeps the tunnel alive while they drain.
1194
+ const endpointResults = await Promise.allSettled(
1195
+ [...proxyEndpoints.values()].map((ep) => ep.close()),
1196
+ );
1097
1197
  duckLakeCache.clear();
1098
1198
  snowflakeJwtCache.clear();
1099
1199
  azureDuckDBCache.clear();
1200
+ proxyConnectionCache.clear();
1201
+ proxyEndpoints.clear();
1100
1202
 
1101
- const failures = closeResults.filter(
1203
+ const failures = [...closeResults, ...endpointResults].filter(
1102
1204
  (result): result is PromiseRejectedResult =>
1103
1205
  result.status === "rejected",
1104
1206
  );
@@ -349,3 +349,119 @@ describe("assembleEnvironmentConnections — publisher", () => {
349
349
  });
350
350
  });
351
351
  });
352
+
353
+ describe("SSH proxy validation", () => {
354
+ const validSshProxy: ApiConnection = {
355
+ name: "pg-via-bastion",
356
+ type: "postgres",
357
+ postgresConnection: {
358
+ host: "127.0.0.1",
359
+ port: 5432,
360
+ databaseName: "mydb",
361
+ userName: "user",
362
+ password: "pass",
363
+ },
364
+ proxy: {
365
+ type: "ssh",
366
+ ssh: {
367
+ host: "bastion.example.com",
368
+ username: "ec2-user",
369
+ privateKey:
370
+ "-----BEGIN OPENSSH PRIVATE KEY-----\nfake\n-----END OPENSSH PRIVATE KEY-----",
371
+ },
372
+ },
373
+ };
374
+
375
+ it("allows a postgres+ssh-proxy connection with no env flag required", () => {
376
+ // The SSH proxy is a normal connection capability — deliberately NOT gated
377
+ // by PUBLISHER_ALLOW_PROXY_CONNECTIONS (that flag is for the `publisher`
378
+ // HTTP multi-hop type). Prove it assembles with the flag unset.
379
+ const priorFlag = process.env.PUBLISHER_ALLOW_PROXY_CONNECTIONS;
380
+ delete process.env.PUBLISHER_ALLOW_PROXY_CONNECTIONS;
381
+ try {
382
+ const { pojo } = assembleEnvironmentConnections([validSshProxy]);
383
+ expect(pojo.connections["pg-via-bastion"].is).toBe("postgres");
384
+ } finally {
385
+ if (priorFlag === undefined) {
386
+ delete process.env.PUBLISHER_ALLOW_PROXY_CONNECTIONS;
387
+ } else {
388
+ process.env.PUBLISHER_ALLOW_PROXY_CONNECTIONS = priorFlag;
389
+ }
390
+ }
391
+ });
392
+
393
+ it("rejects a non-postgres (bigquery) connection with a proxy", () => {
394
+ const conn: ApiConnection = {
395
+ name: "bq-via-bastion",
396
+ type: "bigquery",
397
+ bigqueryConnection: {
398
+ serviceAccountKeyJson: JSON.stringify({
399
+ type: "service_account",
400
+ project_id: "proj",
401
+ private_key: "key",
402
+ client_email: "sa@proj.iam.gserviceaccount.com",
403
+ }),
404
+ },
405
+ proxy: {
406
+ type: "ssh",
407
+ ssh: {
408
+ host: "bastion.example.com",
409
+ username: "ec2-user",
410
+ privateKey: "key",
411
+ },
412
+ },
413
+ };
414
+ expect(() => assembleEnvironmentConnections([conn])).toThrow(
415
+ "Connection proxy is not supported for type 'bigquery' (only 'postgres' today)",
416
+ );
417
+ });
418
+
419
+ it("rejects an ssh proxy with no ssh config object", () => {
420
+ const conn: ApiConnection = {
421
+ name: "pg-no-ssh-config",
422
+ type: "postgres",
423
+ postgresConnection: {
424
+ host: "127.0.0.1",
425
+ databaseName: "mydb",
426
+ },
427
+ proxy: {
428
+ type: "ssh",
429
+ },
430
+ };
431
+ expect(() => assembleEnvironmentConnections([conn])).toThrow(
432
+ "Connection proxy on 'pg-no-ssh-config' has type 'ssh' but no 'ssh' config object",
433
+ );
434
+ });
435
+
436
+ it("rejects a proxied postgres connection that also carries a connectionString", () => {
437
+ const conn: ApiConnection = {
438
+ ...validSshProxy,
439
+ name: "pg-with-connstring",
440
+ postgresConnection: {
441
+ connectionString: "postgresql://real-db.example.com:5432/mydb",
442
+ host: "127.0.0.1",
443
+ port: 5432,
444
+ databaseName: "mydb",
445
+ },
446
+ };
447
+ // The tunnel forwards to host/port; a connectionString would be silently
448
+ // ignored and could point at a different database, so it's rejected.
449
+ expect(() => assembleEnvironmentConnections([conn])).toThrow(
450
+ "does not support the connectionString form",
451
+ );
452
+ });
453
+
454
+ it("rejects a proxied postgres connection missing explicit host/port", () => {
455
+ const conn: ApiConnection = {
456
+ ...validSshProxy,
457
+ name: "pg-no-host-port",
458
+ postgresConnection: {
459
+ databaseName: "mydb",
460
+ userName: "user",
461
+ },
462
+ };
463
+ expect(() => assembleEnvironmentConnections([conn])).toThrow(
464
+ "requires explicit host and port",
465
+ );
466
+ });
467
+ });
@@ -22,6 +22,7 @@ export type EnvironmentConnectionMetadata = {
22
22
  isDuckLake: boolean;
23
23
  databasePath?: string;
24
24
  workingDirectory: string;
25
+ proxy?: ApiConnection["proxy"];
25
26
  };
26
27
 
27
28
  export type AssembledEnvironmentConnections = {
@@ -258,6 +259,52 @@ function buildDuckdbEntry(
258
259
  }
259
260
 
260
261
  function validateConnectionShape(connection: ApiConnection): void {
262
+ if (connection.proxy) {
263
+ // A connection proxy makes THIS server open an outbound SSH tunnel to a
264
+ // tenant-configured bastion. It's a normal connection capability,
265
+ // authorized by whoever configures the connection — deliberately NOT gated
266
+ // by an env flag, and kept separate from the `publisher` HTTP multi-hop
267
+ // type's PUBLISHER_ALLOW_PROXY_CONNECTIONS gate below (that flag is about
268
+ // publisher-to-publisher proxying, a distinct operator decision). Host-key
269
+ // pinning is fail-closed at connect time (see openProxy).
270
+ if (connection.proxy.type !== "ssh") {
271
+ throw new Error(
272
+ `Connection '${connection.name}' has an unsupported proxy type '${connection.proxy.type}'. Only 'ssh' is supported.`,
273
+ );
274
+ }
275
+ if (connection.type !== "postgres") {
276
+ throw new Error(
277
+ `Connection proxy is not supported for type '${connection.type}' (only 'postgres' today).`,
278
+ );
279
+ }
280
+ if (!connection.proxy.ssh) {
281
+ throw new Error(
282
+ `Connection proxy on '${connection.name}' has type 'ssh' but no 'ssh' config object.`,
283
+ );
284
+ }
285
+ // The tunnel forwards to an explicit host:port; the connectionString form
286
+ // can't be rewritten to the local endpoint. Reject it outright when a
287
+ // proxy is set — normal postgres gives connectionString precedence over
288
+ // host/port, so a config carrying BOTH would silently tunnel to
289
+ // host/port and ignore the connectionString, connecting to a different
290
+ // database than the operator configured. Require discrete host/port.
291
+ if (connection.postgresConnection?.connectionString) {
292
+ throw new Error(
293
+ `Connection proxy on '${connection.name}' does not support the connectionString form; ` +
294
+ `provide discrete host and port instead (the tunnel forwards to an explicit endpoint).`,
295
+ );
296
+ }
297
+ if (
298
+ !connection.postgresConnection?.host ||
299
+ !connection.postgresConnection?.port
300
+ ) {
301
+ throw new Error(
302
+ `Connection proxy on '${connection.name}' requires explicit host and port on the ` +
303
+ `postgres connection; the connectionString form is not supported with a proxy.`,
304
+ );
305
+ }
306
+ }
307
+
261
308
  switch (connection.type) {
262
309
  case "postgres":
263
310
  case "mysql":
@@ -445,6 +492,7 @@ export function assembleEnvironmentConnections(
445
492
  isDuckLake,
446
493
  databasePath,
447
494
  workingDirectory: environmentPath,
495
+ proxy: connection.proxy,
448
496
  });
449
497
 
450
498
  switch (connection.type) {
@@ -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<void> {
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> {
@@ -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
- await model.validateRenderTags();
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.