@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.
- package/dist/app/api-doc.yaml +98 -0
- package/dist/app/assets/{EnvironmentPage-BX71Wsun.js → EnvironmentPage-DxOiCxcd.js} +1 -1
- package/dist/app/assets/{HomePage-Bnp4Puv4.js → HomePage-i8qAtD6x.js} +1 -1
- package/dist/app/assets/{MainPage-chkqUlI0.js → MainPage-B58d5pmX.js} +1 -1
- package/dist/app/assets/{MaterializationsPage-DG4e_wRd.js → MaterializationsPage-6OEVM543.js} +1 -1
- package/dist/app/assets/{ModelPage-DbLU-ABs.js → ModelPage-8d5l7YkU.js} +1 -1
- package/dist/app/assets/{PackagePage-EvWf3VZ4.js → PackagePage-BVmoVsPQ.js} +1 -1
- package/dist/app/assets/{RouteError-POj8kImc.js → RouteError-DFX521_t.js} +1 -1
- package/dist/app/assets/{WorkbookPage-Bkq3C1MS.js → WorkbookPage-CzucDJ-T.js} +1 -1
- package/dist/app/assets/{core-DiLT6QvL.es-Jn6sdy15.js → core-B95VQkAV.es-Cbn-yKG-.js} +1 -1
- package/dist/app/assets/{index-gjr27uMq.js → index--xJ1pzL-.js} +3 -3
- package/dist/app/assets/{index-bAdd7U9-.js → index-Bxkza-hz.js} +1 -1
- package/dist/app/assets/{index-C_AT6ZZw.js → index-DxRKma36.js} +1 -1
- package/dist/app/assets/{index.umd-BxzPw_1E.js → index.umd-o-yUIEtS.js} +1 -1
- package/dist/app/index.html +1 -1
- package/dist/cpufeatures-1yrn0vtw.node +0 -0
- package/dist/server.mjs +19022 -70
- package/dist/sshcrypto-8m50vnmb.node +0 -0
- package/package.json +1 -1
- package/src/dto/connection.dto.ts +43 -0
- package/src/service/connection.ts +104 -2
- package/src/service/connection_config.spec.ts +116 -0
- package/src/service/connection_config.ts +48 -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 +367 -0
- package/src/service/proxy.ts +233 -0
|
Binary file
|
package/package.json
CHANGED
|
@@ -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
|
|
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) {
|
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.
|