@malloy-publisher/server 0.0.231 → 0.0.233
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/README.docker.md +4 -0
- package/dist/app/api-doc.yaml +135 -13
- package/dist/app/assets/{EnvironmentPage-wa_EPkwK.js → EnvironmentPage-DutP7T8h.js} +1 -1
- package/dist/app/assets/{HomePage-jnCrupQp.js → HomePage-BcxDrBfl.js} +1 -1
- package/dist/app/assets/{LightMode-DYbwNULZ.js → LightMode-BJukGxgz.js} +1 -1
- package/dist/app/assets/{MainPage-CuJLrPNI.js → MainPage-DXbwlMeF.js} +2 -2
- package/dist/app/assets/{MaterializationsPage-D_67x2ee.js → MaterializationsPage-BBQksmTU.js} +1 -1
- package/dist/app/assets/{ModelPage-D5JtAWqR.js → ModelPage-C6tK51uU.js} +1 -1
- package/dist/app/assets/{PackagePage-BRwtqUSG.js → PackagePage-Bo3cwwZE.js} +1 -1
- package/dist/app/assets/{RouteError-CBNNrnSD.js → RouteError-BufkcAKE.js} +1 -1
- package/dist/app/assets/{ThemeEditorPage-CTCeBneA.js → ThemeEditorPage-DICvvKpa.js} +1 -1
- package/dist/app/assets/{WorkbookPage-SN6f1RBm.js → WorkbookPage-Dkwt75Nj.js} +1 -1
- package/dist/app/assets/{core-Dp3q5Ieu.es-CD5FvM2s.js → core-C0nunIQT.es-DlMLKZBK.js} +1 -1
- package/dist/app/assets/{index-DU4r7GdU.js → index-BabP-V-S.js} +346 -321
- package/dist/app/assets/{index-BLCx1EdC.js → index-BusxL5Pt.js} +1 -1
- package/dist/app/assets/{index-C_tJstcx.js → index-CmEVVe-8.js} +15 -15
- package/dist/app/assets/{index-CfmBVB6M.js → index-Cs4WVm2z.js} +1 -1
- package/dist/app/assets/{index-B3Nn8Vm2.js → index-qnhU9CGo.js} +266 -265
- package/dist/app/index.html +1 -1
- package/dist/instrumentation.mjs +2 -0
- package/dist/package_load_worker.mjs +11 -1
- package/dist/server.mjs +22627 -1260
- package/package.json +12 -12
- package/scripts/bake-duckdb-extensions.js +4 -1
- package/src/config.spec.ts +39 -0
- package/src/config.ts +135 -0
- package/src/controller/materialization.controller.spec.ts +62 -0
- package/src/controller/materialization.controller.ts +15 -0
- package/src/controller/package.controller.spec.ts +6 -0
- package/src/controller/package.controller.ts +7 -2
- package/src/controller/query.controller.ts +26 -21
- package/src/errors.ts +19 -0
- package/src/json_utils.spec.ts +51 -0
- package/src/json_utils.ts +33 -0
- package/src/logger.spec.ts +18 -1
- package/src/logger.ts +3 -1
- package/src/materialization_metrics.spec.ts +89 -4
- package/src/materialization_metrics.ts +155 -5
- package/src/mcp/query_envelope.spec.ts +229 -0
- package/src/mcp/query_envelope.ts +230 -0
- package/src/mcp/server.protocol.spec.ts +128 -16
- package/src/mcp/skills/build_skills_bundle.ts +94 -4
- package/src/mcp/skills/skills_bundle.json +1 -1
- package/src/mcp/skills/skills_bundle.spec.ts +113 -4
- package/src/mcp/tool_response.spec.ts +108 -0
- package/src/mcp/tool_response.ts +138 -0
- package/src/mcp/tools/compile_tool.spec.ts +112 -4
- package/src/mcp/tools/compile_tool.ts +61 -30
- package/src/mcp/tools/docs_search_tool.ts +6 -16
- package/src/mcp/tools/embedding_index.spec.ts +1236 -0
- package/src/mcp/tools/embedding_index.ts +808 -0
- package/src/mcp/tools/execute_query_tool.spec.ts +23 -3
- package/src/mcp/tools/execute_query_tool.ts +90 -151
- package/src/mcp/tools/get_context_eval.ts +194 -45
- package/src/mcp/tools/get_context_tool.spec.ts +358 -5
- package/src/mcp/tools/get_context_tool.ts +202 -56
- package/src/mcp/tools/reload_package_tool.ts +3 -29
- package/src/pg_helpers.spec.ts +201 -0
- package/src/pg_helpers.ts +44 -5
- package/src/server.ts +24 -0
- package/src/service/build_plan.spec.ts +128 -2
- package/src/service/build_plan.ts +239 -17
- package/src/service/compile_fragment_techniques.spec.ts +156 -0
- package/src/service/connection.spec.ts +371 -1
- package/src/service/connection.ts +339 -20
- package/src/service/connection_config.spec.ts +108 -0
- package/src/service/connection_config.ts +47 -8
- package/src/service/connection_federation.spec.ts +184 -0
- package/src/service/duckdb_instance_isolation.spec.ts +137 -0
- package/src/service/embedding_provider.spec.ts +329 -0
- package/src/service/embedding_provider.ts +236 -0
- package/src/service/environment.ts +274 -12
- package/src/service/environment_store.spec.ts +678 -3
- package/src/service/environment_store.ts +449 -33
- package/src/service/environment_store_clone.spec.ts +350 -0
- package/src/service/manifest_loader.spec.ts +68 -13
- package/src/service/manifest_loader.ts +67 -19
- package/src/service/materialization_build_session.spec.ts +435 -0
- package/src/service/materialization_build_session.ts +681 -0
- package/src/service/materialization_eligibility.spec.ts +158 -0
- package/src/service/materialization_eligibility.ts +305 -0
- package/src/service/materialization_serve_transform.spec.ts +1003 -0
- package/src/service/materialization_serve_transform.ts +779 -0
- package/src/service/materialization_service.spec.ts +774 -7
- package/src/service/materialization_service.ts +1107 -42
- package/src/service/materialization_test_fixtures.ts +7 -0
- package/src/service/model.spec.ts +207 -0
- package/src/service/model.ts +569 -59
- package/src/service/model_limits.spec.ts +28 -0
- package/src/service/model_limits.ts +21 -0
- package/src/service/model_storage_serve.spec.ts +193 -0
- package/src/service/model_storage_serve_joins.spec.ts +193 -0
- package/src/service/package.spec.ts +196 -0
- package/src/service/package.ts +385 -17
- package/src/service/persistence_policy.spec.ts +109 -0
- package/src/storage/duckdb/schema.ts +37 -0
- package/tests/fixtures/xlsx/database.xlsx +0 -0
- package/tests/integration/first_boot/readiness_line.integration.spec.ts +177 -0
- package/tests/integration/materialization/manifest_binding.integration.spec.ts +104 -0
- package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +37 -12
- package/tests/integration/mcp/mcp_get_context_semantic.integration.spec.ts +235 -0
|
@@ -52,6 +52,7 @@ import {
|
|
|
52
52
|
} from "./connection_config";
|
|
53
53
|
import { CloudStorageCredentials } from "./gcs_s3_utils";
|
|
54
54
|
import { openProxy, type ProxyEndpoint } from "./proxy";
|
|
55
|
+
import { quoteIdentifier } from "./quoting";
|
|
55
56
|
|
|
56
57
|
type AttachedDatabase = components["schemas"]["AttachedDatabase"];
|
|
57
58
|
type ApiConnection = components["schemas"]["Connection"];
|
|
@@ -148,7 +149,7 @@ export async function applyExtensionSessionSettings(
|
|
|
148
149
|
}
|
|
149
150
|
}
|
|
150
151
|
|
|
151
|
-
async function installAndLoadExtension(
|
|
152
|
+
export async function installAndLoadExtension(
|
|
152
153
|
connection: DuckDBConnection,
|
|
153
154
|
extensionName: string,
|
|
154
155
|
fromCommunity = false,
|
|
@@ -232,7 +233,7 @@ function sanitizeSecretName(name: string): string {
|
|
|
232
233
|
return `secret_${name.replace(/[^a-zA-Z0-9_]/g, "_")}`;
|
|
233
234
|
}
|
|
234
235
|
|
|
235
|
-
function escapeSQL(value: string): string {
|
|
236
|
+
export function escapeSQL(value: string): string {
|
|
236
237
|
return value.replace(/'/g, "''");
|
|
237
238
|
}
|
|
238
239
|
|
|
@@ -476,33 +477,72 @@ function runSQLRows(result: unknown): Record<string, unknown>[] {
|
|
|
476
477
|
* the fixed name (which would make every later preflight for this connection
|
|
477
478
|
* fail its ATTACH with "already exists" and silently skip), and so two
|
|
478
479
|
* concurrent first-touch lookups can't cross-DETACH each other.
|
|
480
|
+
*
|
|
481
|
+
* {@link metadataSchema} must be the catalog's configured
|
|
482
|
+
* `catalog.metadataSchema`, because `ducklake_metadata` lives in that schema
|
|
483
|
+
* rather than the catalog connection's default one. Getting this wrong is not
|
|
484
|
+
* loud: the read would simply miss, the catch below would log and return, and the
|
|
485
|
+
* range check would stop protecting precisely the catalogs that set the option.
|
|
479
486
|
*/
|
|
480
487
|
let ducklakePreflightSeq = 0;
|
|
481
488
|
async function preflightDuckLakeCatalogFormat(
|
|
482
489
|
connection: DuckDBConnection,
|
|
483
490
|
dbName: string,
|
|
484
491
|
pgConnString: string,
|
|
492
|
+
metadataSchema?: string,
|
|
485
493
|
): Promise<void> {
|
|
486
494
|
const tempDb = `${dbName}_fmt_preflight_${++ducklakePreflightSeq}`;
|
|
495
|
+
// Identifier position, not a string literal, so the schema is double-quoted.
|
|
496
|
+
// Measured: with today's validator this is belt-and-braces rather than a fix —
|
|
497
|
+
// every name the regex admits resolves correctly unquoted, including reserved
|
|
498
|
+
// words (DuckDB parses them fine here) and mixed case (matching is
|
|
499
|
+
// case-insensitive). It is quoted anyway because this preflight fails SOFT: a
|
|
500
|
+
// read that misses logs and returns, silently disabling format range-checking
|
|
501
|
+
// for that connection, so the cost of ever getting this wrong is invisible.
|
|
502
|
+
// Quoting makes correctness local to this line instead of contingent on the
|
|
503
|
+
// validator's accept-set, so widening that regex later cannot break it. Safe
|
|
504
|
+
// by construction: the regex admits no quote character to break out with.
|
|
505
|
+
const metadataRef = metadataSchema
|
|
506
|
+
? `${tempDb}."${metadataSchema}".ducklake_metadata`
|
|
507
|
+
: `${tempDb}.ducklake_metadata`;
|
|
487
508
|
let catalogFormat: string | undefined;
|
|
488
509
|
try {
|
|
489
510
|
await connection.runSQL(
|
|
490
511
|
`ATTACH '${escapeSQL(pgConnString)}' AS ${tempDb} (TYPE postgres, READ_ONLY);`,
|
|
491
512
|
);
|
|
492
513
|
const result = await connection.runSQL(
|
|
493
|
-
`SELECT value FROM ${
|
|
514
|
+
`SELECT value FROM ${metadataRef} WHERE key = 'version' LIMIT 1;`,
|
|
494
515
|
);
|
|
495
516
|
const value = runSQLRows(result)[0]?.value;
|
|
496
517
|
catalogFormat = typeof value === "string" ? value : undefined;
|
|
497
518
|
} catch (error) {
|
|
519
|
+
const message = redactPgSecrets(
|
|
520
|
+
error instanceof Error ? error.message : String(error),
|
|
521
|
+
);
|
|
522
|
+
// A named metadata schema holding no catalog has two very different causes,
|
|
523
|
+
// and the preflight cannot tell them apart: it is the NORMAL state before the
|
|
524
|
+
// first read-write attach creates the catalog, and it is also what a typo (or
|
|
525
|
+
// adding `metadataSchema` to a catalog whose metadata lives elsewhere) looks
|
|
526
|
+
// like. Neither is loud on its own — a read-write attach CREATES an empty
|
|
527
|
+
// catalog there and materializes into it, and a read-only attach fails with an
|
|
528
|
+
// error that says nothing about the schema — so name the schema and say both,
|
|
529
|
+
// rather than implying a mistake on a path that is expected to hit this once
|
|
530
|
+
// per catalog. The message also has to hedge on the cause: `does not exist`
|
|
531
|
+
// matches a missing database or role too, not only a missing table.
|
|
532
|
+
if (metadataSchema !== undefined && /does not exist/i.test(message)) {
|
|
533
|
+
logger.warn(
|
|
534
|
+
"No DuckLake catalog found in the configured metadata schema. This is " +
|
|
535
|
+
"expected the first time a catalog is created there: a read-write " +
|
|
536
|
+
"attach will create it, and a read-only attach will fail until it " +
|
|
537
|
+
"exists. Otherwise check the schema name, and the catalog database " +
|
|
538
|
+
"and role, against the error.",
|
|
539
|
+
{ dbName, metadataSchema, error: message },
|
|
540
|
+
);
|
|
541
|
+
return;
|
|
542
|
+
}
|
|
498
543
|
logger.warn(
|
|
499
544
|
"DuckLake catalog-format preflight read failed; falling back to ATTACH",
|
|
500
|
-
{
|
|
501
|
-
dbName,
|
|
502
|
-
error: redactPgSecrets(
|
|
503
|
-
error instanceof Error ? error.message : String(error),
|
|
504
|
-
),
|
|
505
|
-
},
|
|
545
|
+
{ dbName, error: message },
|
|
506
546
|
);
|
|
507
547
|
return;
|
|
508
548
|
} finally {
|
|
@@ -574,10 +614,21 @@ async function preflightDuckLakeCatalogFormat(
|
|
|
574
614
|
);
|
|
575
615
|
}
|
|
576
616
|
|
|
577
|
-
|
|
617
|
+
/**
|
|
618
|
+
* Attach a DuckLake catalog in a given access mode. `readOnly: true` (the live
|
|
619
|
+
* user-facing connection path) attaches with `READ_ONLY true`; `readOnly: false`
|
|
620
|
+
* produces the read-WRITE build variant used only on a transient, build-scoped
|
|
621
|
+
* session so a materialization can `CREATE TABLE` into the catalog. Both keep
|
|
622
|
+
* `OVERRIDE_DATA_PATH true` and NEVER pass `AUTOMATIC_MIGRATION` — catalog-format
|
|
623
|
+
* migration is a coordinated fleet cutover, never an implicit side effect of a
|
|
624
|
+
* build attach. Both range-preflight the catalog format first (see
|
|
625
|
+
* {@link preflightDuckLakeCatalogFormat}).
|
|
626
|
+
*/
|
|
627
|
+
async function attachDuckLakeWithMode(
|
|
578
628
|
connection: DuckDBConnection,
|
|
579
629
|
dbName: string,
|
|
580
630
|
ducklakeConfig: components["schemas"]["DucklakeConnection"],
|
|
631
|
+
options: { readOnly: boolean },
|
|
581
632
|
): Promise<void> {
|
|
582
633
|
// Tier build/serve DuckLake sessions never implicitly auto-install (Publisher
|
|
583
634
|
// installs the DuckLake stack explicitly below) — regardless of the policy.
|
|
@@ -618,26 +669,54 @@ async function attachDuckLake(
|
|
|
618
669
|
|
|
619
670
|
const pg = ducklakeConfig.catalog.postgresConnection;
|
|
620
671
|
const pgConnString: string = buildPgConnectionString(pg);
|
|
621
|
-
|
|
622
|
-
//
|
|
623
|
-
|
|
672
|
+
const mode = options.readOnly ? "READ_ONLY" : "READ_WRITE";
|
|
673
|
+
// READ_ONLY: the client manages metadata, we only read the catalog.
|
|
674
|
+
// READ_WRITE (build only): a build-scoped session materializes into it.
|
|
675
|
+
// Debug, not info. These three lines — this one, the escaped form below, and the
|
|
676
|
+
// assembled ATTACH — are per-attach diagnostics that between them print the catalog
|
|
677
|
+
// host and database twice and the storage path once. `redactPgSecrets` removes the
|
|
678
|
+
// password and any URI userinfo, but a DATA_PATH is not a secret it knows about, so
|
|
679
|
+
// logging the assembled command at info discloses the bucket and whatever the prefix
|
|
680
|
+
// encodes to every reader of the logs. What an operator watching a healthy fleet
|
|
681
|
+
// needs is the mode and the outcome, which stay at info below.
|
|
682
|
+
logger.debug(`pgConnString: ${redactPgSecrets(pgConnString)}`);
|
|
624
683
|
const escapedPgConnString = escapeSQL(pgConnString);
|
|
625
|
-
logger.
|
|
684
|
+
logger.debug(
|
|
626
685
|
`Final escaped connection string: ${redactPgSecrets(escapedPgConnString)}`,
|
|
627
686
|
);
|
|
628
687
|
const escapedBucketUrl = escapeSQL(ducklakeConfig.storage.bucketUrl);
|
|
629
|
-
|
|
688
|
+
// Optional metadata schema: which schema in the catalog database holds this
|
|
689
|
+
// DuckLake's `ducklake_*` tables. Absent keeps DuckLake's default (the catalog
|
|
690
|
+
// connection's default schema), so the emitted command is unchanged for every
|
|
691
|
+
// existing config. Validated to a plain identifier at config load, because it
|
|
692
|
+
// reaches a quoted literal here and an identifier position in the preflight.
|
|
693
|
+
const metadataSchema = ducklakeConfig.catalog.metadataSchema;
|
|
630
694
|
// Range-preflight the catalog's recorded format version so an unsupported
|
|
631
|
-
// catalog fails as a clean, actionable 422 rather than a deep DuckDB 500.
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
695
|
+
// catalog fails as a clean, actionable 422 rather than a deep DuckDB 500. The
|
|
696
|
+
// schema must be threaded through: the preflight reads `ducklake_metadata`, so
|
|
697
|
+
// when the metadata does not live in the catalog's default schema an unqualified
|
|
698
|
+
// read misses it — and the preflight fails SOFT (logs and returns), so the range
|
|
699
|
+
// check would silently stop protecting exactly the catalogs using this option.
|
|
700
|
+
await preflightDuckLakeCatalogFormat(
|
|
701
|
+
connection,
|
|
702
|
+
dbName,
|
|
703
|
+
pgConnString,
|
|
704
|
+
metadataSchema,
|
|
705
|
+
);
|
|
706
|
+
// READ_ONLY is stated explicitly; read-write omits the flag (DuckLake's
|
|
707
|
+
// default is writable). AUTOMATIC_MIGRATION is never set in either mode.
|
|
708
|
+
const readOnlyClause = options.readOnly ? ", READ_ONLY true" : "";
|
|
709
|
+
const metadataSchemaClause = metadataSchema
|
|
710
|
+
? `, METADATA_SCHEMA '${escapeSQL(metadataSchema)}'`
|
|
711
|
+
: "";
|
|
712
|
+
const attachCommand = `ATTACH OR REPLACE 'ducklake:postgres:${escapedPgConnString}' AS ${dbName} (DATA_PATH '${escapedBucketUrl}', OVERRIDE_DATA_PATH true${readOnlyClause}${metadataSchemaClause});`;
|
|
713
|
+
logger.debug(
|
|
635
714
|
`Attaching DuckLake database using command: ${redactPgSecrets(attachCommand)}`,
|
|
636
715
|
);
|
|
637
716
|
try {
|
|
638
717
|
await connection.runSQL(attachCommand);
|
|
639
718
|
logger.info(
|
|
640
|
-
`Successfully attached DuckLake database in
|
|
719
|
+
`Successfully attached DuckLake database in ${mode} mode: ${dbName}`,
|
|
641
720
|
);
|
|
642
721
|
} catch (error) {
|
|
643
722
|
// Handle case where DuckLake database is already attached
|
|
@@ -655,6 +734,246 @@ async function attachDuckLake(
|
|
|
655
734
|
}
|
|
656
735
|
}
|
|
657
736
|
|
|
737
|
+
async function attachDuckLake(
|
|
738
|
+
connection: DuckDBConnection,
|
|
739
|
+
dbName: string,
|
|
740
|
+
ducklakeConfig: components["schemas"]["DucklakeConnection"],
|
|
741
|
+
): Promise<void> {
|
|
742
|
+
await attachDuckLakeWithMode(connection, dbName, ducklakeConfig, {
|
|
743
|
+
readOnly: true,
|
|
744
|
+
});
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/**
|
|
748
|
+
* Read-WRITE DuckLake attach for a materialization build. Identical to the
|
|
749
|
+
* read-only user-facing attach except the catalog is writable, so a build-scoped
|
|
750
|
+
* session can `CREATE TABLE ... AS` into it. Least privilege: this variant is
|
|
751
|
+
* only ever used on a transient build session (never the serve connection), for
|
|
752
|
+
* the duration of one build. Like the read-only attach it is lazy and never on
|
|
753
|
+
* the worker boot path.
|
|
754
|
+
*/
|
|
755
|
+
export async function attachDuckLakeReadWrite(
|
|
756
|
+
connection: DuckDBConnection,
|
|
757
|
+
dbName: string,
|
|
758
|
+
ducklakeConfig: components["schemas"]["DucklakeConnection"],
|
|
759
|
+
): Promise<void> {
|
|
760
|
+
await attachDuckLakeWithMode(connection, dbName, ducklakeConfig, {
|
|
761
|
+
readOnly: false,
|
|
762
|
+
});
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/** A source warehouse type that exposes a native DuckDB query-passthrough. */
|
|
766
|
+
export type FederatedSourceType = "bigquery" | "snowflake" | "postgres";
|
|
767
|
+
|
|
768
|
+
/**
|
|
769
|
+
* The result of federating a source connection onto a build session: the
|
|
770
|
+
* `handle` a passthrough table function needs to reach it —
|
|
771
|
+
* - bigquery: the resolved project id (`bigquery_query('<project>', '<sql>')`),
|
|
772
|
+
* - snowflake: the created secret name (`snowflake_query('<sql>', '<secret>')`),
|
|
773
|
+
* - postgres: the ATTACH alias (`postgres_query('<alias>', '<sql>')`).
|
|
774
|
+
*/
|
|
775
|
+
export interface FederatedHandle {
|
|
776
|
+
handle: string;
|
|
777
|
+
sourceType: FederatedSourceType;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* The source connection's configuration needed to federate it onto a build
|
|
782
|
+
* session. `name` is a caller-supplied, already-safe base for the secret/alias
|
|
783
|
+
* identifiers; the type-specific config carries the credentials.
|
|
784
|
+
*/
|
|
785
|
+
export interface FederationConfig {
|
|
786
|
+
name: string;
|
|
787
|
+
bigqueryConnection?: components["schemas"]["BigqueryConnection"];
|
|
788
|
+
snowflakeConnection?: components["schemas"]["SnowflakeConnection"];
|
|
789
|
+
postgresConnection?: components["schemas"]["PostgresConnection"];
|
|
790
|
+
}
|
|
791
|
+
|
|
792
|
+
/**
|
|
793
|
+
* Establish a SOURCE warehouse's credentials on a build-scoped DuckDB session so
|
|
794
|
+
* a native query-passthrough (`bigquery_query`/`snowflake_query`/`postgres_query`)
|
|
795
|
+
* can push the compiled SELECT to that warehouse and stream back result rows.
|
|
796
|
+
* This is the credential-federation half of the build path: it runs ONLY on the
|
|
797
|
+
* transient build session, on demand, for the build's duration — the source
|
|
798
|
+
* creds are never established on the serve connection and never persisted into a
|
|
799
|
+
* connection config. It mirrors the existing read-only attach handlers but
|
|
800
|
+
* targets the passthrough functions (bigquery/snowflake need only a SECRET, no
|
|
801
|
+
* ATTACH; postgres needs an ATTACH whose alias the function references).
|
|
802
|
+
*
|
|
803
|
+
* @returns the {@link FederatedHandle} the caller passes to `wrapPassthrough`.
|
|
804
|
+
* @throws for a source type without a native passthrough, or missing/invalid
|
|
805
|
+
* credentials, so a build fails loud rather than producing a wrong table.
|
|
806
|
+
*/
|
|
807
|
+
export async function federateSourceForPassthrough(
|
|
808
|
+
connection: DuckDBConnection,
|
|
809
|
+
sourceType: FederatedSourceType,
|
|
810
|
+
config: FederationConfig,
|
|
811
|
+
): Promise<FederatedHandle> {
|
|
812
|
+
switch (sourceType) {
|
|
813
|
+
case "bigquery":
|
|
814
|
+
return federateBigQuery(connection, config);
|
|
815
|
+
case "snowflake":
|
|
816
|
+
return federateSnowflake(connection, config);
|
|
817
|
+
case "postgres":
|
|
818
|
+
return federatePostgres(connection, config);
|
|
819
|
+
default: {
|
|
820
|
+
// Exhaustiveness guard: a new FederatedSourceType must add a branch.
|
|
821
|
+
const exhaustive: never = sourceType;
|
|
822
|
+
throw new Error(
|
|
823
|
+
`No native query-passthrough for source type '${String(exhaustive)}'`,
|
|
824
|
+
);
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
async function federateBigQuery(
|
|
830
|
+
connection: DuckDBConnection,
|
|
831
|
+
config: FederationConfig,
|
|
832
|
+
): Promise<FederatedHandle> {
|
|
833
|
+
const bq = config.bigqueryConnection;
|
|
834
|
+
if (!bq) {
|
|
835
|
+
throw new Error(
|
|
836
|
+
`BigQuery connection configuration missing for: ${config.name}`,
|
|
837
|
+
);
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
let projectId = bq.defaultProjectId;
|
|
841
|
+
let serviceAccountJson: string | undefined;
|
|
842
|
+
if (bq.serviceAccountKeyJson) {
|
|
843
|
+
const keyData = JSON.parse(bq.serviceAccountKeyJson as string);
|
|
844
|
+
const requiredFields = [
|
|
845
|
+
"type",
|
|
846
|
+
"project_id",
|
|
847
|
+
"private_key",
|
|
848
|
+
"client_email",
|
|
849
|
+
];
|
|
850
|
+
for (const field of requiredFields) {
|
|
851
|
+
if (!keyData[field]) {
|
|
852
|
+
throw new Error(
|
|
853
|
+
`Invalid service account key: missing "${field}" field`,
|
|
854
|
+
);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
if (keyData.type !== "service_account") {
|
|
858
|
+
throw new Error('Invalid service account key: incorrect "type" field');
|
|
859
|
+
}
|
|
860
|
+
projectId = keyData.project_id || bq.defaultProjectId;
|
|
861
|
+
serviceAccountJson = bq.serviceAccountKeyJson as string;
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
if (!projectId || !serviceAccountJson) {
|
|
865
|
+
throw new Error(
|
|
866
|
+
`BigQuery project_id and service account key required for: ${config.name}`,
|
|
867
|
+
);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
await installAndLoadExtension(connection, "bigquery", true);
|
|
871
|
+
|
|
872
|
+
const secretName = sanitizeSecretName(`bigquery_${config.name}`);
|
|
873
|
+
const escapedJson = escapeSQL(serviceAccountJson);
|
|
874
|
+
await connection.runSQL(`
|
|
875
|
+
CREATE OR REPLACE SECRET ${secretName} (
|
|
876
|
+
TYPE BIGQUERY,
|
|
877
|
+
SCOPE 'bq://${projectId}',
|
|
878
|
+
SERVICE_ACCOUNT_JSON '${escapedJson}'
|
|
879
|
+
);
|
|
880
|
+
`);
|
|
881
|
+
logger.info(
|
|
882
|
+
`Federated BigQuery source for passthrough: project ${projectId}`,
|
|
883
|
+
);
|
|
884
|
+
// bigquery_query resolves credentials from the secret scoped to bq://<project>
|
|
885
|
+
// — the project id is the passthrough handle; no ATTACH is needed.
|
|
886
|
+
return { handle: projectId, sourceType: "bigquery" };
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
async function federateSnowflake(
|
|
890
|
+
connection: DuckDBConnection,
|
|
891
|
+
config: FederationConfig,
|
|
892
|
+
): Promise<FederatedHandle> {
|
|
893
|
+
const sf = config.snowflakeConnection;
|
|
894
|
+
if (!sf) {
|
|
895
|
+
throw new Error(
|
|
896
|
+
`Snowflake connection configuration missing for: ${config.name}`,
|
|
897
|
+
);
|
|
898
|
+
}
|
|
899
|
+
const required = {
|
|
900
|
+
account: sf.account,
|
|
901
|
+
username: sf.username,
|
|
902
|
+
password: sf.password,
|
|
903
|
+
};
|
|
904
|
+
for (const [field, value] of Object.entries(required)) {
|
|
905
|
+
if (!value) {
|
|
906
|
+
throw new Error(`Snowflake ${field} is required for: ${config.name}`);
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
await installAndLoadExtension(connection, "snowflake", true);
|
|
911
|
+
|
|
912
|
+
const params = {
|
|
913
|
+
account: escapeSQL(sf.account || ""),
|
|
914
|
+
user: escapeSQL(sf.username || ""),
|
|
915
|
+
password: escapeSQL(sf.password || ""),
|
|
916
|
+
database: sf.database ? escapeSQL(sf.database) : undefined,
|
|
917
|
+
warehouse: sf.warehouse ? escapeSQL(sf.warehouse) : undefined,
|
|
918
|
+
};
|
|
919
|
+
const secretName = sanitizeSecretName(`snowflake_${config.name}`);
|
|
920
|
+
// DATABASE/WAREHOUSE are optional — emit them only when supplied, so an
|
|
921
|
+
// absent one doesn't interpolate the literal string 'undefined' into the
|
|
922
|
+
// secret (which Snowflake would then try to use as a real db/warehouse name).
|
|
923
|
+
const secretLines = [
|
|
924
|
+
` TYPE snowflake`,
|
|
925
|
+
` ACCOUNT '${params.account}'`,
|
|
926
|
+
` USER '${params.user}'`,
|
|
927
|
+
` PASSWORD '${params.password}'`,
|
|
928
|
+
...(params.database ? [` DATABASE '${params.database}'`] : []),
|
|
929
|
+
...(params.warehouse ? [` WAREHOUSE '${params.warehouse}'`] : []),
|
|
930
|
+
];
|
|
931
|
+
await connection.runSQL(
|
|
932
|
+
`CREATE OR REPLACE SECRET ${secretName} (\n${secretLines.join(",\n")}\n);`,
|
|
933
|
+
);
|
|
934
|
+
logger.info(`Federated Snowflake source for passthrough: ${secretName}`);
|
|
935
|
+
// snowflake_query takes the SQL first and the secret name second — the secret
|
|
936
|
+
// name is the passthrough handle; no ATTACH is needed.
|
|
937
|
+
return { handle: secretName, sourceType: "snowflake" };
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
async function federatePostgres(
|
|
941
|
+
connection: DuckDBConnection,
|
|
942
|
+
config: FederationConfig,
|
|
943
|
+
): Promise<FederatedHandle> {
|
|
944
|
+
const pg = config.postgresConnection;
|
|
945
|
+
if (!pg) {
|
|
946
|
+
throw new Error(
|
|
947
|
+
`PostgreSQL connection configuration missing for: ${config.name}`,
|
|
948
|
+
);
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
await installAndLoadExtension(connection, "postgres");
|
|
952
|
+
|
|
953
|
+
const attachString = buildPgConnectionString(pg);
|
|
954
|
+
// `name` is the ATTACH alias AND (verbatim) the postgres_query handle, so the
|
|
955
|
+
// handle stays the raw name while the ATTACH identifier is dialect-quoted —
|
|
956
|
+
// a name needing quoting (e.g. a hyphen) would otherwise be a parser error.
|
|
957
|
+
const alias = config.name;
|
|
958
|
+
logger.info(
|
|
959
|
+
`Federating Postgres source for passthrough as alias '${alias}': ${redactPgSecrets(attachString)}`,
|
|
960
|
+
);
|
|
961
|
+
// OR REPLACE for within-session idempotency. The cross-build/cross-tenant
|
|
962
|
+
// alias-collision boundary is the build session's PRIVATE DuckDB instance
|
|
963
|
+
// (see createIsolatedBuildSession): each build runs on its own instance, so
|
|
964
|
+
// its postgres attach cannot collide with another build's or another tenant's
|
|
965
|
+
// on a shared instance. OR REPLACE is kept as belt-and-suspenders for a
|
|
966
|
+
// re-attach of the identical source within one session (the alias is the
|
|
967
|
+
// connection name, the config is that connection's, so replacing is a no-op
|
|
968
|
+
// rebind to the same source). (bigquery/snowflake federate via CREATE OR
|
|
969
|
+
// REPLACE SECRET with no ATTACH; secrets are instance-scoped, so isolation
|
|
970
|
+
// covers them too.)
|
|
971
|
+
await connection.runSQL(
|
|
972
|
+
`ATTACH OR REPLACE '${escapeSQL(attachString)}' AS ${quoteIdentifier(alias, "duckdb")} (TYPE postgres, READ_ONLY);`,
|
|
973
|
+
);
|
|
974
|
+
return { handle: alias, sourceType: "postgres" };
|
|
975
|
+
}
|
|
976
|
+
|
|
658
977
|
async function attachCloudStorage(
|
|
659
978
|
connection: DuckDBConnection,
|
|
660
979
|
attachedDb: AttachedDatabase,
|
|
@@ -624,3 +624,111 @@ describe("SSH proxy validation", () => {
|
|
|
624
624
|
);
|
|
625
625
|
});
|
|
626
626
|
});
|
|
627
|
+
|
|
628
|
+
describe("ducklake shape validation", () => {
|
|
629
|
+
// A DuckLake destination is only attached at its first BUILD, so without an
|
|
630
|
+
// eager check a missing catalog or bucketUrl surfaced hours after the config
|
|
631
|
+
// change that caused it. Validated at load like its duckdb-family siblings.
|
|
632
|
+
const valid: ApiConnection = {
|
|
633
|
+
name: "lake",
|
|
634
|
+
type: "ducklake",
|
|
635
|
+
ducklakeConnection: {
|
|
636
|
+
catalog: {
|
|
637
|
+
postgresConnection: {
|
|
638
|
+
host: "127.0.0.1",
|
|
639
|
+
port: 5432,
|
|
640
|
+
databaseName: "catalog",
|
|
641
|
+
userName: "u",
|
|
642
|
+
password: "p",
|
|
643
|
+
},
|
|
644
|
+
},
|
|
645
|
+
storage: { bucketUrl: "/tmp/lake" },
|
|
646
|
+
},
|
|
647
|
+
};
|
|
648
|
+
|
|
649
|
+
it("accepts a fully configured destination", () => {
|
|
650
|
+
expect(() =>
|
|
651
|
+
assembleEnvironmentConnections([valid], "/tmp/env"),
|
|
652
|
+
).not.toThrow();
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
it("rejects a missing catalog connection at load", () => {
|
|
656
|
+
const c = {
|
|
657
|
+
...valid,
|
|
658
|
+
ducklakeConnection: { storage: { bucketUrl: "/tmp/lake" } },
|
|
659
|
+
} as ApiConnection;
|
|
660
|
+
expect(() => assembleEnvironmentConnections([c], "/tmp/env")).toThrow(
|
|
661
|
+
/PostgreSQL connection configuration is required for DuckLake catalog/i,
|
|
662
|
+
);
|
|
663
|
+
});
|
|
664
|
+
|
|
665
|
+
it("rejects a missing bucketUrl at load", () => {
|
|
666
|
+
const c = {
|
|
667
|
+
...valid,
|
|
668
|
+
ducklakeConnection: { catalog: valid.ducklakeConnection!.catalog },
|
|
669
|
+
} as ApiConnection;
|
|
670
|
+
expect(() => assembleEnvironmentConnections([c], "/tmp/env")).toThrow(
|
|
671
|
+
/Storage bucketUrl is required for DuckLake/i,
|
|
672
|
+
);
|
|
673
|
+
});
|
|
674
|
+
|
|
675
|
+
// metadataSchema reaches TWO grammars: a quoted string literal in the ATTACH,
|
|
676
|
+
// and a quoted identifier in the catalog-format preflight's table reference.
|
|
677
|
+
// Restricting it to a plain identifier at load is what keeps one value valid in
|
|
678
|
+
// both, so these pin the accept/reject boundary rather than trusting escaping.
|
|
679
|
+
const withSchema = (metadataSchema: unknown): ApiConnection =>
|
|
680
|
+
({
|
|
681
|
+
...valid,
|
|
682
|
+
ducklakeConnection: {
|
|
683
|
+
...valid.ducklakeConnection,
|
|
684
|
+
catalog: {
|
|
685
|
+
...valid.ducklakeConnection!.catalog,
|
|
686
|
+
metadataSchema,
|
|
687
|
+
},
|
|
688
|
+
},
|
|
689
|
+
}) as ApiConnection;
|
|
690
|
+
|
|
691
|
+
it("accepts an absent metadataSchema", () => {
|
|
692
|
+
// Optional: absence keeps DuckLake's default schema, the prior behavior.
|
|
693
|
+
expect(() =>
|
|
694
|
+
assembleEnvironmentConnections([valid], "/tmp/env"),
|
|
695
|
+
).not.toThrow();
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
it("accepts a plain identifier metadataSchema", () => {
|
|
699
|
+
for (const ok of ["org_a", "_private", "Lake1", "a"]) {
|
|
700
|
+
expect(() =>
|
|
701
|
+
assembleEnvironmentConnections([withSchema(ok)], "/tmp/env"),
|
|
702
|
+
).not.toThrow();
|
|
703
|
+
}
|
|
704
|
+
});
|
|
705
|
+
|
|
706
|
+
it("rejects a metadataSchema that is not a plain identifier", () => {
|
|
707
|
+
for (const bad of [
|
|
708
|
+
"foo'; DROP TABLE x; --",
|
|
709
|
+
"has space",
|
|
710
|
+
"dotted.name",
|
|
711
|
+
"1leading_digit",
|
|
712
|
+
"",
|
|
713
|
+
'"quoted"',
|
|
714
|
+
]) {
|
|
715
|
+
expect(() =>
|
|
716
|
+
assembleEnvironmentConnections([withSchema(bad)], "/tmp/env"),
|
|
717
|
+
).toThrow(/metadataSchema must be a plain identifier/i);
|
|
718
|
+
}
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
it("rejects a non-string metadataSchema rather than coercing it", () => {
|
|
722
|
+
// The value comes from untyped JSON and RegExp.test() coerces, so `true` and
|
|
723
|
+
// `null` match the identifier pattern as "true"/"null" and would pass a
|
|
724
|
+
// pattern-only check — then reach escapeSQL's String.replace as a non-string
|
|
725
|
+
// and throw TypeError at the connection's first attach. That runtime failure is
|
|
726
|
+
// the thing this load-time check exists to prevent, so the type is part of the
|
|
727
|
+
// contract, not a formality.
|
|
728
|
+
for (const bad of [true, false, 0, 1, null, {}, [], ["org_a"]]) {
|
|
729
|
+
expect(() =>
|
|
730
|
+
assembleEnvironmentConnections([withSchema(bad)], "/tmp/env"),
|
|
731
|
+
).toThrow(/metadataSchema must be a plain identifier/i);
|
|
732
|
+
}
|
|
733
|
+
});
|
|
734
|
+
});
|
|
@@ -425,6 +425,52 @@ function validateConnectionShape(connection: ApiConnection): void {
|
|
|
425
425
|
throw new Error("MotherDuck access token is required.");
|
|
426
426
|
}
|
|
427
427
|
break;
|
|
428
|
+
case "ducklake":
|
|
429
|
+
// Every field the ATTACH demands (see attachDuckLakeWithMode), checked
|
|
430
|
+
// together here rather than split between this validator and the pojo
|
|
431
|
+
// assembler below. `bucketUrl` was the one nobody checked: a destination
|
|
432
|
+
// is only attached at its first BUILD, so a missing bucket surfaced
|
|
433
|
+
// hours after the config change that caused it.
|
|
434
|
+
if (!connection.ducklakeConnection) {
|
|
435
|
+
throw new Error("DuckLake connection configuration is missing.");
|
|
436
|
+
}
|
|
437
|
+
if (!connection.ducklakeConnection.catalog?.postgresConnection) {
|
|
438
|
+
throw new Error(
|
|
439
|
+
`PostgreSQL connection configuration is required for DuckLake catalog: ${connection.name}`,
|
|
440
|
+
);
|
|
441
|
+
}
|
|
442
|
+
if (!connection.ducklakeConnection.storage?.bucketUrl) {
|
|
443
|
+
throw new Error(
|
|
444
|
+
`Storage bucketUrl is required for DuckLake: ${connection.name}`,
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
// metadataSchema is optional, but when present it reaches the ATTACH as a
|
|
448
|
+
// quoted string literal AND the catalog-format preflight as a quoted
|
|
449
|
+
// identifier. Rather than escape one value for two grammars, restrict it
|
|
450
|
+
// to a plain identifier here — a deterministic config error, caught at
|
|
451
|
+
// load instead of at the connection's first attach.
|
|
452
|
+
//
|
|
453
|
+
// The typeof check is load-bearing, not defensive: the value arrives from
|
|
454
|
+
// untyped JSON, and RegExp.test() coerces its argument, so `true` and `null`
|
|
455
|
+
// both satisfy the pattern as "true"/"null" and would reach escapeSQL's
|
|
456
|
+
// String.replace as a non-string — a TypeError at the first attach, which is
|
|
457
|
+
// exactly the failure this check exists to turn into a config error.
|
|
458
|
+
if (
|
|
459
|
+
connection.ducklakeConnection.catalog.metadataSchema !== undefined
|
|
460
|
+
) {
|
|
461
|
+
const schema = connection.ducklakeConnection.catalog.metadataSchema;
|
|
462
|
+
if (
|
|
463
|
+
typeof schema !== "string" ||
|
|
464
|
+
!/^[A-Za-z_][A-Za-z0-9_]*$/.test(schema)
|
|
465
|
+
) {
|
|
466
|
+
throw new Error(
|
|
467
|
+
`DuckLake catalog metadataSchema must be a plain identifier ` +
|
|
468
|
+
`([A-Za-z_][A-Za-z0-9_]*), got '${schema}' for connection: ` +
|
|
469
|
+
`${connection.name}`,
|
|
470
|
+
);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
break;
|
|
428
474
|
case "trino":
|
|
429
475
|
if (!connection.trinoConnection) {
|
|
430
476
|
throw new Error("Trino connection configuration is missing.");
|
|
@@ -725,14 +771,7 @@ export function assembleEnvironmentConnections(
|
|
|
725
771
|
}
|
|
726
772
|
|
|
727
773
|
case "ducklake": {
|
|
728
|
-
|
|
729
|
-
throw new Error("DuckLake connection configuration is missing.");
|
|
730
|
-
}
|
|
731
|
-
if (!connection.ducklakeConnection.catalog?.postgresConnection) {
|
|
732
|
-
throw new Error(
|
|
733
|
-
`PostgreSQL connection configuration is required for DuckLake catalog: ${connection.name}`,
|
|
734
|
-
);
|
|
735
|
-
}
|
|
774
|
+
// Shape is validated up front by validateConnectionShape.
|
|
736
775
|
pojo.connections[connection.name] = buildDuckdbEntry(
|
|
737
776
|
connection.name,
|
|
738
777
|
environmentPath,
|