@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.
Files changed (101) hide show
  1. package/README.docker.md +4 -0
  2. package/dist/app/api-doc.yaml +135 -13
  3. package/dist/app/assets/{EnvironmentPage-wa_EPkwK.js → EnvironmentPage-DutP7T8h.js} +1 -1
  4. package/dist/app/assets/{HomePage-jnCrupQp.js → HomePage-BcxDrBfl.js} +1 -1
  5. package/dist/app/assets/{LightMode-DYbwNULZ.js → LightMode-BJukGxgz.js} +1 -1
  6. package/dist/app/assets/{MainPage-CuJLrPNI.js → MainPage-DXbwlMeF.js} +2 -2
  7. package/dist/app/assets/{MaterializationsPage-D_67x2ee.js → MaterializationsPage-BBQksmTU.js} +1 -1
  8. package/dist/app/assets/{ModelPage-D5JtAWqR.js → ModelPage-C6tK51uU.js} +1 -1
  9. package/dist/app/assets/{PackagePage-BRwtqUSG.js → PackagePage-Bo3cwwZE.js} +1 -1
  10. package/dist/app/assets/{RouteError-CBNNrnSD.js → RouteError-BufkcAKE.js} +1 -1
  11. package/dist/app/assets/{ThemeEditorPage-CTCeBneA.js → ThemeEditorPage-DICvvKpa.js} +1 -1
  12. package/dist/app/assets/{WorkbookPage-SN6f1RBm.js → WorkbookPage-Dkwt75Nj.js} +1 -1
  13. package/dist/app/assets/{core-Dp3q5Ieu.es-CD5FvM2s.js → core-C0nunIQT.es-DlMLKZBK.js} +1 -1
  14. package/dist/app/assets/{index-DU4r7GdU.js → index-BabP-V-S.js} +346 -321
  15. package/dist/app/assets/{index-BLCx1EdC.js → index-BusxL5Pt.js} +1 -1
  16. package/dist/app/assets/{index-C_tJstcx.js → index-CmEVVe-8.js} +15 -15
  17. package/dist/app/assets/{index-CfmBVB6M.js → index-Cs4WVm2z.js} +1 -1
  18. package/dist/app/assets/{index-B3Nn8Vm2.js → index-qnhU9CGo.js} +266 -265
  19. package/dist/app/index.html +1 -1
  20. package/dist/instrumentation.mjs +2 -0
  21. package/dist/package_load_worker.mjs +11 -1
  22. package/dist/server.mjs +22627 -1260
  23. package/package.json +12 -12
  24. package/scripts/bake-duckdb-extensions.js +4 -1
  25. package/src/config.spec.ts +39 -0
  26. package/src/config.ts +135 -0
  27. package/src/controller/materialization.controller.spec.ts +62 -0
  28. package/src/controller/materialization.controller.ts +15 -0
  29. package/src/controller/package.controller.spec.ts +6 -0
  30. package/src/controller/package.controller.ts +7 -2
  31. package/src/controller/query.controller.ts +26 -21
  32. package/src/errors.ts +19 -0
  33. package/src/json_utils.spec.ts +51 -0
  34. package/src/json_utils.ts +33 -0
  35. package/src/logger.spec.ts +18 -1
  36. package/src/logger.ts +3 -1
  37. package/src/materialization_metrics.spec.ts +89 -4
  38. package/src/materialization_metrics.ts +155 -5
  39. package/src/mcp/query_envelope.spec.ts +229 -0
  40. package/src/mcp/query_envelope.ts +230 -0
  41. package/src/mcp/server.protocol.spec.ts +128 -16
  42. package/src/mcp/skills/build_skills_bundle.ts +94 -4
  43. package/src/mcp/skills/skills_bundle.json +1 -1
  44. package/src/mcp/skills/skills_bundle.spec.ts +113 -4
  45. package/src/mcp/tool_response.spec.ts +108 -0
  46. package/src/mcp/tool_response.ts +138 -0
  47. package/src/mcp/tools/compile_tool.spec.ts +112 -4
  48. package/src/mcp/tools/compile_tool.ts +61 -30
  49. package/src/mcp/tools/docs_search_tool.ts +6 -16
  50. package/src/mcp/tools/embedding_index.spec.ts +1236 -0
  51. package/src/mcp/tools/embedding_index.ts +808 -0
  52. package/src/mcp/tools/execute_query_tool.spec.ts +23 -3
  53. package/src/mcp/tools/execute_query_tool.ts +90 -151
  54. package/src/mcp/tools/get_context_eval.ts +194 -45
  55. package/src/mcp/tools/get_context_tool.spec.ts +358 -5
  56. package/src/mcp/tools/get_context_tool.ts +202 -56
  57. package/src/mcp/tools/reload_package_tool.ts +3 -29
  58. package/src/pg_helpers.spec.ts +201 -0
  59. package/src/pg_helpers.ts +44 -5
  60. package/src/server.ts +24 -0
  61. package/src/service/build_plan.spec.ts +128 -2
  62. package/src/service/build_plan.ts +239 -17
  63. package/src/service/compile_fragment_techniques.spec.ts +156 -0
  64. package/src/service/connection.spec.ts +371 -1
  65. package/src/service/connection.ts +339 -20
  66. package/src/service/connection_config.spec.ts +108 -0
  67. package/src/service/connection_config.ts +47 -8
  68. package/src/service/connection_federation.spec.ts +184 -0
  69. package/src/service/duckdb_instance_isolation.spec.ts +137 -0
  70. package/src/service/embedding_provider.spec.ts +329 -0
  71. package/src/service/embedding_provider.ts +236 -0
  72. package/src/service/environment.ts +274 -12
  73. package/src/service/environment_store.spec.ts +678 -3
  74. package/src/service/environment_store.ts +449 -33
  75. package/src/service/environment_store_clone.spec.ts +350 -0
  76. package/src/service/manifest_loader.spec.ts +68 -13
  77. package/src/service/manifest_loader.ts +67 -19
  78. package/src/service/materialization_build_session.spec.ts +435 -0
  79. package/src/service/materialization_build_session.ts +681 -0
  80. package/src/service/materialization_eligibility.spec.ts +158 -0
  81. package/src/service/materialization_eligibility.ts +305 -0
  82. package/src/service/materialization_serve_transform.spec.ts +1003 -0
  83. package/src/service/materialization_serve_transform.ts +779 -0
  84. package/src/service/materialization_service.spec.ts +774 -7
  85. package/src/service/materialization_service.ts +1107 -42
  86. package/src/service/materialization_test_fixtures.ts +7 -0
  87. package/src/service/model.spec.ts +207 -0
  88. package/src/service/model.ts +569 -59
  89. package/src/service/model_limits.spec.ts +28 -0
  90. package/src/service/model_limits.ts +21 -0
  91. package/src/service/model_storage_serve.spec.ts +193 -0
  92. package/src/service/model_storage_serve_joins.spec.ts +193 -0
  93. package/src/service/package.spec.ts +196 -0
  94. package/src/service/package.ts +385 -17
  95. package/src/service/persistence_policy.spec.ts +109 -0
  96. package/src/storage/duckdb/schema.ts +37 -0
  97. package/tests/fixtures/xlsx/database.xlsx +0 -0
  98. package/tests/integration/first_boot/readiness_line.integration.spec.ts +177 -0
  99. package/tests/integration/materialization/manifest_binding.integration.spec.ts +104 -0
  100. package/tests/integration/mcp/mcp_execute_query_tool.integration.spec.ts +37 -12
  101. 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 ${tempDb}.ducklake_metadata WHERE key = 'version' LIMIT 1;`,
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
- async function attachDuckLake(
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
- // Attach DuckLake with Postgres catalog and cloud storage data path in READ_ONLY mode
622
- // The client manages metadata - we only read from the catalogs
623
- logger.info(`pgConnString: ${redactPgSecrets(pgConnString)}`);
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.info(
684
+ logger.debug(
626
685
  `Final escaped connection string: ${redactPgSecrets(escapedPgConnString)}`,
627
686
  );
628
687
  const escapedBucketUrl = escapeSQL(ducklakeConfig.storage.bucketUrl);
629
- logger.info(`escapedBucketUrl: ${escapedBucketUrl}`);
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
- await preflightDuckLakeCatalogFormat(connection, dbName, pgConnString);
633
- const attachCommand = `ATTACH OR REPLACE 'ducklake:postgres:${escapedPgConnString}' AS ${dbName} (DATA_PATH '${escapedBucketUrl}', OVERRIDE_DATA_PATH true, READ_ONLY true);`;
634
- logger.info(
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 READ_ONLY mode: ${dbName}`,
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
- if (!connection.ducklakeConnection) {
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,